aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMtBntChvn <[email protected]>2026-02-20 16:29:19 +0000
committerMtBntChvn <[email protected]>2026-02-20 16:29:19 +0000
commit3007541d5347064bd54f4aa52d46862c4c19efbc (patch)
treedd6300ec8589396657b308944ea999eef533f818
parentadd uniform view navigation links to dashboard pages (diff)
downloadzen-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.js2584
-rw-r--r--src/zenserver/frontend/html/zen.css269
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>&nbsp; <b>expand</b> / <b>collapse</b> / <b>re-expand</b> — manage children</p>' +
+ '<p>&nbsp; <b>fit subtree</b> — zoom to fit node and descendants</p>' +
+ '<p>&nbsp; <b>trace to root</b> — hide everything except paths to root</p>' +
+ '<p>&nbsp; <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;
}
}