diff options
| author | MtBntChvn <[email protected]> | 2026-02-20 16:29:19 +0000 |
|---|---|---|
| committer | MtBntChvn <[email protected]> | 2026-02-20 16:29:19 +0000 |
| commit | 3007541d5347064bd54f4aa52d46862c4c19efbc (patch) | |
| tree | dd6300ec8589396657b308944ea999eef533f818 | |
| parent | add uniform view navigation links to dashboard pages (diff) | |
| download | zen-3007541d5347064bd54f4aa52d46862c4c19efbc.tar.xz zen-3007541d5347064bd54f4aa52d46862c4c19efbc.zip | |
WIP: add real data mode to graph debug playground
Connect the graph debug playground to live zenstore API data via
project/oplog URL parameters. Builds a path prefix tree from the
indexer as the entry point for navigating large oplogs (1M+ entries).
- dual-mode: no params = existing synthetic mode, with params = real data
- prefix tree built from indexer.enum_all(), shown as group nodes
- group nodes expand into sub-groups or individual entries
- entry nodes fetch deps via CBO API with legacy fallback
- dep IDs resolved via indexer.lookup_id() with hex fallback
- async expansion with loading pulse animation
- has_deps pre-check for children (same pattern as graph.js)
Still TODO: testing with live data, edge cases, polish.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
| -rw-r--r-- | src/zenserver/frontend/html/pages/graph-debug-playground.js | 2584 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 269 |
2 files changed, 2791 insertions, 62 deletions
diff --git a/src/zenserver/frontend/html/pages/graph-debug-playground.js b/src/zenserver/frontend/html/pages/graph-debug-playground.js new file mode 100644 index 000000000..9fa644b52 --- /dev/null +++ b/src/zenserver/frontend/html/pages/graph-debug-playground.js @@ -0,0 +1,2584 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Toolbar, ProgressBar } from "../util/widgets.js" +import { Friendly } from "../util/friendly.js" +import { create_indexer } from "../indexer/indexer.js" + +//////////////////////////////////////////////////////////////////////////////// +function css_var(name) +{ + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +const NODE_PAD = 20; +const NODE_H = 32; +const NODE_R = 6; +const MAX_VISIBLE_DEPS = 50; +const DOT_SPACE = 10; + +const _measure_canvas = document.createElement("canvas"); +const _measure_ctx = _measure_canvas.getContext("2d"); +_measure_ctx.font = "11px consolas, monospace"; + +function short_name(opkey) +{ + const parts = opkey.replace(/\/$/, "").split("/"); + return parts[parts.length - 1] || opkey; +} + +function measure_node_width(label) +{ + return _measure_ctx.measureText(label).width + NODE_PAD * 2 + DOT_SPACE; +} + +function lerp_color(a, b, t) +{ + const pa = [parseInt(a.slice(1,3),16), parseInt(a.slice(3,5),16), parseInt(a.slice(5,7),16)]; + const pb = [parseInt(b.slice(1,3),16), parseInt(b.slice(3,5),16), parseInt(b.slice(5,7),16)]; + const r = Math.round(pa[0] + (pb[0] - pa[0]) * t); + const g = Math.round(pa[1] + (pb[1] - pa[1]) * t); + const bl = Math.round(pa[2] + (pb[2] - pa[2]) * t); + return "rgb(" + r + "," + g + "," + bl + ")"; +} + +//////////////////////////////////////////////////////////////////////////////// +// Barnes-Hut quadtree for O(n log n) repulsion + +class QuadTree +{ + constructor(x, y, size) + { + this.x = x; // center x + this.y = y; // center y + this.size = size; // half-width + this.mass = 0; + this.com_x = 0; // center of mass x + this.com_y = 0; // center of mass y + this.node = null; // leaf node (if single) + this.children = null; // [NW, NE, SW, SE] or null + } + + insert(node) + { + if (this.mass === 0) + { + this.node = node; + this.mass = 1; + this.com_x = node.x; + this.com_y = node.y; + return; + } + + // update center of mass + const new_mass = this.mass + 1; + this.com_x = (this.com_x * this.mass + node.x) / new_mass; + this.com_y = (this.com_y * this.mass + node.y) / new_mass; + this.mass = new_mass; + + if (!this.children) + { + this.children = [null, null, null, null]; + if (this.node) + { + this._insert_child(this.node); + this.node = null; + } + } + this._insert_child(node); + } + + _insert_child(node) + { + const hs = this.size / 2; + const idx = (node.x >= this.x ? 1 : 0) + (node.y >= this.y ? 2 : 0); + const cx = this.x + (idx & 1 ? hs : -hs); + const cy = this.y + (idx & 2 ? hs : -hs); + + if (!this.children[idx]) + this.children[idx] = new QuadTree(cx, cy, hs); + this.children[idx].insert(node); + } + + compute_force(node, repulsion, theta) + { + if (this.mass === 0) return [0, 0]; + + var dx = node.x - this.com_x; + var dy = node.y - this.com_y; + var dist_sq = dx * dx + dy * dy; + if (dist_sq < 1) dist_sq = 1; + + // leaf with single node — direct calculation + if (!this.children && this.node) + { + if (this.node === node) return [0, 0]; + const f = repulsion / dist_sq; + const dist = Math.sqrt(dist_sq); + return [f * dx / dist, f * dy / dist]; + } + + // Barnes-Hut approximation: if node is far enough, treat as single body + const s = this.size * 2; + if ((s * s) / dist_sq < theta * theta) + { + const f = repulsion * this.mass / dist_sq; + const dist = Math.sqrt(dist_sq); + return [f * dx / dist, f * dy / dist]; + } + + // recurse into children + var fx = 0, fy = 0; + if (this.children) + { + for (const child of this.children) + { + if (!child) continue; + const [cfx, cfy] = child.compute_force(node, repulsion, theta); + fx += cfx; + fy += cfy; + } + } + return [fx, fy]; + } +} + +function build_quadtree(nodes) +{ + var min_x = Infinity, min_y = Infinity; + var max_x = -Infinity, max_y = -Infinity; + for (const n of nodes) + { + if (n.x < min_x) min_x = n.x; + if (n.y < min_y) min_y = n.y; + if (n.x > max_x) max_x = n.x; + if (n.y > max_y) max_y = n.y; + } + const cx = (min_x + max_x) / 2; + const cy = (min_y + max_y) / 2; + const hs = Math.max(max_x - min_x, max_y - min_y) / 2 + 1; + const tree = new QuadTree(cx, cy, hs); + for (const n of nodes) + tree.insert(n); + return tree; +} + +//////////////////////////////////////////////////////////////////////////////// +// overlap removal + +function remove_overlaps(nodes, iterations, padding) +{ + for (var iter = 0; iter < iterations; ++iter) + { + for (var i = 0; i < nodes.length; ++i) + { + for (var j = i + 1; j < nodes.length; ++j) + { + const a = nodes[i]; + const b = nodes[j]; + const hw = (a.w + b.w) / 2 + padding; + const hh = (a.h + b.h) / 2 + padding; + const dx = b.x - a.x; + const dy = b.y - a.y; + const ox = hw - Math.abs(dx); + const oy = hh - Math.abs(dy); + if (ox <= 0 || oy <= 0) continue; + + // push apart along axis of minimum penetration + if (ox < oy) + { + const sx = dx >= 0 ? 1 : -1; + if (a.pinned && b.pinned) continue; + if (a.pinned) + b.x += sx * ox; + else if (b.pinned) + a.x -= sx * ox; + else + { + const push = ox / 2; + a.x -= sx * push; + b.x += sx * push; + } + } + else + { + const sy = dy >= 0 ? 1 : -1; + if (a.pinned && b.pinned) continue; + if (a.pinned) + b.y += sy * oy; + else if (b.pinned) + a.y -= sy * oy; + else + { + const push = oy / 2; + a.y -= sy * push; + b.y += sy * push; + } + } + } + } + } +} + +//////////////////////////////////////////////////////////////////////////////// +function layout_step(nodes, edges, cx, cy) +{ + const n = nodes.length; + const repulsion = 80000 + n * 2000; + const spring_rest = 120 + n * 2; + const spring_k = 0.004; + const gravity = 0.01; + const damping = 0.85; + + // repulsion: use Barnes-Hut when > 100 nodes, naive O(n^2) otherwise + if (n > 100) + { + const qt = build_quadtree(nodes); + for (var i = 0; i < n; ++i) + { + const [rfx, rfy] = qt.compute_force(nodes[i], repulsion, 0.8); + nodes[i].fx = rfx + (cx - nodes[i].x) * gravity; + nodes[i].fy = rfy + (cy - nodes[i].y) * gravity; + } + } + else + { + for (var i = 0; i < n; ++i) + { + var fx = 0, fy = 0; + for (var j = 0; j < n; ++j) + { + if (i == j) continue; + var dx = nodes[i].x - nodes[j].x; + var dy = nodes[i].y - nodes[j].y; + var dist_sq = dx * dx + dy * dy; + if (dist_sq < 1) dist_sq = 1; + var f = repulsion / dist_sq; + var dist = Math.sqrt(dist_sq); + fx += f * dx / dist; + fy += f * dy / dist; + } + fx += (cx - nodes[i].x) * gravity; + fy += (cy - nodes[i].y) * gravity; + nodes[i].fx = fx; + nodes[i].fy = fy; + } + } + + for (const edge of edges) + { + const a = edge.source; + const b = edge.target; + var dx = b.x - a.x; + var dy = b.y - a.y; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) dist = 1; + var f = spring_k * (dist - spring_rest); + var fx = f * dx / dist; + var fy = f * dy / dist; + a.fx += fx; + a.fy += fy; + b.fx -= fx; + b.fy -= fy; + } + + for (const node of nodes) + { + if (node.pinned) continue; + node.vx = (node.vx + node.fx) * damping; + node.vy = (node.vy + node.fy) * damping; + node.x += node.vx; + node.y += node.vy; + } +} + +function layout_run(nodes, edges, cx, cy, n) +{ + for (var i = 0; i < n; ++i) + layout_step(nodes, edges, cx, cy); +} + +//////////////////////////////////////////////////////////////////////////////// +// synthetic data generation + +const PREFIXES = [ + "/Game/__ExternalActors__/Map/Big_City_LVL", + "/Game/Prop/Kit_antenna_RR/Material", + "/Game/Prop/Kit_roof_brick_RR/Mesh", + "/Game/Prop/Kit_ventilation_RR/Mesh", + "/Game/Prop/Kit_roof_exhaust_RR/Mesh", + "/Game/Prop/Kit_roof_windows_RR/Material", + "/Game/Prop/Kit_roof_platform_RR/Mesh", + "/Game/Prop/Kit_roof_storage_RR/Mesh", + "/Game/Prop/Kit_roof_tank_a_RR/Material", + "/Game/Buildings/Residential/Textures", + "/Game/Buildings/Commercial/Materials", + "/Game/Buildings/Industrial/Meshes", + "/Game/Environment/Foliage/Trees", + "/Game/Environment/Foliage/Grass", + "/Game/Environment/Weather/Particles", + "/Game/Vehicles/Cars/Sedan/Materials", + "/Game/Vehicles/Trucks/Delivery/Meshes", + "/Game/Characters/Pedestrians/Anims", + "/Game/Characters/Pedestrians/Materials", + "/Game/FX/Particles/Smoke", + "/Game/FX/Particles/Fire", + "/Game/Audio/Ambience/City", + "/Game/UI/Widgets/HUD", + "/Script/Engine", + "/Script/CoreUObject", + "/Script/UMG", + "/Script/Niagara", + "/Script/Chaos", +]; + +const SUFFIXES = [ + "M_brick_wall_01", "M_concrete_panel_a", "M_glass_window_tinted", + "M_metal_railing_rust", "M_roof_tile_red", "SM_pipe_corner_90", + "SM_vent_large_N1", "SM_exhaust_cap_b", "SM_window_frame_double", + "SM_ladder_section_02", "SM_storage_box_tall", "SM_tank_cylindrical", + "SM_antenna_dish_small", "SM_breaker_panel_N1", "SM_cable_bundle_a", + "T_brick_diffuse_01", "T_concrete_normal", "T_metal_roughness", + "T_wood_basecolor_02", "T_glass_opacity", "SK_pedestrian_male_01", + "SK_pedestrian_female_03", "ABP_pedestrian_walk", "ABP_pedestrian_idle", + "NS_smoke_plume_large", "NS_fire_barrel", "NS_rain_heavy", + "WBP_healthbar", "WBP_minimap_icon", "SB_ambient_city_loop", + "Actor", "Component", "SceneComponent", "StaticMeshComponent", + "MaterialInstance", "Texture2D", "SkeletalMesh", "AnimBlueprint", + "NiagaraSystem", "SoundCue", "UserWidget", "DataAsset", +]; + +const DEP_TYPES = ["imported", "native", "soft"]; + +function generate_path(rng) +{ + const prefix = PREFIXES[Math.floor(rng() * PREFIXES.length)]; + const suffix = SUFFIXES[Math.floor(rng() * SUFFIXES.length)]; + const hex = Math.floor(rng() * 0xFFFFFF).toString(16).toUpperCase().padStart(6, "0"); + return prefix + "/" + hex + "/" + suffix; +} + +function generate_tree(rng, min_deps, max_deps) +{ + const tree = {}; + for (const dep_type of DEP_TYPES) + { + const count = Math.floor(rng() * (max_deps - min_deps)) + min_deps; + const deps = []; + for (var i = 0; i < count; ++i) + deps.push(generate_path(rng)); + tree[dep_type] = deps; + } + return tree; +} + +// simple seeded RNG (mulberry32) +function make_rng(seed) +{ + return function() { + seed |= 0; seed = seed + 0x6D2B79F5 | 0; + var t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + main() + { + this._project = this.get_param("project"); + this._oplog = this.get_param("oplog"); + this._real_mode = !!(this._project && this._oplog); + + this.set_title(this._real_mode + ? this._project + " - " + this._oplog + : "graph debug playground"); + + this._nodes = []; + this._edges = []; + this._node_map = {}; + this._tree_cache = {}; + this._expanded = new Set(); + this._hidden_dep_types = new Set(); + + this._edge_index = new Map(); // node -> { incoming: edge[], outgoing: edge[] } + this._sim_raf = null; // requestAnimationFrame handle + this._ctxmenu = null; // context menu DOM element + this._ctxmenu_node = null; // node that was right-clicked + this._visible_set = null; // null = show all, Set = only these nodes + this._compression_fill = false; // color node backgrounds by compression ratio + + this._transform = { x: 0, y: 0, scale: 1.0 }; + this._drag = null; + this._hover_node = null; + + this._colors = { + p0: css_var("--theme_p0"), + p1: css_var("--theme_p1"), + p2: css_var("--theme_p2"), + p3: css_var("--theme_p3"), + p4: css_var("--theme_p4"), + g0: css_var("--theme_g0"), + g1: css_var("--theme_g1"), + g2: css_var("--theme_g2"), + g3: css_var("--theme_g3"), + g4: css_var("--theme_g4"), + }; + this._dep_colors = { + "imported": { color: this._colors.p0, dash: [] }, + "native": { color: "#496", dash: [] }, + "soft": { color: "#c84", dash: [6, 4] }, + }; + this._dep_default = { color: this._colors.g1, dash: [] }; + this._ratio_colors = ["#482878", "#2d708e", "#20a386", "#75d054", "#fde725"]; + + const seed = parseInt(this.get_param("seed", "42")); + const root_deps = parseInt(this.get_param("root_deps", "40")); + const child_min = parseInt(this.get_param("child_min", "3")); + const child_max = parseInt(this.get_param("child_max", "15")); + + this._rng = make_rng(seed); + this._child_min = child_min; + this._child_max = child_max; + + // pre-generate size map for all paths the RNG will produce + this._size_map = {}; + + this._stress_mode = this.get_param("stress") === "true"; + + const title = this._real_mode + ? this._project + " - " + this._oplog + : "graph debug playground"; + const section = this.add_section(title); + this._build(section, root_deps); + } + + _build(section, root_deps) + { + // toolbar + const toolbar = section.add_widget(Toolbar); + const left = toolbar.left(); + const right = toolbar.right(); + + const info = left.add("", "span"); + this._info_el = info.inner(); + + const show_all = right.add("show all"); + show_all.on_click(() => this._clear_trace()); + this._show_all_el = show_all.inner(); + this._show_all_el.style.display = "none"; + right.add("fit").on_click(() => this._fit_view()); + right.add("reset").on_click(() => this._reset_graph()); + + // canvas + panel + const view_wrap = section.tag().id("graph_wrap"); + const view = view_wrap.tag().id("graph_view"); + const canvas_el = view.tag("canvas").inner(); + this._canvas = canvas_el; + + const splitter = view.tag().id("graph_splitter"); + const panel = view.tag().id("graph_entries"); + this._prop_panel = panel.tag().id("graph_props"); + this._prop_panel.inner().innerHTML = '<div class="graph_props_empty">hover a node</div>'; + + const resize = () => { + const rect = canvas_el.getBoundingClientRect(); + var h = window.visualViewport.height - rect.top - 50; + if (h < 300) h = 300; + canvas_el.style.height = h + "px"; + canvas_el.width = canvas_el.offsetWidth; + canvas_el.height = h; + panel.inner().style.height = h + "px"; + this._render(); + }; + resize(); + window.addEventListener("resize", resize); + + // splitter drag + const splitter_el = splitter.inner(); + const panel_el = panel.inner(); + const on_splitter_move = (e) => { + const dx = e.clientX - this._resize_start_x; + var new_w = this._resize_start_w - dx; + const container_w = canvas_el.parentElement.clientWidth; + new_w = Math.max(80, Math.min(new_w, container_w - 200)); + panel_el.style.width = new_w + "px"; + canvas_el.width = canvas_el.offsetWidth; + canvas_el.height = canvas_el.offsetHeight; + this._render(); + }; + const on_splitter_up = () => { + splitter_el.classList.remove("active"); + document.removeEventListener("mousemove", on_splitter_move); + document.removeEventListener("mouseup", on_splitter_up); + }; + splitter_el.addEventListener("mousedown", (e) => { + e.preventDefault(); + this._resize_start_x = e.clientX; + this._resize_start_w = panel_el.offsetWidth; + splitter_el.classList.add("active"); + document.addEventListener("mousemove", on_splitter_move); + document.addEventListener("mouseup", on_splitter_up); + }); + + this._ctx = canvas_el.getContext("2d"); + + canvas_el.addEventListener("mousedown", (e) => this._on_mousedown(e)); + canvas_el.addEventListener("mousemove", (e) => this._on_mousemove(e)); + canvas_el.addEventListener("mouseup", (e) => this._on_mouseup(e)); + canvas_el.addEventListener("mouseleave", (e) => this._on_mouseup(e)); + canvas_el.addEventListener("dblclick", (e) => this._on_dblclick(e)); + canvas_el.addEventListener("wheel", (e) => this._on_wheel(e), { passive: false }); + canvas_el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const rect = canvas_el.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + this._hide_context_menu(); + if (node) + this._show_context_menu(e.clientX, e.clientY, node); + else if (this._visible_set) + this._show_context_menu(e.clientX, e.clientY, null); + }); + document.addEventListener("click", (e) => { + if (this._ctxmenu && !this._ctxmenu.contains(e.target)) + this._hide_context_menu(); + }); + + // legend + const legend = view_wrap.tag().id("graph_legend"); + const toggle_dep = (name, item) => { + if (this._hidden_dep_types.has(name)) + { + this._hidden_dep_types.delete(name); + item.inner().classList.remove("legend_disabled"); + } + else + { + this._hidden_dep_types.add(name); + item.inner().classList.add("legend_disabled"); + } + this._render(); + }; + for (const name in this._dep_colors) + { + const dep = this._dep_colors[name]; + const item = legend.tag(); + item.classify("legend_toggle"); + const swatch = item.tag("span"); + swatch.classify("legend_swatch"); + if (dep.dash.length) + swatch.inner().style.backgroundImage = + "repeating-linear-gradient(90deg, " + dep.color + " 0 6px, transparent 6px 10px)"; + else + swatch.inner().style.backgroundColor = dep.color; + item.tag("span").text(name); + item.inner().addEventListener("click", () => toggle_dep(name, item)); + } + { + const item = legend.tag(); + item.classify("legend_toggle"); + const swatch = item.tag("span"); + swatch.classify("legend_swatch"); + swatch.inner().style.backgroundColor = this._colors.g1; + item.tag("span").text("other"); + item.inner().addEventListener("click", () => toggle_dep("$other", item)); + } + { + const sep = legend.tag("span"); + sep.classify("zen_toolbar_sep"); + sep.text("|"); + } + { + const item = legend.tag(); + item.tag("span").text("compression:"); + const scale = item.tag("span"); + scale.classify("legend_scale"); + const stops = [...this._ratio_colors].reverse(); + scale.inner().style.backgroundImage = + "linear-gradient(90deg, " + stops.join(", ") + ")"; + scale.tag("span").text("low").classify("legend_scale_lo"); + scale.tag("span").text("high").classify("legend_scale_hi"); + const pill = item.tag("span"); + pill.classify("legend_pill"); + pill.text("fill"); + pill.inner().addEventListener("click", () => { + this._compression_fill = !this._compression_fill; + pill.inner().classList.toggle("active", this._compression_fill); + this._render(); + }); + } + { + const readme = legend.tag().id("graph_readme"); + const readme_el = readme.inner(); + readme_el.innerHTML = + '<div class="graph_readme_toggle">readme</div>' + + '<div class="graph_readme_body">' + + '<p><b>click</b> a node to expand its dependencies</p>' + + '<p><b>double-click</b> an expanded node to re-layout its children in place</p>' + + '<p><b>drag</b> a node to move it and its non-pinned subtree</p>' + + '<p><b>right-click</b> a node for a context menu:</p>' + + '<p> <b>expand</b> / <b>collapse</b> / <b>re-expand</b> — manage children</p>' + + '<p> <b>fit subtree</b> — zoom to fit node and descendants</p>' + + '<p> <b>trace to root</b> — hide everything except paths to root</p>' + + '<p> <b>pin</b> / <b>unpin</b> — lock a node in place</p>' + + '<p><b>scroll wheel</b> to zoom in/out at cursor position</p>' + + '<p><b>drag empty space</b> to pan the view</p>' + + '<p><b>legend</b> toggles filter edges by dependency type</p>' + + '<p><b>compression dot</b> color indicates compression ratio (see scale in legend)</p>' + + '<p><b>fill</b> toggle replaces node backgrounds with compression color</p>' + + '</div>'; + readme_el.querySelector(".graph_readme_toggle").addEventListener("click", () => { + readme_el.classList.toggle("open"); + }); + } + + if (this._real_mode) + { + this._init_real_data(); + } + else + { + // generate root + const root_opkey = "/Game/Map/Big_City_LVL/_Generated_/DebugRoot"; + this._generate_size(root_opkey); + this._load_root(root_opkey, root_deps); + + if (this._stress_mode) + this._run_stress_test(); + } + } + + _generate_size(opkey) + { + if (this._size_map[opkey]) + return; + const raw = BigInt(Math.floor(this._rng() * 10 * 1024 * 1024)); + const ratio = 0.1 + this._rng() * 0.8; + this._size_map[opkey] = { size: BigInt(Math.floor(Number(raw) * ratio)), raw_size: raw }; + } + + _fetch_tree(opkey, dep_count_override) + { + if (opkey in this._tree_cache) + return this._tree_cache[opkey]; + + const min = dep_count_override || this._child_min; + const max = dep_count_override || this._child_max; + const tree = generate_tree(this._rng, min, max); + for (const dep_type in tree) + for (const path of tree[dep_type]) + this._generate_size(path); + + this._tree_cache[opkey] = tree; + return tree; + } + + _load_root(opkey, root_deps) + { + const cx = this._canvas.width / 2; + const cy = this._canvas.height / 2; + + // pre-populate root tree with the requested dep count per type + const per_type = Math.ceil(root_deps / DEP_TYPES.length); + this._fetch_tree(opkey, per_type); + + const node = this._add_node(opkey, cx, cy, true); + this._expand_node(node); + this._fit_view(); + this._update_info(); + } + + // -- real-data mode ------------------------------------------------------- + + async _init_real_data() + { + // show a loading overlay while the indexer builds + const loading = document.createElement("div"); + loading.className = "graph_loading"; + loading.textContent = "loading indexer..."; + this._canvas.parentElement.appendChild(loading); + + const progress_bar = this.add_widget(ProgressBar); + progress_bar.set_progress("indexing"); + + this._indexer = await create_indexer(this._project, this._oplog, (...args) => { + progress_bar.set_progress(...args); + loading.textContent = "indexing... " + (args[1] || ""); + }); + progress_bar.destroy(); + loading.remove(); + + // build size lookup from indexer (same as graph.js) + this._size_map = {}; + var entry_count = 0; + for (const [name, size, raw_size] of this._indexer.enum_all()) + { + this._size_map[name] = { size: size, raw_size: raw_size }; + entry_count++; + } + this._entry_count = entry_count; + + this._build_prefix_tree(); + this._show_prefix_roots(); + this._fit_view(); + this._update_info(); + } + + _build_prefix_tree() + { + this._prefix_tree = { count: 0, size: 0n, raw_size: 0n, children: {} }; + for (const [name, size, raw_size] of this._indexer.enum_all()) + { + const segments = name.split('/').filter(s => s.length > 0); + var node = this._prefix_tree; + for (const seg of segments) + { + node.count++; + node.size += size; + node.raw_size += raw_size; + if (!node.children[seg]) + node.children[seg] = { count: 0, size: 0n, raw_size: 0n, children: {} }; + node = node.children[seg]; + } + // leaf node + node.count++; + node.size += size; + node.raw_size += raw_size; + } + } + + _show_prefix_roots() + { + const trie = this._prefix_tree; + const child_keys = Object.keys(trie.children) + .sort((a, b) => trie.children[b].count - trie.children[a].count); + + const cx = this._canvas.width / 2; + const cy = this._canvas.height / 2; + const count = child_keys.length; + const radius = 300 + count * 8; + + for (var i = 0; i < count; ++i) + { + const key = child_keys[i]; + const child_trie = trie.children[key]; + const angle = (i / count) * 2 * Math.PI; + const x = cx + Math.cos(angle) * radius; + const y = cy + Math.sin(angle) * radius; + + const label = key + " (" + child_trie.count.toLocaleString() + ")"; + const prefix_path = "/" + key; + + const node = this._add_group_node(prefix_path, label, x, y, child_trie); + node.is_root = true; + node.pinned = true; + } + + layout_run(this._nodes, this._edges, cx, cy, 80); + remove_overlaps(this._nodes, 10, 8); + } + + _add_group_node(prefix_path, label, x, y, prefix_node) + { + if (this._node_map[prefix_path]) + return this._node_map[prefix_path]; + + const w = measure_node_width(label) + 10; + const node = { + opkey: prefix_path, + label: label, + w: w, + h: NODE_H + 6, + x: x, y: y, + vx: 0, vy: 0, + fx: 0, fy: 0, + is_root: false, + is_group: true, + expanded: false, + pinned: false, + dep_count: 0, + truncated: false, + prefix_path: prefix_path, + prefix_node: prefix_node, + }; + this._nodes.push(node); + this._node_map[prefix_path] = node; + return node; + } + + async _fetch_tree_real(opkey) + { + if (opkey in this._tree_cache) + return this._tree_cache[opkey]; + + const cbo = await new Fetcher() + .resource("prj", this._project, "oplog", this._oplog, "entries") + .param("opkey", opkey) + .cbo(); + + if (!cbo) + { + this._tree_cache[opkey] = null; + return null; + } + + const entry_field = cbo.as_object().find("entry"); + if (!entry_field) + { + this._tree_cache[opkey] = null; + return null; + } + + const entry = entry_field.as_object(); + var tree = entry.find("$tree"); + if (tree != undefined) + tree = tree.as_object().to_js_object(); + else + tree = this._convert_legacy_to_tree(entry); + + if (!tree) + { + this._tree_cache[opkey] = null; + return null; + } + + delete tree["$id"]; + this._tree_cache[opkey] = tree; + return tree; + } + + _convert_legacy_to_tree(entry) + { + const raw_pkgst_entry = entry.find("packagestoreentry"); + if (raw_pkgst_entry == undefined) + return null; + + const tree = {}; + + const pkg_data = entry.find("packagedata"); + if (pkg_data) + { + var id = 0n; + for (var item of pkg_data.as_array()) + { + var pkg_id = item.as_object().find("id"); + if (pkg_id == undefined) + continue; + + pkg_id = pkg_id.as_value().subarray(0, 8); + for (var i = 7; i >= 0; --i) + { + id <<= 8n; + id |= BigInt(pkg_id[i]); + } + break; + } + tree["$id"] = id; + } + + const pkgst_entry = raw_pkgst_entry.as_object(); + for (const field of pkgst_entry) + { + const field_name = field.get_name(); + if (!field_name.endsWith("importedpackageids")) + continue; + + var dep_name = field_name.slice(0, -18); + if (dep_name.length == 0) + dep_name = "imported"; + + var out = tree[dep_name] = []; + for (var item of field.as_array()) + out.push(item.as_value(BigInt)); + } + + return tree; + } + + // -- end real-data mode --------------------------------------------------- + + _add_node(opkey, x, y, is_root) + { + if (this._node_map[opkey]) + return this._node_map[opkey]; + + const label = short_name(opkey) + (is_root ? " [R]" : ""); + const node = { + opkey: opkey, + label: label, + w: measure_node_width(label), + h: NODE_H, + x: x, y: y, + vx: 0, vy: 0, + fx: 0, fy: 0, + is_root: is_root || false, + expanded: false, + pinned: is_root || false, + dep_count: 0, + truncated: false, + }; + this._nodes.push(node); + this._node_map[opkey] = node; + return node; + } + + _add_edge(source, target, dep_type) + { + for (const e of this._edges) + if (e.source === source && e.target === target) + return; + this._edges.push({ source: source, target: target, dep_type: dep_type }); + } + + _node_color(node) + { + const sizes = this._size_map[node.opkey]; + if (!sizes || sizes.raw_size == 0n) + return this._colors.g1; + const ratio = 1.0 - Number(sizes.size) / Number(sizes.raw_size); + const stops = this._ratio_colors; + const t = Math.max(0, Math.min(1, 1.0 - ratio)) * (stops.length - 1); + const i = Math.min(Math.floor(t), stops.length - 2); + return lerp_color(stops[i], stops[i + 1], t - i); + } + + _find_parent(node) + { + // find all parents and pick the one closest to root + const parents = []; + for (const e of this._edges) + if (e.target === node) + parents.push(e.source); + + if (parents.length === 0) return null; + if (parents.length === 1) return parents[0]; + + // multiple parents: pick shortest path to root, break ties randomly + const depth_of = (n) => { + const visited = new Set(); + var cur = n; + var d = 0; + while (cur && !cur.is_root) + { + if (visited.has(cur)) return Infinity; + visited.add(cur); + var found = null; + for (const e of this._edges) + { + if (e.target === cur) { found = e.source; break; } + } + cur = found; + d++; + } + return cur ? d : Infinity; + }; + var best_depth = Infinity; + const candidates = []; + for (const p of parents) + { + const d = depth_of(p); + if (d < best_depth) + { + best_depth = d; + candidates.length = 0; + candidates.push(p); + } + else if (d === best_depth) + candidates.push(p); + } + return candidates[Math.floor(this._rng() * candidates.length)]; + } + + async _expand_node(node) + { + if (this._expanded.has(node.opkey)) + return; + + // real mode: group node expansion + if (this._real_mode && node.is_group) + { + await this._expand_group_node(node); + return; + } + + // real mode: entry node dep expansion + if (this._real_mode && !node.is_group) + { + await this._expand_real_entry(node); + return; + } + + // synthetic mode (unchanged) + this._sim_stop(); + this._expanded.add(node.opkey); + node.expanded = true; + + const tree = this._fetch_tree(node.opkey); + if (!tree) + { + this._render(); + return; + } + + var dep_total = 0; + const dep_types = {}; + const all_deps = []; + + for (const dep_name in tree) + { + const paths = tree[dep_name]; + dep_total += paths.length; + dep_types[dep_name] = paths.length; + for (const opkey of paths) + all_deps.push({ opkey, dep_name, size: Number(this._size_map[opkey]?.raw_size || 0n) }); + } + node.dep_count = dep_total; + node.dep_types = dep_types; + + all_deps.sort((a, b) => { + if (a.dep_name !== b.dep_name) + return a.dep_name < b.dep_name ? -1 : 1; + return b.size - a.size; + }); + + const visible = Math.min(all_deps.length, MAX_VISIBLE_DEPS); + + if (node.is_root) + this._expand_root(node, all_deps, visible); + else + this._expand_child(node, all_deps, visible); + + // in the playground every generated node always has deps + for (const dep of all_deps.slice(0, MAX_VISIBLE_DEPS)) + { + const dep_node = this._node_map[dep.opkey]; + if (dep_node) + dep_node.has_deps = true; + } + + this._rebuild_edge_index(); + this._update_info(); + this._render(); + } + + _expand_group_node(node) + { + this._sim_stop(); + this._expanded.add(node.opkey); + node.expanded = true; + + const trie = node.prefix_node; + const child_keys = Object.keys(trie.children); + + // decide: show sub-groups or individual entries? + const has_sub_groups = child_keys.some(k => + trie.children[k].count > 1 || Object.keys(trie.children[k].children).length > 0 + ); + + const existing = new Set(this._nodes); + + if (has_sub_groups) + { + // sort by count desc, cap at MAX_VISIBLE_DEPS + const sorted = child_keys.sort((a, b) => trie.children[b].count - trie.children[a].count); + const visible = Math.min(sorted.length, MAX_VISIBLE_DEPS); + node.dep_count = sorted.length; + if (sorted.length > MAX_VISIBLE_DEPS) + node.truncated = true; + + const radius = 200 + visible * 4; + for (var i = 0; i < visible; ++i) + { + const key = sorted[i]; + const child_trie = trie.children[key]; + const has_more = Object.keys(child_trie.children).length > 0 || child_trie.count > 1; + + const t = visible > 1 ? i / (visible - 1) : 0.5; + const angle = t * 2 * Math.PI; + const r = radius + Math.random() * 40; + const x = node.x + Math.cos(angle) * r; + const y = node.y + Math.sin(angle) * r; + + const child_path = node.prefix_path + "/" + key; + + if (has_more) + { + const label = key + " (" + child_trie.count.toLocaleString() + ")"; + const child_node = this._add_group_node(child_path, label, x, y, child_trie); + this._add_edge(node, child_node, "group"); + } + else + { + // single entry — add as entry node + const entry_node = this._add_node(child_path, x, y, false); + this._add_edge(node, entry_node, "group"); + } + } + } + else + { + // all children are leaf entries — use trie keys directly + const sorted = child_keys.sort((a, b) => { + const sa = this._size_map[node.prefix_path + "/" + a]; + const sb = this._size_map[node.prefix_path + "/" + b]; + return Number((sb?.raw_size || 0n) - (sa?.raw_size || 0n)); + }); + node.dep_count = trie.count; + if (trie.count > MAX_VISIBLE_DEPS) + node.truncated = true; + + const visible = Math.min(sorted.length, MAX_VISIBLE_DEPS); + const radius = 200 + visible * 4; + for (var i = 0; i < visible; ++i) + { + const t = visible > 1 ? i / (visible - 1) : 0.5; + const angle = t * 2 * Math.PI; + const r = radius + Math.random() * 40; + const x = node.x + Math.cos(angle) * r; + const y = node.y + Math.sin(angle) * r; + const entry_path = node.prefix_path + "/" + sorted[i]; + const entry_node = this._add_node(entry_path, x, y, false); + this._add_edge(node, entry_node, "group"); + } + } + + // pin existing nodes during layout, then restore + for (const n of existing) + { + n._was_pinned = n.pinned; + n.pinned = true; + } + + layout_run(this._nodes, this._edges, node.x, node.y, 80); + remove_overlaps(this._nodes, 10, 8); + + for (const n of existing) + { + n.pinned = n._was_pinned; + delete n._was_pinned; + } + + remove_overlaps(this._nodes, 15, 20); + + this._rebuild_edge_index(); + this._update_info(); + this._render(); + } + + async _expand_real_entry(node) + { + this._sim_stop(); + this._expanded.add(node.opkey); + node.expanded = true; + node.loading = true; + this._render(); + + // start pulsing animation while loading + const pulse_raf = setInterval(() => this._render(), 50); + + try + { + const tree = await this._fetch_tree_real(node.opkey); + if (!tree) + { + node.loading = false; + clearInterval(pulse_raf); + this._render(); + return; + } + + // resolve dep IDs via indexer + var dep_total = 0; + const dep_types = {}; + const all_deps = []; + + for (const dep_name in tree) + { + const count = tree[dep_name].length; + dep_total += count; + dep_types[dep_name] = count; + + for (const dep_id of tree[dep_name]) + { + var opkey = this._indexer.lookup_id(dep_id); + var is_unresolved = false; + if (!opkey) + { + opkey = "0x" + dep_id.toString(16).padStart(16, "0"); + is_unresolved = true; + } + const sizes = this._size_map[opkey]; + const size = sizes ? Number(sizes.raw_size) : 0; + all_deps.push({ opkey, dep_name, is_unresolved, size }); + } + } + node.dep_count = dep_total; + node.dep_types = dep_types; + + // sort by dep type then size + all_deps.sort((a, b) => { + if (a.dep_name !== b.dep_name) + return a.dep_name < b.dep_name ? -1 : 1; + return b.size - a.size; + }); + + // snapshot existing nodes + const existing = new Set(this._nodes); + + const parent = this._find_parent(node); + const outward_angle = parent + ? Math.atan2(node.y - parent.y, node.x - parent.x) + : 0; + + const visible = Math.min(all_deps.length, MAX_VISIBLE_DEPS); + + // push node away from parent + if (parent && !node._skip_push) + { + const push_dist = 400 + visible * 6; + node.x += Math.cos(outward_angle) * push_dist; + node.y += Math.sin(outward_angle) * push_dist; + } + node.pinned = true; + + const arc_span = Math.PI; + const arc_start = outward_angle - arc_span / 2; + const radius = 200 + visible * 4; + + var added = 0; + for (const dep of all_deps) + { + if (added >= MAX_VISIBLE_DEPS) { node.truncated = true; break; } + const t = visible > 1 ? added / (visible - 1) : 0.5; + const angle = arc_start + t * arc_span; + const r = radius + Math.random() * 40; + const dep_node = this._add_node(dep.opkey, node.x + Math.cos(angle) * r, node.y + Math.sin(angle) * r, false); + dep_node.unresolved = dep.is_unresolved; + this._add_edge(node, dep_node, dep.dep_name); + added++; + } + + // pin all existing nodes during layout + for (const n of existing) + { + n._was_pinned = n.pinned; + n.pinned = true; + } + + layout_run(this._nodes, this._edges, node.x, node.y, 80); + remove_overlaps(this._nodes, 10, 8); + + for (const n of existing) + { + n.pinned = n._was_pinned; + delete n._was_pinned; + } + + remove_overlaps(this._nodes, 15, 20); + + this._rebuild_edge_index(); + this._update_info(); + + // async has_deps pre-check for new children + const new_deps = all_deps.filter(d => !d.is_unresolved).slice(0, MAX_VISIBLE_DEPS); + if (new_deps.length > 0) + { + (async () => { + await Promise.all(new_deps.map(async (d) => { + const dep_node = this._node_map[d.opkey]; + if (!dep_node || dep_node.has_deps !== undefined) + return; + const dep_tree = await this._fetch_tree_real(d.opkey); + if (!dep_tree) + dep_node.has_deps = false; + else + { + var has = false; + for (const k in dep_tree) + if (dep_tree[k].length > 0) { has = true; break; } + dep_node.has_deps = has; + } + })); + this._render(); + })(); + } + } + finally + { + node.loading = false; + clearInterval(pulse_raf); + this._render(); + } + } + + // root expansion: full circle + force-directed layout + _expand_root(node, all_deps, visible) + { + const radius = 200 + visible * 4; + var added = 0; + for (const dep of all_deps) + { + if (added >= MAX_VISIBLE_DEPS) { node.truncated = true; break; } + const t = visible > 1 ? added / (visible - 1) : 0.5; + const angle = t * 2 * Math.PI; + const r = radius + this._rng() * 40; + this._add_node(dep.opkey, node.x + Math.cos(angle) * r, node.y + Math.sin(angle) * r, false); + this._add_edge(node, this._node_map[dep.opkey], dep.dep_name); + added++; + } + + // force-directed layout to spread root children evenly + layout_run(this._nodes, this._edges, node.x, node.y, 80); + remove_overlaps(this._nodes, 10, 8); + } + + // non-root expansion: same radial style as root but in a half-circle away from parent + _expand_child(node, all_deps, visible) + { + const parent = this._find_parent(node); + const outward_angle = parent + ? Math.atan2(node.y - parent.y, node.x - parent.x) + : 0; + + // push node away from parent (long stem) — skip if re-expanding in place + if (parent && !node._skip_push) + { + const push_dist = 400 + visible * 6; + node.x += Math.cos(outward_angle) * push_dist; + node.y += Math.sin(outward_angle) * push_dist; + } + node.pinned = true; + + // snapshot existing nodes before adding children + const existing = new Set(this._nodes); + + // place children in a half-circle facing away from parent, same style as root + const arc_span = Math.PI; + const arc_start = outward_angle - arc_span / 2; + const radius = 200 + visible * 4; + + var added = 0; + for (const dep of all_deps) + { + if (added >= MAX_VISIBLE_DEPS) { node.truncated = true; break; } + const t = visible > 1 ? added / (visible - 1) : 0.5; + const angle = arc_start + t * arc_span; + const r = radius + this._rng() * 40; + this._add_node(dep.opkey, node.x + Math.cos(angle) * r, node.y + Math.sin(angle) * r, false); + this._add_edge(node, this._node_map[dep.opkey], dep.dep_name); + added++; + } + + // pin ALL pre-existing nodes so only new children move during layout + for (const n of existing) + { + n._was_pinned = n.pinned; + n.pinned = true; + } + + layout_run(this._nodes, this._edges, node.x, node.y, 80); + remove_overlaps(this._nodes, 10, 8); + + // restore pin state of pre-existing nodes + for (const n of existing) + { + n.pinned = n._was_pinned; + delete n._was_pinned; + } + + // second pass: push all subtrees apart (nothing pinned except root) + remove_overlaps(this._nodes, 15, 20); + } + + _sim_stop() + { + if (this._sim_raf) + { + cancelAnimationFrame(this._sim_raf); + this._sim_raf = null; + } + } + + _update_info() + { + if (!this._info_el) return; + var text = "nodes: " + this._nodes.length + " edges: " + this._edges.length; + if (this._real_mode && this._entry_count) + text += " entries: " + this._entry_count.toLocaleString(); + this._info_el.textContent = text; + } + + _reset_graph() + { + this._sim_stop(); + this._nodes = []; + this._edges = []; + this._node_map = {}; + this._expanded = new Set(); + this._edge_index = new Map(); + this._visible_set = null; + this._show_all_el.style.display = "none"; + this._transform = { x: 0, y: 0, scale: 1.0 }; + this._update_info(); + this._render(); + } + + _fit_view() + { + if (this._nodes.length == 0) + return; + + var min_x = Infinity, min_y = Infinity; + var max_x = -Infinity, max_y = -Infinity; + const vs = this._visible_set; + for (const n of this._nodes) + { + if (vs && !vs.has(n)) continue; + const hw = n.w / 2, hh = n.h / 2; + if (n.x - hw < min_x) min_x = n.x - hw; + if (n.y - hh < min_y) min_y = n.y - hh; + if (n.x + hw > max_x) max_x = n.x + hw; + if (n.y + hh > max_y) max_y = n.y + hh; + } + + const pad = 40; + const w = max_x - min_x + pad * 2; + const h = max_y - min_y + pad * 2; + const scale = Math.min(this._canvas.width / w, this._canvas.height / h, 4.0); + const cx = (min_x + max_x) / 2; + const cy = (min_y + max_y) / 2; + + this._transform.scale = Math.max(scale, 0.2); + this._transform.x = this._canvas.width / 2 - cx * this._transform.scale; + this._transform.y = this._canvas.height / 2 - cy * this._transform.scale; + this._render(); + } + + _is_edge_hidden(edge) + { + const dep_type = edge.dep_type; + if (this._dep_colors[dep_type]) + return this._hidden_dep_types.has(dep_type); + return this._hidden_dep_types.has("$other"); + } + + _rebuild_edge_index() + { + this._edge_index = new Map(); + for (const node of this._nodes) + this._edge_index.set(node, { incoming: [], outgoing: [] }); + for (const edge of this._edges) + { + const si = this._edge_index.get(edge.source); + const ti = this._edge_index.get(edge.target); + if (si) si.outgoing.push(edge); + if (ti) ti.incoming.push(edge); + } + } + + // -- rendering ---- + + _render() + { + const ctx = this._ctx; + if (!ctx) return; + + const c = this._colors; + const canvas = this._canvas; + const scale = this._transform.scale; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // viewport culling: compute visible world-space rect + const vp_pad = 100 / scale; // padding in world coords + const vp_x0 = -this._transform.x / scale - vp_pad; + const vp_y0 = -this._transform.y / scale - vp_pad; + const vp_x1 = (canvas.width - this._transform.x) / scale + vp_pad; + const vp_y1 = (canvas.height - this._transform.y) / scale + vp_pad; + + const vs = this._visible_set; + + const is_node_visible = (n) => { + if (vs && !vs.has(n)) return false; + const hw = n.w / 2, hh = n.h / 2; + return n.x + hw >= vp_x0 && n.x - hw <= vp_x1 && + n.y + hh >= vp_y0 && n.y - hh <= vp_y1; + }; + + const is_edge_visible = (e) => { + if (vs && (!vs.has(e.source) || !vs.has(e.target))) return false; + return is_node_visible(e.source) || is_node_visible(e.target); + }; + + // text LOD thresholds + const node_screen_h = NODE_H * scale; + const show_labels = node_screen_h >= 8; + const show_dots = node_screen_h >= 14; + + ctx.save(); + ctx.translate(this._transform.x, this._transform.y); + ctx.scale(scale, scale); + + const rect_edge = (cx, cy, hw, hh, sx, sy) => { + var dx = sx - cx; + var dy = sy - cy; + if (dx == 0 && dy == 0) dx = 1; + const sx_t = hw / (Math.abs(dx) || 1); + const sy_t = hh / (Math.abs(dy) || 1); + const t = Math.min(sx_t, sy_t); + return [cx + dx * t, cy + dy * t]; + }; + + const draw_edge = (edge, color, width) => { + const dep = this._dep_colors[edge.dep_type] || this._dep_default; + const ec = color || dep.color; + const lw = width || 1.5; + ctx.strokeStyle = ec; + ctx.lineWidth = lw / scale; + ctx.setLineDash(dep.dash.map(v => v / scale)); + + const s = edge.source; + const t = edge.target; + const [sx, sy] = rect_edge(s.x, s.y, s.w / 2, s.h / 2, t.x, t.y); + const [tx, ty] = rect_edge(t.x, t.y, t.w / 2, t.h / 2, s.x, s.y); + + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(tx, ty); + ctx.stroke(); + + const dx = tx - sx; + const dy = ty - sy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0) + { + const ux = dx / dist; + const uy = dy / dist; + const arrow_size = 8 / scale; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(tx, ty); + ctx.lineTo(tx - ux * arrow_size - uy * arrow_size * 0.5, + ty - uy * arrow_size + ux * arrow_size * 0.5); + ctx.lineTo(tx - ux * arrow_size + uy * arrow_size * 0.5, + ty - uy * arrow_size - ux * arrow_size * 0.5); + ctx.closePath(); + ctx.fillStyle = ec; + ctx.fill(); + } + }; + + // draw edges + const hover = this._hover_node; + const do_highlight = hover && !hover.is_root; + + for (const edge of this._edges) + { + if (this._is_edge_hidden(edge)) + continue; + if (!is_edge_visible(edge)) + continue; + if (do_highlight && (edge.source === hover || edge.target === hover)) + continue; + if (do_highlight) + { + ctx.globalAlpha = 0.2; + draw_edge(edge); + ctx.globalAlpha = 1.0; + } + else + draw_edge(edge); + } + + if (do_highlight) + { + // use edge index for O(D + path_length) path tracing + const path_edges = new Set(); + const visited = new Set(); + const trace = (node) => { + if (visited.has(node)) return; + visited.add(node); + const idx = this._edge_index.get(node); + if (!idx) return; + for (const edge of idx.incoming) + { + if (this._is_edge_hidden(edge)) continue; + path_edges.add(edge); + trace(edge.source); + } + }; + trace(hover); + const hover_idx = this._edge_index.get(hover); + if (hover_idx) + { + for (const edge of hover_idx.outgoing) + if (!this._is_edge_hidden(edge)) + path_edges.add(edge); + } + for (const edge of path_edges) + draw_edge(edge, c.p0, 3); + } + ctx.setLineDash([]); + + // draw nodes + const font_size = 11; + ctx.font = font_size + "px consolas, monospace"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + + const draw_node = (node) => { + const nw = node.w; + const nh = node.h; + const x = node.x - nw / 2; + const y = node.y - nh / 2; + + ctx.beginPath(); + ctx.roundRect(x, y, nw, nh, NODE_R); + + // group nodes: distinct fill + dashed border + if (node.is_group) + { + if (node === this._hover_node) + ctx.fillStyle = c.p2; + else + ctx.fillStyle = c.p3; + ctx.fill(); + + ctx.setLineDash([4 / scale, 3 / scale]); + ctx.strokeStyle = c.p1; + ctx.lineWidth = 1.5 / scale; + ctx.stroke(); + ctx.setLineDash([]); + } + else + { + if (node === this._hover_node) + ctx.fillStyle = c.p2; + else if (this._compression_fill) + ctx.fillStyle = this._node_color(node); + else + ctx.fillStyle = c.g3; + ctx.fill(); + + if (node.has_deps) + { + ctx.strokeStyle = c.p1; + ctx.lineWidth = 2 / scale; + ctx.stroke(); + } + } + + // loading state: pulsing border + if (node.loading) + { + const pulse = 0.4 + 0.6 * Math.abs(Math.sin(Date.now() / 300)); + ctx.strokeStyle = c.p0; + ctx.lineWidth = 2.5 / scale; + ctx.globalAlpha = pulse; + ctx.beginPath(); + ctx.roundRect(x, y, nw, nh, NODE_R); + ctx.stroke(); + ctx.globalAlpha = 1.0; + } + + // text LOD: skip labels when zoomed out far + if (!show_labels) + return; + + const node_sizes = this._size_map[node.opkey]; + const has_dot = show_dots && !node.is_group && !this._compression_fill && node_sizes && node_sizes.raw_size > 0n; + const text_x = has_dot ? node.x + DOT_SPACE / 2 : node.x; + + ctx.fillStyle = (node === this._hover_node || this._compression_fill) ? c.g4 : c.g0; + ctx.fillText(node.label, text_x, node.y); + + if (has_dot) + { + const dot_r = 4; + const label_w = _measure_ctx.measureText(node.label).width; + const dot_x = text_x - label_w / 2 - dot_r - 4; + ctx.beginPath(); + ctx.arc(dot_x, node.y, dot_r, 0, Math.PI * 2); + ctx.fillStyle = this._node_color(node); + ctx.fill(); + if (node === this._hover_node) + { + ctx.strokeStyle = c.g0; + ctx.lineWidth = 1 / scale; + ctx.stroke(); + } + } + + if (node.truncated) + { + const extra = node.dep_count - MAX_VISIBLE_DEPS; + ctx.fillStyle = c.g1; + ctx.font = (font_size * 0.8) + "px consolas, monospace"; + ctx.fillText("+" + extra + " more", node.x, node.y + node.h / 2 + font_size * 0.7); + ctx.font = font_size + "px consolas, monospace"; + } + }; + + for (const node of this._nodes) + if (!node.expanded && node !== this._hover_node && is_node_visible(node)) + draw_node(node); + for (const node of this._nodes) + if (node.expanded && node !== this._hover_node && is_node_visible(node)) + draw_node(node); + if (this._hover_node && scale >= 0.6 && (!vs || vs.has(this._hover_node))) + draw_node(this._hover_node); + + ctx.restore(); + + if (this._hover_node && scale < 0.6 && (!vs || vs.has(this._hover_node))) + { + const node = this._hover_node; + const sx = node.x * scale + this._transform.x; + const sy = node.y * scale + this._transform.y; + ctx.font = "11px consolas, monospace"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + + const node_sizes = this._size_map[node.opkey]; + const has_dot = !this._compression_fill && node_sizes && node_sizes.raw_size > 0n; + const tw = ctx.measureText(node.label).width + NODE_PAD * 2 + (has_dot ? DOT_SPACE : 0); + const text_x = has_dot ? sx + DOT_SPACE / 2 : sx; + const th = NODE_H; + ctx.beginPath(); + ctx.roundRect(sx - tw / 2, sy - th / 2, tw, th, NODE_R); + ctx.fillStyle = c.p2; + ctx.fill(); + ctx.fillStyle = c.g4; + ctx.fillText(node.label, text_x, sy); + + if (has_dot) + { + const dot_r = 4; + const label_w = ctx.measureText(node.label).width; + const dot_x = text_x - label_w / 2 - dot_r - 4; + ctx.beginPath(); + ctx.arc(dot_x, sy, dot_r, 0, Math.PI * 2); + ctx.fillStyle = this._node_color(node); + ctx.fill(); + ctx.strokeStyle = c.g0; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + + // tooltip + if (this._hover_node) + { + const node = this._hover_node; + ctx.font = "11px consolas, monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + + const lines = [node.opkey]; + if (node.is_group && node.prefix_node) + { + lines.push("entries: " + node.prefix_node.count.toLocaleString()); + if (node.prefix_node.raw_size > 0n) + lines.push("total size: " + Friendly.kib(node.prefix_node.size) + + " raw: " + Friendly.kib(node.prefix_node.raw_size)); + } + else + { + const sizes = this._size_map[node.opkey]; + if (sizes) + { + var size_line = "size: " + Friendly.kib(sizes.size) + + " raw: " + Friendly.kib(sizes.raw_size); + if (sizes.raw_size > 0n) + { + const pct = (100 * (1.0 - Number(sizes.size) / Number(sizes.raw_size))).toFixed(0); + size_line += " (" + pct + "% compressed)"; + } + lines.push(size_line); + } + } + if (node.dep_count > 0) + { + var dep_str = "deps: " + node.dep_count; + if (node.dep_types) + { + const parts = []; + for (const t in node.dep_types) + parts.push(t + ": " + node.dep_types[t]); + dep_str += " (" + parts.join(", ") + ")"; + } + lines.push(dep_str); + } + else if (node.expanded) + lines.push("deps: none"); + + const tags = []; + if (node.is_root) tags.push("root"); + if (node.is_group) tags.push("group"); + if (node.expanded) tags.push("expanded"); + if (node.truncated) tags.push("truncated"); + if (node.has_deps === true) tags.push("has deps"); + else if (node.has_deps === false) tags.push("leaf"); + if (tags.length > 0) + lines.push("[" + tags.join(", ") + "]"); + + const line_h = 15; + const pad = 6; + var tw = 0; + for (const line of lines) + { + const w = ctx.measureText(line).width; + if (w > tw) tw = w; + } + tw += pad * 2; + const th = lines.length * line_h + pad * 2; + + const node_sx = node.x * scale + this._transform.x; + const node_sy = node.y * scale + this._transform.y; + const node_sh = node.h * scale; + var tx = node_sx - tw / 2; + var ty = node_sy + node_sh / 2 + 16; + + if (tx < 4) tx = 4; + if (tx + tw > canvas.width - 4) tx = canvas.width - tw - 4; + if (ty + th > canvas.height - 4) ty = node_sy - node_sh / 2 - th - 16; + + ctx.shadowColor = "rgba(0,0,0,0.3)"; + ctx.shadowBlur = 8; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + ctx.fillStyle = c.g3; + ctx.fillRect(tx, ty, tw, th); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.strokeStyle = c.g2; + ctx.lineWidth = 1; + ctx.strokeRect(tx, ty, tw, th); + + ctx.fillStyle = c.g0; + for (var i = 0; i < lines.length; ++i) + ctx.fillText(lines[i], tx + pad, ty + pad + i * line_h); + } + + if (this._nodes.length == 0) + { + ctx.fillStyle = c.g1; + ctx.font = "14px consolas, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("empty graph", canvas.width / 2, canvas.height / 2); + } + } + + // -- hit testing ---- + + _hit_test(mx, my) + { + const gx = (mx - this._transform.x) / this._transform.scale; + const gy = (my - this._transform.y) / this._transform.scale; + + // expand hit area when zoomed out so nodes are easier to click + const pad = Math.max(0, 8 / this._transform.scale - 8); + + for (var i = this._nodes.length - 1; i >= 0; --i) + { + const n = this._nodes[i]; + if (this._visible_set && !this._visible_set.has(n)) continue; + const hw = n.w / 2 + pad, hh = n.h / 2 + pad; + if (gx >= n.x - hw && gx <= n.x + hw && + gy >= n.y - hh && gy <= n.y + hh) + return n; + } + return null; + } + + // -- property panel ---- + + _update_prop_panel(node) + { + const el = this._prop_panel.inner(); + + if (!node) + { + el.innerHTML = '<div class="graph_props_empty">hover a node</div>'; + return; + } + + var html = ""; + const row = (label, value) => { + html += '<div class="graph_props_row">' + + '<span class="graph_props_label">' + label + '</span>' + + '<span>' + value + '</span></div>'; + }; + + row("name", node.label); + row("path", node.opkey); + + if (node.is_group && node.prefix_node) + { + row("entries", node.prefix_node.count.toLocaleString()); + if (node.prefix_node.raw_size > 0n) + { + row("total size", Friendly.kib(node.prefix_node.size)); + row("total raw", Friendly.kib(node.prefix_node.raw_size)); + } + } + else + { + const sizes = this._size_map[node.opkey]; + if (sizes) + { + row("size", Friendly.kib(sizes.size)); + row("raw size", Friendly.kib(sizes.raw_size)); + if (sizes.raw_size > 0n) + { + const pct = (100 * (1.0 - Number(sizes.size) / Number(sizes.raw_size))).toFixed(1); + row("compression", pct + "%"); + } + } + } + + if (node.dep_count > 0) + { + var dep_str = "" + node.dep_count; + if (node.dep_types) + { + const parts = []; + for (const t in node.dep_types) + parts.push(t + ": " + node.dep_types[t]); + dep_str += " (" + parts.join(", ") + ")"; + } + row("imports", dep_str); + } + + const tags = []; + if (node.is_root) tags.push("root"); + if (node.is_group) tags.push("group"); + if (node.expanded) tags.push("expanded"); + if (node.truncated) tags.push("truncated"); + if (node.has_deps === true) tags.push("has deps"); + else if (node.has_deps === false) tags.push("leaf"); + if (tags.length > 0) + row("status", tags.join(", ")); + + el.innerHTML = html; + } + + // -- context menu ---- + + _show_context_menu(mx, my, node) + { + this._hide_context_menu(); + + const menu = document.createElement("div"); + menu.className = "graph_ctxmenu"; + this._ctxmenu = menu; + this._ctxmenu_node = node; + + const add_item = (label, action) => { + const item = document.createElement("div"); + item.className = "graph_ctxmenu_item"; + item.textContent = label; + item.addEventListener("click", (e) => { + e.stopPropagation(); + action(); + this._hide_context_menu(); + }); + menu.appendChild(item); + }; + + if (node) + { + if (!node.expanded && !node.unresolved && node.has_deps !== false) + add_item("Expand", () => this._expand_node(node)); + + if (node.expanded && !node.is_root) + { + add_item("Collapse", () => { + this._collapse_node(node); + this._rebuild_edge_index(); + this._update_info(); + this._render(); + }); + add_item("Re-expand", () => this._reexpand_node(node)); + } + + if (node.expanded) + add_item("Fit subtree", () => this._fit_subtree(node)); + if (!node.is_root) + add_item("Trace to root", () => this._trace_to_root(node)); + } + if (this._visible_set) + add_item("Show all", () => this._clear_trace()); + if (node) + { + add_item(node.pinned ? "Unpin" : "Pin", () => { + node.pinned = !node.pinned; + this._render(); + }); + } + + // position: append first to measure, then clamp + this._canvas.parentElement.appendChild(menu); + const canvas_rect = this._canvas.getBoundingClientRect(); + var left = mx - canvas_rect.left; + var top = my - canvas_rect.top; + if (left + menu.offsetWidth > canvas_rect.width) + left = canvas_rect.width - menu.offsetWidth; + if (top + menu.offsetHeight > canvas_rect.height) + top = canvas_rect.height - menu.offsetHeight; + if (left < 0) left = 0; + if (top < 0) top = 0; + menu.style.left = left + "px"; + menu.style.top = top + "px"; + menu.addEventListener("mouseleave", () => this._hide_context_menu()); + } + + _hide_context_menu() + { + if (this._ctxmenu) + { + this._ctxmenu.remove(); + this._ctxmenu = null; + this._ctxmenu_node = null; + } + } + + _fit_subtree(node) + { + // collect node + all descendants via outgoing edges + const collected = [node]; + const visited = new Set(); + visited.add(node); + const collect = (n) => { + const idx = this._edge_index.get(n); + if (!idx) return; + for (const edge of idx.outgoing) + { + const child = edge.target; + if (visited.has(child)) continue; + visited.add(child); + collected.push(child); + collect(child); + } + }; + collect(node); + + // compute bounding box + var min_x = Infinity, min_y = Infinity; + var max_x = -Infinity, max_y = -Infinity; + for (const n of collected) + { + const hw = n.w / 2, hh = n.h / 2; + if (n.x - hw < min_x) min_x = n.x - hw; + if (n.y - hh < min_y) min_y = n.y - hh; + if (n.x + hw > max_x) max_x = n.x + hw; + if (n.y + hh > max_y) max_y = n.y + hh; + } + + const pad = 40; + const w = max_x - min_x + pad * 2; + const h = max_y - min_y + pad * 2; + const scale = Math.min(this._canvas.width / w, this._canvas.height / h, 4.0); + const cx = (min_x + max_x) / 2; + const cy = (min_y + max_y) / 2; + + this._transform.scale = Math.max(scale, 0.2); + this._transform.x = this._canvas.width / 2 - cx * this._transform.scale; + this._transform.y = this._canvas.height / 2 - cy * this._transform.scale; + this._render(); + } + + _trace_to_root(node) + { + const visible = new Set(); + const queue = [node]; + visible.add(node); + while (queue.length > 0) + { + const n = queue.shift(); + const idx = this._edge_index.get(n); + if (!idx) continue; + for (const edge of idx.incoming) + { + if (!visible.has(edge.source)) + { + visible.add(edge.source); + queue.push(edge.source); + } + } + } + this._visible_set = visible; + this._show_all_el.style.display = ""; + this._fit_view(); + } + + _clear_trace() + { + this._visible_set = null; + this._show_all_el.style.display = "none"; + this._render(); + } + + // -- mouse interaction ---- + + _on_mousedown(e) + { + this._hide_context_menu(); + if (e.button !== 0) return; + + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + + if (node) + { + // collect non-expanded descendants via outgoing edges so they move together + // stop at expanded nodes — they own their own subtree + const descendants = []; + const visited = new Set(); + const collect = (n) => { + if (visited.has(n)) return; + visited.add(n); + const idx = this._edge_index.get(n); + if (!idx) return; + for (const edge of idx.outgoing) + { + const child = edge.target; + if (visited.has(child)) continue; + if (child.expanded) continue; + if (child.pinned) continue; + descendants.push({ node: child, dx: child.x - node.x, dy: child.y - node.y }); + collect(child); + } + }; + collect(node); + + this._drag = { + type: "node", + node: node, + start_x: mx, + start_y: my, + node_start_x: node.x, + node_start_y: node.y, + descendants: descendants, + moved: false, + }; + node.pinned = true; + } + else + { + this._drag = { + type: "pan", + start_x: mx, + start_y: my, + tx: this._transform.x, + ty: this._transform.y, + }; + } + } + + _on_mousemove(e) + { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + if (this._drag) + { + const dx = mx - this._drag.start_x; + const dy = my - this._drag.start_y; + + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) + this._drag.moved = true; + + if (this._drag.type == "pan") + { + this._transform.x = this._drag.tx + dx; + this._transform.y = this._drag.ty + dy; + this._render(); + } + else if (this._drag.type == "node") + { + const node = this._drag.node; + node.x = this._drag.node_start_x + dx / this._transform.scale; + node.y = this._drag.node_start_y + dy / this._transform.scale; + for (const d of this._drag.descendants) + { + d.node.x = node.x + d.dx; + d.node.y = node.y + d.dy; + } + this._render(); + } + return; + } + + const node = this._hit_test(mx, my); + if (node !== this._hover_node) + { + this._hover_node = node; + this._canvas.style.cursor = node ? "pointer" : "grab"; + this._update_prop_panel(node); + this._render(); + } + } + + _on_mouseup(e) + { + if (!this._drag) + return; + + const drag = this._drag; + this._drag = null; + + if (drag.type == "node" && !drag.moved) + { + const node = drag.node; + if (!node.expanded && !node.unresolved && node.has_deps !== false) + this._expand_node(node); + } + } + + _on_dblclick(e) + { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + + if (node && node.expanded && !node.is_root) + this._reexpand_node(node); + } + + async _reexpand_node(node) + { + if (!this._expanded.has(node.opkey)) + return; + + // keep expanded children (+ their subtrees) and pinned children in place + const keep = new Set(); + const idx = this._edge_index.get(node); + if (idx) + { + const mark_keep = (n) => { + if (keep.has(n)) return; + keep.add(n); + const ci = this._edge_index.get(n); + if (!ci) return; + for (const e of ci.outgoing) + mark_keep(e.target); + }; + for (const edge of idx.outgoing) + { + const child = edge.target; + if (child.expanded || child.pinned) + mark_keep(child); + } + } + + // collect non-kept descendants to remove + const to_remove = new Set(); + const collect = (n) => { + const ni = this._edge_index.get(n); + if (!ni) return; + for (const edge of ni.outgoing) + { + const child = edge.target; + if (to_remove.has(child) || keep.has(child)) continue; + to_remove.add(child); + collect(child); + } + }; + collect(node); + + // remove only non-kept descendants + this._nodes = this._nodes.filter(n => !to_remove.has(n)); + this._edges = this._edges.filter(e => !to_remove.has(e.source) && !to_remove.has(e.target)); + for (const n of to_remove) + delete this._node_map[n.opkey]; + + // reset expand state so _expand_node can run again + this._expanded.delete(node.opkey); + node.expanded = false; + node.dep_count = 0; + node.dep_types = null; + node.truncated = false; + this._rebuild_edge_index(); + + // re-expand — kept nodes already in _node_map won't be duplicated + node._skip_push = true; + await this._expand_node(node); + delete node._skip_push; + } + + _collapse_node(node) + { + if (!this._expanded.has(node.opkey)) + return; + + // collect all descendant nodes (non-expanded only, stop at other expanded nodes) + const to_remove = new Set(); + const collect = (n) => { + const idx = this._edge_index.get(n); + if (!idx) return; + for (const edge of idx.outgoing) + { + const child = edge.target; + if (to_remove.has(child)) continue; + to_remove.add(child); + // if child is also expanded, collapse it recursively first + if (child.expanded) + this._collapse_node(child); + collect(child); + } + }; + collect(node); + + // remove descendant nodes and their edges + this._nodes = this._nodes.filter(n => !to_remove.has(n)); + this._edges = this._edges.filter(e => !to_remove.has(e.source) && !to_remove.has(e.target)); + for (const n of to_remove) + delete this._node_map[n.opkey]; + + // reset node state so _expand_node can run again + this._expanded.delete(node.opkey); + node.expanded = false; + node.dep_count = 0; + node.dep_types = null; + node.truncated = false; + + this._rebuild_edge_index(); + this._update_info(); + } + + _on_wheel(e) + { + e.preventDefault(); + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const old_scale = this._transform.scale; + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + var new_scale = old_scale * factor; + new_scale = Math.max(0.05, Math.min(4.0, new_scale)); + + this._transform.x = mx - (mx - this._transform.x) * (new_scale / old_scale); + this._transform.y = my - (my - this._transform.y) * (new_scale / old_scale); + this._transform.scale = new_scale; + this._render(); + } + + // -- stress test ---------------------------------------------------------- + + _create_stress_overlay() + { + const overlay = document.createElement("div"); + overlay.id = "stress_overlay"; + this._canvas.parentElement.appendChild(overlay); + this._stress_overlay = overlay; + } + + _stress_log(msg, cls) + { + console.log("[stress] " + msg); + if (!this._stress_overlay) return; + const line = document.createElement("div"); + if (cls) line.className = cls; + line.textContent = msg; + this._stress_overlay.appendChild(line); + this._stress_overlay.scrollTop = this._stress_overlay.scrollHeight; + } + + _stress_status(msg) + { + console.log("[stress] >> " + msg); + if (!this._stress_overlay) return; + if (!this._stress_status_el) + { + this._stress_status_el = document.createElement("div"); + this._stress_status_el.className = "stress_current"; + this._stress_overlay.appendChild(this._stress_status_el); + } + this._stress_status_el.textContent = ">> " + msg; + this._stress_overlay.scrollTop = this._stress_overlay.scrollHeight; + } + + _stress_delay(ms) + { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + async _run_stress_test() + { + this._create_stress_overlay(); + this._stress_log("stress test started"); + + var passed = 0; + const total = 10; + + const step = async (num, name, fn) => { + this._stress_status(num + "/10 " + name); + await this._stress_delay(100); + const t0 = performance.now(); + try + { + await fn(); + const ms = (performance.now() - t0).toFixed(0); + this._stress_log(" PASS " + num + ". " + name + " (" + ms + " ms)", "stress_pass"); + passed++; + } + catch (e) + { + const ms = (performance.now() - t0).toFixed(0); + this._stress_log(" FAIL " + num + ". " + name + " (" + ms + " ms)", "stress_fail"); + this._stress_log(" " + e.message, "stress_fail"); + } + }; + + // 1. Verify root expanded + await step(1, "verify root expanded", async () => { + if (this._nodes.length <= 1) + throw new Error("expected >1 nodes after root expansion, got " + this._nodes.length); + }); + + // 2. Expand 5 children + await step(2, "expand 5 children", async () => { + const root = this._nodes[0]; + const children = this._nodes.filter(n => + !n.expanded && !n.is_root && n.has_deps !== false); + const to_expand = children.slice(0, 5); + if (to_expand.length === 0) + throw new Error("no unexpanded children found"); + for (const child of to_expand) + { + await this._expand_node(child); + await this._stress_delay(200); + } + this._stress_log(" expanded " + to_expand.length + " children, total nodes: " + this._nodes.length); + }); + + // 3. Collapse/re-expand 2 nodes + await step(3, "collapse/re-expand 2 nodes", async () => { + const expanded = this._nodes.filter(n => n.expanded && !n.is_root); + if (expanded.length < 2) + throw new Error("need at least 2 expanded non-root nodes, have " + expanded.length); + for (var i = 0; i < 2; ++i) + { + const node = expanded[i]; + const before = this._nodes.length; + this._collapse_node(node); + this._rebuild_edge_index(); + this._update_info(); + this._render(); + await this._stress_delay(200); + + const after_collapse = this._nodes.length; + await this._expand_node(node); + await this._stress_delay(200); + + this._stress_log(" node " + (i+1) + ": " + before + " -> " + after_collapse + " -> " + this._nodes.length); + } + }); + + // 4. Trace to root + await step(4, "trace to root", async () => { + // find a leaf (non-expanded node with no outgoing edges) + var leaf = null; + for (const n of this._nodes) + { + if (n.is_root || n.expanded) continue; + const idx = this._edge_index.get(n); + if (idx && idx.outgoing.length === 0) + { + leaf = n; + break; + } + } + if (!leaf) + { + // fallback: pick any non-root non-expanded node + leaf = this._nodes.find(n => !n.is_root && !n.expanded); + } + if (!leaf) + throw new Error("no leaf node found"); + this._trace_to_root(leaf); + if (!this._visible_set) + throw new Error("visible_set not set after trace_to_root"); + this._stress_log(" traced " + leaf.label + ", visible set size: " + this._visible_set.size); + }); + + // 5. Clear trace + await step(5, "clear trace", async () => { + this._clear_trace(); + if (this._visible_set !== null) + throw new Error("visible_set should be null after clear_trace"); + }); + + // 6. Fit view + await step(6, "fit view", async () => { + this._fit_view(); + }); + + // 7. Fit subtree + await step(7, "fit subtree", async () => { + const expanded = this._nodes.find(n => n.expanded && !n.is_root); + if (!expanded) + throw new Error("no expanded non-root node found"); + this._fit_subtree(expanded); + }); + + // 8. Toggle compression fill + await step(8, "toggle compression fill", async () => { + this._compression_fill = true; + this._render(); + await this._stress_delay(200); + this._compression_fill = false; + this._render(); + }); + + // 9. Expand to 500+ nodes + await step(9, "expand to 500+ nodes", async () => { + var safety = 0; + while (this._nodes.length < 500 && safety < 100) + { + const unexpanded = this._nodes.find(n => + !n.expanded && !n.is_root && n.has_deps !== false); + if (!unexpanded) break; + await this._expand_node(unexpanded); + safety++; + if (safety % 5 === 0) + await this._stress_delay(50); + } + this._stress_log(" total nodes: " + this._nodes.length + " (expanded " + safety + " nodes)"); + if (this._nodes.length < 500) + throw new Error("only reached " + this._nodes.length + " nodes"); + }); + + // 10. Render benchmark + await step(10, "render benchmark", async () => { + const iters = 20; + const t0 = performance.now(); + for (var i = 0; i < iters; ++i) + this._render(); + const total_ms = performance.now() - t0; + const avg = (total_ms / iters).toFixed(1); + this._stress_log(" " + iters + " renders, avg " + avg + " ms/render (" + this._nodes.length + " nodes)"); + }); + + // summary + if (this._stress_status_el) + this._stress_status_el.remove(); + this._stress_status_el = null; + this._stress_log(passed + "/" + total + " steps passed", "stress_summary"); + } +} diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index ad64bef85..6e54adc87 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -489,71 +489,74 @@ a { text-decoration: underline; } } - .zen_minigraph { - position: relative; - display: flex; - height: 20em; - canvas { - flex: 1; - min-width: 0; - border: 1px solid var(--theme_g2); - cursor: grab; - } - canvas:active { - cursor: grabbing; - } - .minigraph_splitter { - width: 4px; - cursor: col-resize; - background-color: var(--theme_g2); - flex-shrink: 0; - &:hover, &.active { - background-color: var(--theme_p1); - } - } - .minigraph_props { - width: 18em; - flex-shrink: 0; - border: 1px solid var(--theme_g2); - border-left: none; - padding: 0.5em 0.6em; - font-size: 0.85em; - overflow-y: auto; - .minigraph_props_empty { - color: var(--theme_g1); - padding: 1em 0; - text-align: center; - } - .minigraph_props_row { - display: flex; - padding: 0.15em 0; - } - .minigraph_props_label { - color: var(--theme_g1); - min-width: 8em; - } - .minigraph_props_row > span:last-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +} + +/* minigraph (shared by entry + artifactdeps) ------------------------------- */ + +.zen_minigraph { + position: relative; + display: flex; + height: 20em; + canvas { + flex: 1; + min-width: 0; + border: 1px solid var(--theme_g2); + cursor: grab; + } + canvas:active { + cursor: grabbing; + } + .minigraph_splitter { + width: 4px; + cursor: col-resize; + background-color: var(--theme_g2); + flex-shrink: 0; + &:hover, &.active { + background-color: var(--theme_p1); } } - .minigraph_legend { - display: flex; - gap: 1.5em; - margin-top: 0.5em; + .minigraph_props { + width: 18em; + flex-shrink: 0; + border: 1px solid var(--theme_g2); + border-left: none; + padding: 0.5em 0.6em; font-size: 0.85em; - > div { + overflow-y: auto; + .minigraph_props_empty { + color: var(--theme_g1); + padding: 1em 0; + text-align: center; + } + .minigraph_props_row { display: flex; - align-items: center; - gap: 0.3em; + padding: 0.15em 0; } - .legend_swatch { - width: 1.5em; - height: 0.3em; - display: inline-block; + .minigraph_props_label { + color: var(--theme_g1); + min-width: 8em; } + .minigraph_props_row > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} +.minigraph_legend { + display: flex; + gap: 1.5em; + margin-top: 0.5em; + font-size: 0.85em; + > div { + display: flex; + align-items: center; + gap: 0.3em; + } + .legend_swatch { + width: 1.5em; + height: 0.3em; + display: inline-block; } } @@ -629,13 +632,13 @@ html:has(#map) { /* graph -------------------------------------------------------------------- */ -html:has(#graph) { +html:has(#graph), html:has(#graph-debug-playground) { height: 100%; - body, #container, #graph { + body, #container, #graph, #graph-debug-playground { height: 100%; } } -#graph { +#graph, #graph-debug-playground { #graph_view { position: relative; display: flex; @@ -650,8 +653,18 @@ html:has(#graph) { cursor: grabbing; } } + #graph_splitter { + width: 4px; + cursor: col-resize; + background-color: var(--theme_g2); + flex-shrink: 0; + &:hover, &.active { + background-color: var(--theme_p1); + } + } #graph_entries { width: 22em; + flex-shrink: 0; border: 1px solid var(--theme_g2); border-left: none; display: flex; @@ -736,6 +749,7 @@ html:has(#graph) { } #graph_legend { display: flex; + align-items: flex-start; gap: 1.5em; margin-top: 0.5em; font-size: 0.85em; @@ -749,5 +763,136 @@ html:has(#graph) { height: 0.3em; display: inline-block; } + .legend_pill { + cursor: pointer; + user-select: none; + padding: 0.1em 0.5em; + border-radius: 0.6em; + border: 1px solid var(--theme_g2); + color: var(--theme_g1); + font-size: 0.9em; + &:hover { + border-color: var(--theme_p1); + color: var(--theme_g0); + } + &.active { + background-color: var(--theme_p0); + border-color: var(--theme_p0); + color: var(--theme_g4); + } + } + #graph_readme { + margin-left: auto; + position: relative; + .graph_readme_toggle { + cursor: pointer; + color: var(--theme_g1); + user-select: none; + &:hover { + color: var(--theme_g0); + } + } + .graph_readme_body { + display: none; + position: absolute; + right: 0; + bottom: 1.6em; + width: max-content; + background-color: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px; + box-shadow: 2px 4px 12px rgba(0,0,0,0.3); + padding: 0.6em 0.8em; + color: var(--theme_g1); + line-height: 1.5; + z-index: 100; + b { + color: var(--theme_g0); + font-weight: normal; + } + p { + margin: 0.3em 0; + } + } + &.open .graph_readme_body { + display: block; + } + } + } +} + +/* loading overlay --------------------------------------------------------- */ + +#graph-debug-playground .graph_loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--theme_g1); + font: 11px consolas, monospace; + z-index: 100; + pointer-events: none; +} + +/* stress overlay ----------------------------------------------------------- */ + +#graph-debug-playground #stress_overlay { + position: absolute; + top: 8px; + left: 8px; + z-index: 500; + background-color: rgba(0, 0, 0, 0.8); + color: #4f4; + font: 11px consolas, monospace; + padding: 8px 12px; + border-radius: 6px; + pointer-events: none; + max-height: 50%; + overflow-y: auto; + white-space: pre; + .stress_current { + color: #ff4; + } + .stress_pass { + color: #4f4; + } + .stress_fail { + color: #f44; + } + .stress_summary { + color: #fff; + border-top: 1px solid rgba(255, 255, 255, 0.3); + margin-top: 4px; + padding-top: 4px; + } +} + +/* graph context menu ------------------------------------------------------ */ + +.graph_ctxmenu { + position: absolute; + z-index: 1000; + background-color: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px; + box-shadow: 2px 4px 12px rgba(0,0,0,0.3); + padding: 4px 0; + font: 11px consolas, monospace; + user-select: none; + .graph_ctxmenu_item { + padding: 6px 14px; + cursor: pointer; + white-space: nowrap; + color: var(--theme_g0); + &:hover { + background-color: var(--theme_p3); + } + } + .graph_ctxmenu_disabled { + padding: 6px 14px; + white-space: nowrap; + color: var(--theme_g1); + opacity: 0.4; + pointer-events: none; } } |