aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMtBntChvn <[email protected]>2026-02-20 23:13:20 +0000
committerMtBntChvn <[email protected]>2026-02-20 23:13:20 +0000
commitc7e0083c5f4be6034ad2b13a8e0bb9f35c35f412 (patch)
treed6b04013955ad197d7939958462eba034024c8f6 /src
parentWIP: add real data mode to graph debug playground (diff)
downloadzen-c7e0083c5f4be6034ad2b13a8e0bb9f35c35f412.tar.xz
zen-c7e0083c5f4be6034ad2b13a8e0bb9f35c35f412.zip
extract shared GraphEngine from graph views, add filtering and search
Extract ~2000-line GraphEngine class from duplicated code across graph-debug-playground.js, graph.js, and minigraph.js. All three consumers now share: QuadTree layout, viewport culling, text LOD, context menu (collapse/re-expand/fit subtree/trace to root/pin), compression fill, descendant dragging, overlap removal, loading pulse. New features added to the shared engine: - Compression slider: arrow beneath the gradient scale filters nodes by compression ratio with smooth fade - Edge-type node hiding: toggling off an edge type fades out nodes that become unreachable from roots - Graph search overlay (Ctrl+F / find button): highlights matches, dims non-matching nodes, navigate with Enter/Shift+Enter - Quick-filter pills: leaves, unresolved, large (top 20% by size) - Breadcrumb path filter: clickable path segments dim non-matching nodes, synced with the right-panel tree browser - Improved node appearance: slimmer, rounder, border-based styling Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/zenserver/frontend/html/pages/entry.js36
-rw-r--r--src/zenserver/frontend/html/pages/graph-debug-playground.js2326
-rw-r--r--src/zenserver/frontend/html/pages/graph.js1335
-rw-r--r--src/zenserver/frontend/html/util/graphengine.js2297
-rw-r--r--src/zenserver/frontend/html/util/minigraph.js1060
-rw-r--r--src/zenserver/frontend/html/zen.css147
6 files changed, 3133 insertions, 4068 deletions
diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js
index 894d8315b..7408a1a01 100644
--- a/src/zenserver/frontend/html/pages/entry.js
+++ b/src/zenserver/frontend/html/pages/entry.js
@@ -148,9 +148,6 @@ export class Page extends ZenPage
if (!mini_graph)
{
const canvas_el = graph_wrap.tag("canvas").inner();
- const splitter_el = graph_wrap.tag().classify("minigraph_splitter").inner();
- const prop_el = graph_wrap.tag().classify("minigraph_props").inner();
- prop_el.innerHTML = '<div class="minigraph_props_empty">hover a node</div>';
// set canvas buffer after all children exist so flex layout is settled
canvas_el.width = canvas_el.clientWidth;
@@ -167,11 +164,22 @@ export class Page extends ZenPage
const root_label = root_opkey.replace(/\/$/, "").split("/").pop() || root_opkey;
mini_graph = new MiniGraph(
- canvas_el, prop_el, splitter_el, legend_el,
+ canvas_el, legend_el,
root_opkey, root_label, deps, this._size_map,
(opkey) => this.view_opkey(opkey),
on_expand
);
+
+ // pre-check which deps have their own dependencies
+ (async () => {
+ const has_deps = new Set();
+ await Promise.all(deps.filter(d => !d.unresolved).map(async (d) => {
+ const result = await on_expand(d.opkey);
+ if (result && result.length > 0)
+ has_deps.add(d.opkey);
+ }));
+ mini_graph.mark_has_deps(has_deps);
+ })();
}
});
}
@@ -246,6 +254,18 @@ export class Page extends ZenPage
);
const action_tb = new Toolbar(row.get_cell(-1), true);
+ if (key == "CookPackageArtifacts" || key == "cook.artifacts")
+ {
+ const artifact_params = {
+ "page": "artifactdeps",
+ "project": project,
+ "oplog": oplog,
+ "hash": value,
+ "opkey": this.get_param("opkey"),
+ };
+ action_tb.left().add("list").link("", Object.assign({}, artifact_params, { "view": "list" }));
+ action_tb.left().add("graph").link("", Object.assign({}, artifact_params, { "view": "graph" }));
+ }
action_tb.left().add("copy-hash").on_click(async (v) => {
await navigator.clipboard.writeText(v);
}, value);
@@ -260,14 +280,6 @@ export class Page extends ZenPage
const name = entry.find("key").as_value();
var section = this.add_section(name);
- const nav = section.add_widget(Toolbar, true);
- nav.left().add("graph").link("", {
- "page": "graph",
- "project": this.get_param("project"),
- "oplog": this.get_param("oplog"),
- "opkey": this.get_param("opkey"),
- });
-
// tree
{
var tree = entry.find("$tree");
diff --git a/src/zenserver/frontend/html/pages/graph-debug-playground.js b/src/zenserver/frontend/html/pages/graph-debug-playground.js
index 9fa644b52..26e8f4b52 100644
--- a/src/zenserver/frontend/html/pages/graph-debug-playground.js
+++ b/src/zenserver/frontend/html/pages/graph-debug-playground.js
@@ -7,299 +7,7 @@ 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);
-}
+import { GraphEngine, short_name, measure_node_width, layout_run, remove_overlaps, MAX_VISIBLE_DEPS } from "../util/graphengine.js"
////////////////////////////////////////////////////////////////////////////////
// synthetic data generation
@@ -399,43 +107,10 @@ export class Page extends ZenPage
? 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"];
+ this._sim_raf = null;
+
+ this._size_map = {};
const seed = parseInt(this.get_param("seed", "42"));
const root_deps = parseInt(this.get_param("root_deps", "40"));
@@ -446,9 +121,6 @@ export class Page extends ZenPage
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
@@ -469,22 +141,20 @@ export class Page extends ZenPage
this._info_el = info.inner();
const show_all = right.add("show all");
- show_all.on_click(() => this._clear_trace());
+ show_all.on_click(() => {
+ this._engine.clear_trace();
+ this._show_all_el.style.display = "none";
+ });
this._show_all_el = show_all.inner();
this._show_all_el.style.display = "none";
- right.add("fit").on_click(() => this._fit_view());
+ right.add("find").on_click(() => this._engine.show_search());
+ right.add("fit").on_click(() => this._engine.fit_view());
right.add("reset").on_click(() => this._reset_graph());
- // canvas + panel
+ // canvas
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();
@@ -493,127 +163,26 @@ export class Page extends ZenPage
canvas_el.style.height = h + "px";
canvas_el.width = canvas_el.offsetWidth;
canvas_el.height = h;
- panel.inner().style.height = h + "px";
- this._render();
+ if (this._engine)
+ this._engine.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();
+ // create engine
+ this._engine = new GraphEngine({
+ canvas: canvas_el,
+ size_map: this._size_map,
+ on_expand: (opkey) => this._on_expand(opkey),
+ empty_message: "empty graph",
+ friendly_kib: (n) => Friendly.kib(n),
});
// 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();
- });
- }
+ this._engine.build_legend(legend.inner());
+
+ // readme
{
const readme = legend.tag().id("graph_readme");
const readme_el = readme.inner();
@@ -630,9 +199,10 @@ export class Page extends ZenPage
'<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>' +
+ '<p><b>find</b> or <b>Ctrl+F</b> — search nodes in the graph</p>' +
+ '<p><b>legend</b> toggles filter edges by dependency type; unreachable nodes fade out</p>' +
+ '<p><b>compression</b> slider filters nodes by compression ratio; <b>fill</b> colors backgrounds</p>' +
+ '<p><b>leaves</b> / <b>unresolved</b> / <b>large</b> — quick-filter pills to highlight subsets</p>' +
'</div>';
readme_el.querySelector(".graph_readme_toggle").addEventListener("click", () => {
readme_el.classList.toggle("open");
@@ -655,6 +225,8 @@ export class Page extends ZenPage
}
}
+ // -- synthetic data mode ---------------------------------------------------
+
_generate_size(opkey)
{
if (this._size_map[opkey])
@@ -664,7 +236,7 @@ export class Page extends ZenPage
this._size_map[opkey] = { size: BigInt(Math.floor(Number(raw) * ratio)), raw_size: raw };
}
- _fetch_tree(opkey, dep_count_override)
+ _fetch_tree_synthetic(opkey, dep_count_override)
{
if (opkey in this._tree_cache)
return this._tree_cache[opkey];
@@ -682,285 +254,161 @@ export class Page extends ZenPage
_load_root(opkey, root_deps)
{
- const cx = this._canvas.width / 2;
- const cy = this._canvas.height / 2;
+ const engine = this._engine;
+ const cx = engine.canvas.width / 2;
+ const cy = engine.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);
+ this._fetch_tree_synthetic(opkey, per_type);
- const node = this._add_node(opkey, cx, cy, true);
- this._expand_node(node);
- this._fit_view();
+ const node = engine.add_node(opkey, cx, cy, true, short_name(opkey) + " [R]");
+ this._expand_synthetic(node, true);
+ engine.fit_view();
this._update_info();
}
- // -- real-data mode -------------------------------------------------------
-
- async _init_real_data()
+ _expand_synthetic(node, is_root)
{
- // 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();
+ const engine = this._engine;
+ this._sim_stop();
+ engine.expanded.add(node.opkey);
+ node.expanded = true;
- // 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())
+ const tree = this._fetch_tree_synthetic(node.opkey);
+ if (!tree)
{
- this._size_map[name] = { size: size, raw_size: raw_size };
- entry_count++;
+ engine.render();
+ return;
}
- this._entry_count = entry_count;
- this._build_prefix_tree();
- this._show_prefix_roots();
- this._fit_view();
- this._update_info();
- }
+ var dep_total = 0;
+ const dep_types = {};
+ const all_deps = [];
- _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())
+ for (const dep_name in tree)
{
- 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;
+ 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) });
}
- }
-
- _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);
+ node.dep_count = dep_total;
+ node.dep_types = dep_types;
- const cx = this._canvas.width / 2;
- const cy = this._canvas.height / 2;
- const count = child_keys.length;
- const radius = 300 + count * 8;
+ 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;
+ });
- 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 visible = Math.min(all_deps.length, MAX_VISIBLE_DEPS);
- const label = key + " (" + child_trie.count.toLocaleString() + ")";
- const prefix_path = "/" + key;
+ if (node.is_root)
+ this._expand_root_synthetic(node, all_deps, visible);
+ else
+ this._expand_child_synthetic(node, all_deps, visible);
- const node = this._add_group_node(prefix_path, label, x, y, child_trie);
- node.is_root = true;
- node.pinned = true;
+ // in the playground every generated node always has deps
+ for (const dep of all_deps.slice(0, MAX_VISIBLE_DEPS))
+ {
+ const dep_node = engine.node_map[dep.opkey];
+ if (dep_node)
+ dep_node.has_deps = 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;
+ engine.rebuild_edge_index();
+ this._update_info();
+ engine.render();
}
- async _fetch_tree_real(opkey)
+ _expand_root_synthetic(node, all_deps, visible)
{
- 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)
+ const engine = this._engine;
+ const radius = 200 + visible * 4;
+ var added = 0;
+ for (const dep of all_deps)
{
- this._tree_cache[opkey] = null;
- return null;
+ 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;
+ const label = short_name(dep.opkey);
+ engine.add_node(dep.opkey, node.x + Math.cos(angle) * r, node.y + Math.sin(angle) * r, false, label);
+ engine.add_edge(node, engine.node_map[dep.opkey], dep.dep_name);
+ added++;
}
- const entry_field = cbo.as_object().find("entry");
- if (!entry_field)
- {
- this._tree_cache[opkey] = null;
- return null;
- }
+ layout_run(engine.nodes, engine.edges, node.x, node.y, 80);
+ remove_overlaps(engine.nodes, 10, 8);
+ }
- 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);
+ _expand_child_synthetic(node, all_deps, visible)
+ {
+ const engine = this._engine;
+ const parent = this._find_parent_synthetic(node);
+ const outward_angle = parent
+ ? Math.atan2(node.y - parent.y, node.x - parent.x)
+ : 0;
- if (!tree)
+ // push node away from parent — skip if re-expanding in place
+ if (parent && !node._skip_push)
{
- this._tree_cache[opkey] = null;
- return null;
+ 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;
- 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 existing = new Set(engine.nodes);
- const tree = {};
+ const arc_span = Math.PI;
+ const arc_start = outward_angle - arc_span / 2;
+ const radius = 200 + visible * 4;
- const pkg_data = entry.find("packagedata");
- if (pkg_data)
+ var added = 0;
+ for (const dep of all_deps)
{
- 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;
+ 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;
+ const label = short_name(dep.opkey);
+ engine.add_node(dep.opkey, node.x + Math.cos(angle) * r, node.y + Math.sin(angle) * r, false, label);
+ engine.add_edge(node, engine.node_map[dep.opkey], dep.dep_name);
+ added++;
}
- const pkgst_entry = raw_pkgst_entry.as_object();
- for (const field of pkgst_entry)
+ for (const n of existing)
{
- 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));
+ n._was_pinned = n.pinned;
+ n.pinned = true;
}
- return tree;
- }
-
- // -- end real-data mode ---------------------------------------------------
+ layout_run(engine.nodes, engine.edges, node.x, node.y, 80);
+ remove_overlaps(engine.nodes, 10, 8);
- _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 });
- }
+ for (const n of existing)
+ {
+ n.pinned = n._was_pinned;
+ delete n._was_pinned;
+ }
- _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);
+ remove_overlaps(engine.nodes, 15, 20);
}
- _find_parent(node)
+ _find_parent_synthetic(node)
{
- // find all parents and pick the one closest to root
+ const engine = this._engine;
const parents = [];
- for (const e of this._edges)
+ for (const e of engine.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;
@@ -970,7 +418,7 @@ export class Page extends ZenPage
if (visited.has(cur)) return Infinity;
visited.add(cur);
var found = null;
- for (const e of this._edges)
+ for (const e of engine.edges)
{
if (e.target === cur) { found = e.source; break; }
}
@@ -996,82 +444,131 @@ export class Page extends ZenPage
return candidates[Math.floor(this._rng() * candidates.length)];
}
- async _expand_node(node)
+ // -- on_expand callback (dispatches to synthetic or real) ------------------
+
+ async _on_expand(opkey)
{
- if (this._expanded.has(node.opkey))
- return;
+ const engine = this._engine;
+ const node = engine.node_map[opkey];
+ if (!node) return null;
- // real mode: group node expansion
+ // real mode: group node expansion (handled directly, not via engine.expand_node)
if (this._real_mode && node.is_group)
{
+ // undo the expand state that engine.expand_node already set
+ engine.expanded.delete(opkey);
+ node.expanded = false;
await this._expand_group_node(node);
- return;
+ return []; // return empty so engine doesn't try to add deps
}
// real mode: entry node dep expansion
if (this._real_mode && !node.is_group)
- {
- await this._expand_real_entry(node);
- return;
- }
+ return await this._expand_real_entry_deps(node);
- // synthetic mode (unchanged)
- this._sim_stop();
- this._expanded.add(node.opkey);
- node.expanded = true;
+ // synthetic mode: handled directly
+ engine.expanded.delete(opkey);
+ node.expanded = false;
+ this._expand_synthetic(node, node.is_root);
+ return []; // return empty so engine doesn't try to add deps
+ }
- const tree = this._fetch_tree(node.opkey);
- if (!tree)
+ // -- 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._engine.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
+ this._size_map = {};
+ var entry_count = 0;
+ for (const [name, size, raw_size] of this._indexer.enum_all())
{
- this._render();
- return;
+ this._size_map[name] = { size: size, raw_size: raw_size };
+ entry_count++;
}
+ this._entry_count = entry_count;
- var dep_total = 0;
- const dep_types = {};
- const all_deps = [];
+ // update engine's size_map reference
+ this._engine._size_map = this._size_map;
- for (const dep_name in tree)
+ this._build_prefix_tree();
+ this._show_prefix_roots();
+ this._engine.fit_view();
+ this._update_info();
+ }
+
+ _build_prefix_tree()
+ {
+ this._prefix_tree = { count: 0, children: Object.create(null) };
+ for (const entry of this._indexer.enum_all())
{
- 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) });
+ const name = entry[0];
+ const segments = name.split('/').filter(p => p.length > 0);
+ var node = this._prefix_tree;
+ for (const seg of segments)
+ {
+ node.count++;
+ if (!node.children[seg])
+ node.children[seg] = { count: 0, children: Object.create(null) };
+ node = node.children[seg];
+ }
+ node.count++;
}
- 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);
+ _show_prefix_roots()
+ {
+ const engine = this._engine;
+ const trie = this._prefix_tree;
+ const child_keys = Object.keys(trie.children)
+ .sort((a, b) => trie.children[b].count - trie.children[a].count);
- if (node.is_root)
- this._expand_root(node, all_deps, visible);
- else
- this._expand_child(node, all_deps, visible);
+ const cx = engine.canvas.width / 2;
+ const cy = engine.canvas.height / 2;
+ const count = child_keys.length;
+ const radius = 300 + count * 8;
- // in the playground every generated node always has deps
- for (const dep of all_deps.slice(0, MAX_VISIBLE_DEPS))
+ for (var i = 0; i < count; ++i)
{
- const dep_node = this._node_map[dep.opkey];
- if (dep_node)
- dep_node.has_deps = true;
+ 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 = engine.add_group_node(prefix_path, label, x, y, child_trie);
+ node.is_root = true;
+ node.pinned = true;
}
- this._rebuild_edge_index();
- this._update_info();
- this._render();
+ layout_run(engine.nodes, engine.edges, cx, cy, 80);
+ remove_overlaps(engine.nodes, 10, 8);
+ engine.rebuild_edge_index();
}
- _expand_group_node(node)
+ async _expand_group_node(node)
{
+ const engine = this._engine;
this._sim_stop();
- this._expanded.add(node.opkey);
+ engine.expanded.add(node.opkey);
node.expanded = true;
const trie = node.prefix_node;
@@ -1082,11 +579,10 @@ export class Page extends ZenPage
trie.children[k].count > 1 || Object.keys(trie.children[k].children).length > 0
);
- const existing = new Set(this._nodes);
+ const existing = new Set(engine.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;
@@ -1111,20 +607,18 @@ export class Page extends ZenPage
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");
+ const child_node = engine.add_group_node(child_path, label, x, y, child_trie);
+ engine.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");
+ const entry_node = engine.add_node(child_path, x, y, false);
+ engine.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];
@@ -1144,8 +638,8 @@ export class Page extends ZenPage
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");
+ const entry_node = engine.add_node(entry_path, x, y, false);
+ engine.add_edge(node, entry_node, "group");
}
}
@@ -1156,8 +650,8 @@ export class Page extends ZenPage
n.pinned = true;
}
- layout_run(this._nodes, this._edges, node.x, node.y, 80);
- remove_overlaps(this._nodes, 10, 8);
+ layout_run(engine.nodes, engine.edges, node.x, node.y, 80);
+ remove_overlaps(engine.nodes, 10, 8);
for (const n of existing)
{
@@ -1165,1217 +659,199 @@ export class Page extends ZenPage
delete n._was_pinned;
}
- remove_overlaps(this._nodes, 15, 20);
+ remove_overlaps(engine.nodes, 15, 20);
- this._rebuild_edge_index();
+ engine.rebuild_edge_index();
this._update_info();
- this._render();
+ engine.render();
}
- async _expand_real_entry(node)
+ async _expand_real_entry_deps(node)
{
- this._sim_stop();
- this._expanded.add(node.opkey);
- node.expanded = true;
- node.loading = true;
- this._render();
+ const tree = await this._fetch_tree_real(node.opkey);
+ if (!tree) return null;
- // start pulsing animation while loading
- const pulse_raf = setInterval(() => this._render(), 50);
+ const result = [];
+ var dep_total = 0;
+ const dep_types = {};
- try
+ for (const dep_name in tree)
{
- 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 = [];
+ const count = tree[dep_name].length;
+ dep_total += count;
+ dep_types[dep_name] = count;
- for (const dep_name in tree)
+ for (const dep_id of tree[dep_name])
{
- 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)
{
- 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 });
+ 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;
+ result.push({ opkey, dep_type: dep_name, unresolved: 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;
- }
+ // sort by dep type then size
+ result.sort((a, b) => {
+ if (a.dep_type !== b.dep_type)
+ return a.dep_type < b.dep_type ? -1 : 1;
+ return b.size - a.size;
+ });
- 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();
- }
+ // schedule async has_deps pre-check for new children
+ setTimeout(() => this._precheck_has_deps_real(result), 0);
- _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");
- }
+ // update info after engine finishes
+ setTimeout(() => this._update_info(), 0);
- _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);
- }
+ return result;
}
- // -- rendering ----
-
- _render()
+ async _precheck_has_deps_real(deps)
{
- 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);
- };
+ const engine = this._engine;
+ const new_deps = deps.filter(d => !d.unresolved).slice(0, MAX_VISIBLE_DEPS);
+ if (new_deps.length === 0) return;
- // 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)
+ await Promise.all(new_deps.map(async (d) => {
+ const dep_node = engine.node_map[d.opkey];
+ if (!dep_node || dep_node.has_deps !== undefined)
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));
- }
+ const dep_tree = await this._fetch_tree_real(d.opkey);
+ if (!dep_tree)
+ dep_node.has_deps = false;
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;
+ var has = false;
+ for (const k in dep_tree)
+ if (dep_tree[k].length > 0) { has = true; break; }
+ dep_node.has_deps = has;
}
- 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);
- }
+ }));
+ engine.render();
}
- // -- hit testing ----
-
- _hit_test(mx, my)
+ async _fetch_tree_real(opkey)
{
- const gx = (mx - this._transform.x) / this._transform.scale;
- const gy = (my - this._transform.y) / this._transform.scale;
+ if (opkey in this._tree_cache)
+ return this._tree_cache[opkey];
- // expand hit area when zoomed out so nodes are easier to click
- const pad = Math.max(0, 8 / this._transform.scale - 8);
+ const cbo = await new Fetcher()
+ .resource("prj", this._project, "oplog", this._oplog, "entries")
+ .param("opkey", opkey)
+ .cbo();
- for (var i = this._nodes.length - 1; i >= 0; --i)
+ if (!cbo)
{
- 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;
+ this._tree_cache[opkey] = null;
+ return null;
}
- return null;
- }
-
- // -- property panel ----
-
- _update_prop_panel(node)
- {
- const el = this._prop_panel.inner();
- if (!node)
+ const entry_field = cbo.as_object().find("entry");
+ if (!entry_field)
{
- el.innerHTML = '<div class="graph_props_empty">hover a node</div>';
- return;
+ this._tree_cache[opkey] = null;
+ return null;
}
- 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));
- }
- }
+ const entry = entry_field.as_object();
+ var tree = entry.find("$tree");
+ if (tree != undefined)
+ tree = tree.as_object().to_js_object();
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 + "%");
- }
- }
- }
+ tree = this._convert_legacy_to_tree(entry);
- if (node.dep_count > 0)
+ if (!tree)
{
- 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);
+ this._tree_cache[opkey] = null;
+ return null;
}
- 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;
+ delete tree["$id"];
+ this._tree_cache[opkey] = tree;
+ return tree;
}
- // -- context menu ----
-
- _show_context_menu(mx, my, node)
+ _convert_legacy_to_tree(entry)
{
- 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();
- });
- }
+ const raw_pkgst_entry = entry.find("packagestoreentry");
+ if (raw_pkgst_entry == undefined)
+ return null;
- // 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());
- }
+ const tree = {};
- _hide_context_menu()
- {
- if (this._ctxmenu)
+ const pkg_data = entry.find("packagedata");
+ if (pkg_data)
{
- 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)
+ var id = 0n;
+ for (var item of pkg_data.as_array())
{
- 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();
- }
+ var pkg_id = item.as_object().find("id");
+ if (pkg_id == undefined)
+ continue;
- _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))
+ pkg_id = pkg_id.as_value().subarray(0, 8);
+ for (var i = 7; i >= 0; --i)
{
- visible.add(edge.source);
- queue.push(edge.source);
+ id <<= 8n;
+ id |= BigInt(pkg_id[i]);
}
+ break;
}
+ tree["$id"] = id;
}
- 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 pkgst_entry = raw_pkgst_entry.as_object();
+ for (const field of pkgst_entry)
{
- 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;
+ const field_name = field.get_name();
+ if (!field_name.endsWith("importedpackageids"))
+ continue;
- 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;
- }
+ var dep_name = field_name.slice(0, -18);
+ if (dep_name.length == 0)
+ dep_name = "imported";
- 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();
+ var out = tree[dep_name] = [];
+ for (var item of field.as_array())
+ out.push(item.as_value(BigInt));
}
- }
- _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);
- }
+ return tree;
}
- _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);
- }
+ // -- shared helpers -------------------------------------------------------
- async _reexpand_node(node)
+ _sim_stop()
{
- 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)
+ if (this._sim_raf)
{
- 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);
- }
+ cancelAnimationFrame(this._sim_raf);
+ this._sim_raf = null;
}
-
- // 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)
+ _update_info()
{
- 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();
+ if (!this._info_el) return;
+ const engine = this._engine;
+ var text = "nodes: " + engine.nodes.length + " edges: " + engine.edges.length;
+ if (this._real_mode && this._entry_count)
+ text += " entries: " + this._entry_count.toLocaleString();
+ this._info_el.textContent = text;
}
- _on_wheel(e)
+ _reset_graph()
{
- 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();
+ this._sim_stop();
+ this._engine.reset();
+ this._show_all_el.style.display = "none";
+ this._update_info();
}
// -- stress test ----------------------------------------------------------
@@ -2384,7 +860,7 @@ export class Page extends ZenPage
{
const overlay = document.createElement("div");
overlay.id = "stress_overlay";
- this._canvas.parentElement.appendChild(overlay);
+ this._engine.canvas.parentElement.appendChild(overlay);
this._stress_overlay = overlay;
}
@@ -2420,6 +896,7 @@ export class Page extends ZenPage
async _run_stress_test()
{
+ const engine = this._engine;
this._create_stress_overlay();
this._stress_log("stress test started");
@@ -2447,57 +924,55 @@ export class Page extends ZenPage
// 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);
+ if (engine.nodes.length <= 1)
+ throw new Error("expected >1 nodes after root expansion, got " + engine.nodes.length);
});
// 2. Expand 5 children
await step(2, "expand 5 children", async () => {
- const root = this._nodes[0];
- const children = this._nodes.filter(n =>
+ const children = engine.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 engine.expand_node(child);
await this._stress_delay(200);
}
- this._stress_log(" expanded " + to_expand.length + " children, total nodes: " + this._nodes.length);
+ this._stress_log(" expanded " + to_expand.length + " children, total nodes: " + engine.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);
+ const expanded = engine.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();
+ const before = engine.nodes.length;
+ engine.collapse_node(node);
+ engine.rebuild_edge_index();
this._update_info();
- this._render();
+ engine.render();
await this._stress_delay(200);
- const after_collapse = this._nodes.length;
- await this._expand_node(node);
+ const after_collapse = engine.nodes.length;
+ await engine.expand_node(node);
await this._stress_delay(200);
- this._stress_log(" node " + (i+1) + ": " + before + " -> " + after_collapse + " -> " + this._nodes.length);
+ this._stress_log(" node " + (i+1) + ": " + before + " -> " + after_collapse + " -> " + engine.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)
+ for (const n of engine.nodes)
{
if (n.is_root || n.expanded) continue;
- const idx = this._edge_index.get(n);
+ const idx = engine.edge_index.get(n);
if (idx && idx.outgoing.length === 0)
{
leaf = n;
@@ -2505,63 +980,58 @@ export class Page extends ZenPage
}
}
if (!leaf)
- {
- // fallback: pick any non-root non-expanded node
- leaf = this._nodes.find(n => !n.is_root && !n.expanded);
- }
+ leaf = engine.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)
+ engine.trace_to_root(leaf);
+ if (!engine.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);
+ this._stress_log(" traced " + leaf.label + ", visible set size: " + engine.visible_set.size);
});
// 5. Clear trace
await step(5, "clear trace", async () => {
- this._clear_trace();
- if (this._visible_set !== null)
+ engine.clear_trace();
+ if (engine.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();
+ engine.fit_view();
});
// 7. Fit subtree
await step(7, "fit subtree", async () => {
- const expanded = this._nodes.find(n => n.expanded && !n.is_root);
+ const expanded = engine.nodes.find(n => n.expanded && !n.is_root);
if (!expanded)
throw new Error("no expanded non-root node found");
- this._fit_subtree(expanded);
+ engine.fit_subtree(expanded);
});
// 8. Toggle compression fill
await step(8, "toggle compression fill", async () => {
- this._compression_fill = true;
- this._render();
+ engine.set_compression_fill(true);
await this._stress_delay(200);
- this._compression_fill = false;
- this._render();
+ engine.set_compression_fill(false);
});
// 9. Expand to 500+ nodes
await step(9, "expand to 500+ nodes", async () => {
var safety = 0;
- while (this._nodes.length < 500 && safety < 100)
+ while (engine.nodes.length < 500 && safety < 100)
{
- const unexpanded = this._nodes.find(n =>
+ const unexpanded = engine.nodes.find(n =>
!n.expanded && !n.is_root && n.has_deps !== false);
if (!unexpanded) break;
- await this._expand_node(unexpanded);
+ await engine.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");
+ this._stress_log(" total nodes: " + engine.nodes.length + " (expanded " + safety + " nodes)");
+ if (engine.nodes.length < 500)
+ throw new Error("only reached " + engine.nodes.length + " nodes");
});
// 10. Render benchmark
@@ -2569,10 +1039,10 @@ export class Page extends ZenPage
const iters = 20;
const t0 = performance.now();
for (var i = 0; i < iters; ++i)
- this._render();
+ engine.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)");
+ this._stress_log(" " + iters + " renders, avg " + avg + " ms/render (" + engine.nodes.length + " nodes)");
});
// summary
diff --git a/src/zenserver/frontend/html/pages/graph.js b/src/zenserver/frontend/html/pages/graph.js
index cce4dd3c1..ca6daa9e9 100644
--- a/src/zenserver/frontend/html/pages/graph.js
+++ b/src/zenserver/frontend/html/pages/graph.js
@@ -7,121 +7,7 @@ import { Fetcher } from "../util/fetcher.js"
import { Friendly } from "../util/friendly.js"
import { Toolbar, ProgressBar } from "../util/widgets.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;
-
-// offscreen canvas for text measurement
-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;
-}
-
-const DOT_SPACE = 10; // extra width for compression dot
-
-function measure_node_width(label)
-{
- return _measure_ctx.measureText(label).width + NODE_PAD * 2 + DOT_SPACE;
-}
-
-// interpolate between two hex colors (#rrggbb)
-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 + ")";
-}
-
-////////////////////////////////////////////////////////////////////////////////
-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;
-
- for (var i = 0; i < nodes.length; ++i)
- {
- var fx = 0, fy = 0;
-
- // repulsion from all other nodes
- for (var j = 0; j < nodes.length; ++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;
- }
-
- // gravity toward center
- fx += (cx - nodes[i].x) * gravity;
- fy += (cy - nodes[i].y) * gravity;
-
- nodes[i].fx = fx;
- nodes[i].fy = fy;
- }
-
- // spring attraction along edges
- 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;
- }
-
- // apply forces
- 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);
-}
+import { GraphEngine, MAX_VISIBLE_DEPS } from "../util/graphengine.js"
////////////////////////////////////////////////////////////////////////////////
export class Page extends ZenPage
@@ -134,38 +20,7 @@ export class Page extends ZenPage
this.set_title("graph");
- this._nodes = [];
- this._edges = [];
- this._node_map = {};
this._tree_cache = {};
- this._expanded = new Set();
- this._hidden_dep_types = new Set();
-
- 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: [] };
-
- // compression ratio color stops (viridis): purple (good) → yellow (poor)
- this._ratio_colors = ["#482878", "#2d708e", "#20a386", "#75d054", "#fde725"];
this._indexer = this._load_indexer(project, oplog);
const section = this.add_section(project + " - " + oplog);
@@ -199,39 +54,28 @@ export class Page extends ZenPage
const left = toolbar.left();
const right = toolbar.right();
- const search_wrap = left.add("", "div");
- search_wrap.inner().style.position = "relative";
- const search_input = search_wrap.tag("input");
- search_input.attr("type", "text");
- search_input.attr("placeholder", "search entry...");
- search_input.inner().style.width = "30em";
-
- const dropdown = search_wrap.tag().id("graph_search_results");
- dropdown.inner().style.display = "none";
-
- search_input.inner().addEventListener("input", (e) => {
- this._on_search(e.target.value.trim(), dropdown);
- });
- search_input.inner().addEventListener("focus", (e) => {
- if (e.target.value.trim().length > 0)
- this._on_search(e.target.value.trim(), dropdown);
- });
- document.addEventListener("click", (e) => {
- if (!search_wrap.inner().contains(e.target))
- dropdown.inner().style.display = "none";
+ const show_all = right.add("show all");
+ show_all.on_click(() => {
+ this._engine.clear_trace();
+ this._show_all_el.style.display = "none";
});
+ this._show_all_el = show_all.inner();
+ this._show_all_el.style.display = "none";
- right.add("fit").on_click(() => this._fit_view());
+ right.add("find").on_click(() => this._engine.show_search());
+ right.add("fit").on_click(() => this._engine.fit_view());
right.add("reset").on_click(() => this._reset_graph());
- this._search_input = search_input;
- this._dropdown = dropdown;
+ // breadcrumb path filter
+ const breadcrumb = section.tag().classify("graph_breadcrumb");
// canvas + entry list + legend wrapper
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;
+
+ // splitter
+ const splitter = view.tag().id("graph_splitter");
// entry list panel
const panel = view.tag().id("graph_entries");
@@ -241,11 +85,6 @@ export class Page extends ZenPage
const panel_list = panel.tag().id("graph_entries_list");
this._panel_list = panel_list;
this._panel_filter = panel_filter;
- this._populate_entries(panel_list, panel_filter);
-
- // property panel (below entry list)
- 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();
@@ -255,77 +94,58 @@ export class Page extends ZenPage
canvas_el.width = canvas_el.offsetWidth;
canvas_el.height = h;
panel.inner().style.height = h + "px";
- this._render();
+ if (this._engine)
+ this._engine.render();
};
resize();
window.addEventListener("resize", resize);
- this._ctx = canvas_el.getContext("2d");
+ // create engine
+ this._engine = new GraphEngine({
+ canvas: canvas_el,
+ splitter: splitter.inner(),
+ size_map: this._size_map,
+ on_expand: (opkey) => this._on_expand(opkey),
+ empty_message: "search for an entry to view its dependency graph",
+ friendly_kib: (n) => Friendly.kib(n),
+ });
+
+ // populate entries after engine exists (async, accesses engine.nodes)
+ this._populate_entries(panel_list, panel_filter);
- 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("wheel", (e) => this._on_wheel(e), { passive: false });
+ // breadcrumb: sync with tree browser
+ this._engine.build_breadcrumb(breadcrumb.inner());
// 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));
- }
-
- // compression ratio legend
- {
- 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");
+ this._engine.build_legend(legend.inner());
+
+ // readme
+ {
+ 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>find</b> or <b>Ctrl+F</b> — search nodes in the graph</p>' +
+ '<p><b>breadcrumb</b> — click path segments to filter by directory</p>' +
+ '<p><b>legend</b> toggles filter edges by dependency type; unreachable nodes fade out</p>' +
+ '<p><b>compression</b> slider filters nodes by compression ratio; <b>fill</b> colors backgrounds</p>' +
+ '<p><b>leaves</b> / <b>unresolved</b> / <b>large</b> — quick-filter pills to highlight subsets</p>' +
+ '</div>';
+ readme_el.querySelector(".graph_readme_toggle").addEventListener("click", () => {
+ readme_el.classList.toggle("open");
+ });
}
if (opkey)
@@ -334,311 +154,89 @@ export class Page extends ZenPage
async _load_root(opkey)
{
- const cx = this._canvas.width / 2;
- const cy = this._canvas.height / 2;
+ const engine = this._engine;
+ const cx = engine.canvas.width / 2;
+ const cy = engine.canvas.height / 2;
- const node = this._add_node(opkey, cx, cy, true);
- await this._expand_node(node);
+ const node = engine.add_node(opkey, cx, cy, true);
+ await engine.expand_node(node);
+ engine.fit_view();
this._navigate_tree_to(opkey);
- }
-
- async _populate_entries(list, filter_input)
- {
- const indexer = await this._indexer;
- const all_names = [];
- for (const name of indexer.enum_names())
- all_names.push(name);
- all_names.sort();
- this._all_names = all_names;
- filter_input.inner().addEventListener("input", (e) => {
- const needle = e.target.value.trim();
- if (needle.length >= 2)
- this._render_filtered_entries(list, needle);
- else
- this._render_tree_level(list, "/");
+ // set breadcrumb to the root entry's parent directory
+ const last_slash = opkey.lastIndexOf("/");
+ const dir = last_slash > 0 ? opkey.substring(0, last_slash) : null;
+ engine.set_breadcrumb_prefix(dir, (prefix) => {
+ // breadcrumb click → navigate tree browser
+ const tree_prefix = prefix ? prefix + "/" : "/";
+ this._render_tree_level(this._panel_list, tree_prefix);
});
- this._render_tree_level(list, "/");
+ // pre-check which new deps have their own dependencies
+ this._precheck_has_deps();
}
- _render_tree_level(list, prefix)
+ async _precheck_has_deps()
{
- list.inner().innerHTML = "";
- this._current_prefix = prefix;
- const children = {};
- for (const name of this._all_names)
- {
- if (!name.startsWith(prefix))
- continue;
- var rest = name.substr(prefix.length);
- const slash = rest.indexOf("/");
- if (slash != -1)
- rest = rest.substr(0, slash + 1);
- if (children[rest] === undefined)
- children[rest] = 0;
- children[rest]++;
- }
-
- const sorted = Object.keys(children).sort((a, b) => {
- const ad = a.endsWith("/"), bd = b.endsWith("/");
- if (ad != bd) return ad ? -1 : 1;
- return a < b ? -1 : a > b ? 1 : 0;
- });
-
- // collect root opkeys for highlighting
- const roots = new Set();
- for (const n of this._nodes)
- if (n.is_root)
- roots.add(n.opkey);
-
- for (const child of sorted)
- {
- const item = list.tag();
- const is_dir = child.endsWith("/");
- const full_path = prefix + child;
-
- // check if any root lives under this directory
- var has_root = false;
- if (is_dir)
- {
- for (const r of roots)
- if (r.startsWith(full_path)) { has_root = true; break; }
- }
-
- const display = is_dir ? child.slice(0, -1) + "/ (" + children[child] + ")" : child;
- item.text(display);
-
- if (is_dir)
- {
- item.classify("graph_entry_dir");
- if (has_root)
- item.classify("graph_entry_active");
- item.on("click", () => {
- this._render_tree_level(list, full_path);
- });
- }
+ const engine = this._engine;
+ const new_deps = engine.nodes.filter(n =>
+ !n.is_root && !n.unresolved && n.has_deps === undefined);
+ if (new_deps.length === 0) return;
+
+ const has_deps = new Set();
+ await Promise.all(new_deps.map(async (n) => {
+ const tree = await this._fetch_tree(n.opkey);
+ if (!tree) return;
+ var has = false;
+ for (const k in tree)
+ if (tree[k].length > 0) { has = true; break; }
+ if (has)
+ has_deps.add(n.opkey);
else
- {
- item.classify("graph_entry_leaf");
- if (roots.has(full_path))
- item.classify("graph_entry_active");
- item.on("click", () => {
- this._select_entry(full_path);
- });
- }
- }
-
- if (prefix != "/")
- {
- const back = list.inner().insertBefore(
- document.createElement("div"), list.inner().firstChild);
- back.textContent = "..";
- back.className = "graph_entry_dir";
- back.style.cursor = "pointer";
- const parent = prefix.slice(0, prefix.slice(0, -1).lastIndexOf("/") + 1) || "/";
- back.addEventListener("click", () => {
- this._render_tree_level(list, parent);
- });
- }
- }
-
- _navigate_tree_to(opkey)
- {
- // find the parent directory of the entry
- const last_slash = opkey.lastIndexOf("/");
- const prefix = (last_slash >= 0) ? opkey.substring(0, last_slash + 1) : "/";
- this._panel_filter.inner().value = "";
- this._render_tree_level(this._panel_list, prefix);
-
- // scroll the entry into view
- const el = this._panel_list.inner();
- for (const child of el.children)
- if (child.classList.contains("graph_entry_active"))
- { child.scrollIntoView({ block: "nearest" }); break; }
- }
-
- _refresh_panel()
- {
- const prefix = this._current_prefix || "/";
- this._render_tree_level(this._panel_list, prefix);
+ n.has_deps = false;
+ }));
+ engine.mark_has_deps(has_deps);
}
- _update_prop_panel(node)
+ async _on_expand(opkey)
{
- 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);
-
- 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 + "%");
- }
- }
-
- // count incoming/outgoing edges
- var incoming = 0, outgoing = 0;
- for (const edge of this._edges)
- {
- if (edge.source === node) outgoing++;
- if (edge.target === node) incoming++;
- }
- if (outgoing > 0 || node.dep_count > 0)
- {
- var dep_str = "" + (node.dep_count || outgoing);
- 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);
- }
- if (incoming > 0)
- row("imported by", incoming + " node" + (incoming > 1 ? "s" : ""));
-
- // depth from root
- const depth = this._depth_to_root(node);
- if (depth >= 0)
- row("depth", depth);
-
- // status
- const tags = [];
- if (node.is_root) tags.push("root");
- if (node.expanded) tags.push("expanded");
- if (node.unresolved) tags.push("unresolved");
- if (node.truncated) tags.push("truncated");
- if (tags.length > 0)
- row("status", tags.join(", "));
-
- el.innerHTML = html;
- }
+ const tree = await this._fetch_tree(opkey);
+ if (!tree) return null;
- _depth_to_root(node)
- {
- const visited = new Set();
- var current = node;
- var depth = 0;
- while (current && !current.is_root)
+ const indexer = await this._indexer;
+ const result = [];
+ for (const dep_name in tree)
{
- if (visited.has(current)) return -1;
- visited.add(current);
- var parent = null;
- for (const edge of this._edges)
+ for (const dep_id of tree[dep_name])
{
- if (edge.target === current)
+ var resolved = indexer.lookup_id(dep_id);
+ var is_unresolved = false;
+ if (!resolved)
{
- parent = edge.source;
- break;
+ resolved = "0x" + dep_id.toString(16).padStart(16, "0");
+ is_unresolved = true;
}
+ const sizes = this._size_map[resolved];
+ result.push({
+ opkey: resolved,
+ dep_type: dep_name,
+ unresolved: is_unresolved,
+ size: sizes ? Number(sizes.raw_size) : 0,
+ });
}
- current = parent;
- depth++;
- }
- return current ? depth : -1;
- }
-
- _render_filtered_entries(list, needle)
- {
- list.inner().innerHTML = "";
- const lwr = needle.toLowerCase();
- var count = 0;
- for (const name of this._all_names)
- {
- if (name.toLowerCase().indexOf(lwr) < 0)
- continue;
- if (count >= 200)
- {
- list.tag().text("...").classify("graph_entries_more");
- break;
- }
- const item = list.tag();
- item.text(name);
- item.classify("graph_entry_leaf");
- item.on("click", () => {
- this._select_entry(name);
- });
- count++;
}
- }
-
- _select_entry(name)
- {
- this._reset_graph();
- this.set_param("opkey", name);
- this._search_input.inner().value = name;
- this._load_root(name);
- }
- _add_node(opkey, x, y, is_root)
- {
- if (this._node_map[opkey])
- return this._node_map[opkey];
-
- const label = short_name(opkey);
- const pad = is_root ? NODE_PAD * 3 : 0;
- const node = {
- opkey: opkey,
- label: label,
- w: measure_node_width(label) + pad,
- h: NODE_H + (is_root ? 10 : 0),
- 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;
- }
+ // sort: group by dep type, then largest first within each type
+ result.sort((a, b) => {
+ if (a.dep_type !== b.dep_type)
+ return a.dep_type < b.dep_type ? -1 : 1;
+ return b.size - a.size;
+ });
- _node_color(node)
- {
- const sizes = this._size_map[node.opkey];
- if (!sizes || sizes.raw_size == 0n)
- return this._colors.g1;
-
- // ratio: 0 = no compression (size == raw), 1 = perfect (size == 0)
- const ratio = 1.0 - Number(sizes.size) / Number(sizes.raw_size);
- // map [0..1] across the color stops
- 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);
- }
+ // schedule async has_deps pre-check for newly added children
+ setTimeout(() => this._precheck_has_deps(), 0);
- _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 });
+ return result;
}
async _fetch_tree(opkey)
@@ -732,632 +330,177 @@ export class Page extends ZenPage
return tree;
}
- async _expand_node(node)
+ _reset_graph()
{
- if (this._expanded.has(node.opkey))
- return;
+ this._engine.reset();
+ this._show_all_el.style.display = "none";
+ this._refresh_panel();
+ }
- this._expanded.add(node.opkey);
- node.expanded = true;
-
- const tree = await this._fetch_tree(node.opkey);
- if (!tree)
- {
- this._render();
- return;
- }
+ // -- entry list panel ------------------------------------------------------
+ async _populate_entries(list, filter_input)
+ {
const indexer = await this._indexer;
+ const all_names = [];
+ for (const name of indexer.enum_names())
+ all_names.push(name);
+ all_names.sort();
+ this._all_names = all_names;
- // collect all deps, resolve names, sort by type then size
- 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 = 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: group by dep type, then largest first within each type
- 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;
+ filter_input.inner().addEventListener("input", (e) => {
+ const needle = e.target.value.trim();
+ const prefix = this._current_prefix || "/";
+ if (needle.length >= 2)
+ this._render_filtered_entries(list, needle, prefix);
+ else
+ this._render_tree_level(list, prefix);
});
- // determine arc: full circle for root, semi-circle for non-root
- var arc_start, arc_span;
- if (node.is_root)
- {
- arc_start = 0;
- arc_span = 2 * Math.PI;
- }
- else
- {
- // find root node and point away from it
- const root = this._nodes.find(n => n.is_root);
- const root_angle = root
- ? Math.atan2(node.y - root.y, node.x - root.x)
- : 0;
- // semi-circle facing away from root
- arc_start = root_angle - Math.PI / 2;
- arc_span = Math.PI;
- }
-
- const visible = Math.min(all_deps.length, MAX_VISIBLE_DEPS);
- const radius = 150 + visible * 3;
-
- 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() * 30;
- const dx = node.x + Math.cos(angle) * r;
- const dy = node.y + Math.sin(angle) * r;
- const dep_node = this._add_node(dep.opkey, dx, dy, false);
- dep_node.unresolved = dep.is_unresolved;
- this._add_edge(node, dep_node, dep.dep_name);
- added++;
- }
-
- const cx = this._canvas.width / 2;
- const cy = this._canvas.height / 2;
- layout_run(this._nodes, this._edges, cx, cy, 200);
- this._render();
- }
-
- _reset_graph()
- {
- this._nodes = [];
- this._edges = [];
- this._node_map = {};
- this._expanded = new Set();
- this._transform = { x: 0, y: 0, scale: 1.0 };
- this._render();
+ this._render_tree_level(list, "/");
}
- _fit_view()
+ _render_tree_level(list, prefix)
{
- if (this._nodes.length == 0)
- return;
+ list.inner().innerHTML = "";
+ this._current_prefix = prefix;
- var min_x = Infinity, min_y = Infinity;
- var max_x = -Infinity, max_y = -Infinity;
- for (const n of this._nodes)
+ // sync breadcrumb with tree browser (strip trailing /)
+ if (this._engine)
{
- 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 bc = (prefix && prefix !== "/") ? prefix.replace(/\/$/, "") : null;
+ this._engine.set_breadcrumb_prefix(bc, (p) => {
+ const tree_prefix = p ? p + "/" : "/";
+ this._render_tree_level(this._panel_list, tree_prefix);
+ });
}
- 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");
- }
-
- // -- rendering ----
-
- _render()
- {
- const ctx = this._ctx;
- if (!ctx) return;
-
- const c = this._colors;
- const canvas = this._canvas;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
-
- ctx.save();
- ctx.translate(this._transform.x, this._transform.y);
- ctx.scale(this._transform.scale, this._transform.scale);
-
- // find where a line from (sx,sy) to the center of a rect intersects the rect border
- const rect_edge = (cx, cy, hw, hh, sx, sy) => {
- var dx = sx - cx;
- var dy = sy - cy;
- if (dx == 0 && dy == 0) dx = 1;
- // scale so the point lies on the rect boundary
- 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];
- };
-
- // draw edges — two passes: dim non-highlighted, then highlighted on top
- const hover = this._hover_node;
- 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 / this._transform.scale;
- ctx.setLineDash(dep.dash.map(v => v / this._transform.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 / this._transform.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();
- }
- };
-
- // pass 1: non-highlighted edges (dimmed if there is a hover on non-root)
- const do_highlight = hover && !hover.is_root;
- for (const edge of this._edges)
+ const children = {};
+ for (const name of this._all_names)
{
- if (this._is_edge_hidden(edge))
- continue;
- if (do_highlight && (edge.source === hover || edge.target === hover))
+ if (!name.startsWith(prefix))
continue;
- if (do_highlight)
- {
- ctx.globalAlpha = 0.2;
- draw_edge(edge);
- ctx.globalAlpha = 1.0;
- }
- else
- draw_edge(edge);
- }
-
- // pass 2: highlighted edges for hover node (non-root only)
- if (do_highlight)
- {
- // trace path from hover node back to root
- const path_edges = new Set();
- const visited = new Set();
- const trace = (node) => {
- if (visited.has(node)) return;
- visited.add(node);
- for (const edge of this._edges)
- {
- if (this._is_edge_hidden(edge))
- continue;
- if (edge.target === node)
- {
- path_edges.add(edge);
- trace(edge.source);
- }
- }
- };
- trace(hover);
-
- // also include direct outgoing edges from hover
- for (const edge of this._edges)
- {
- if (edge.source === hover && !this._is_edge_hidden(edge))
- path_edges.add(edge);
- }
-
- for (const edge of path_edges)
- draw_edge(edge, c.p0, 3);
+ var rest = name.substr(prefix.length);
+ const slash = rest.indexOf("/");
+ if (slash != -1)
+ rest = rest.substr(0, slash + 1);
+ if (children[rest] === undefined)
+ children[rest] = 0;
+ children[rest]++;
}
- ctx.setLineDash([]);
-
- // draw nodes — two passes: leaf nodes first, then root/expanded on top
- 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);
-
- if (node === this._hover_node)
- ctx.fillStyle = c.p2;
- else if (node.is_root)
- ctx.fillStyle = c.p0;
- else if (node.unresolved)
- ctx.fillStyle = c.g3;
- else
- ctx.fillStyle = c.g3;
- ctx.fill();
-
- const node_sizes = this._size_map[node.opkey];
- const has_dot = !node.is_root && !node.unresolved && node_sizes && node_sizes.raw_size > 0n;
- const text_x = has_dot ? node.x + DOT_SPACE / 2 : node.x;
-
- ctx.fillStyle = (node.is_root || node === this._hover_node) ? c.g4 : c.g0;
- if (node.unresolved)
- ctx.fillStyle = c.g1;
- ctx.fillText(node.label, text_x, node.y);
-
- // compression ratio dot
- 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.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";
- }
- };
-
- // pass 1: leaf nodes (not root, not expanded)
- for (const node of this._nodes)
- if (!node.is_root && !node.expanded && node !== this._hover_node)
- draw_node(node);
-
- // pass 2: expanded nodes
- for (const node of this._nodes)
- if (node.expanded && !node.is_root && node !== this._hover_node)
- draw_node(node);
-
- // pass 3: root node (always on top)
- for (const node of this._nodes)
- if (node.is_root)
- draw_node(node);
-
- // pass 4: hovered node (topmost)
- if (this._hover_node && this._transform.scale >= 0.6)
- draw_node(this._hover_node);
-
- ctx.restore();
+ const sorted = Object.keys(children).sort((a, b) => {
+ const ad = a.endsWith("/"), bd = b.endsWith("/");
+ if (ad != bd) return ad ? -1 : 1;
+ return a < b ? -1 : a > b ? 1 : 0;
+ });
- // pass 4b: when zoomed out, draw hovered node in screen space at readable size
- if (this._hover_node && this._transform.scale < 0.6)
- {
- const node = this._hover_node;
- const sx = node.x * this._transform.scale + this._transform.x;
- const sy = node.y * this._transform.scale + this._transform.y;
-
- ctx.font = "11px consolas, monospace";
- ctx.textBaseline = "middle";
- ctx.textAlign = "center";
- const tw = ctx.measureText(node.label).width + NODE_PAD * 2;
- 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, sx, sy);
- }
+ // collect root opkeys for highlighting
+ const roots = new Set();
+ for (const n of this._engine.nodes)
+ if (n.is_root)
+ roots.add(n.opkey);
- // tooltip for hover node
- if (this._hover_node)
+ for (const child of sorted)
{
- const node = this._hover_node;
- ctx.font = "11px consolas, monospace";
- ctx.textAlign = "left";
- ctx.textBaseline = "top";
-
- // build tooltip lines
- const lines = [node.opkey];
- 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");
-
- if (node.is_root) lines.push("[root]");
- if (node.unresolved) lines.push("[unresolved]");
+ const item = list.tag();
+ const is_dir = child.endsWith("/");
+ const full_path = prefix + child;
- // measure tooltip size
- const line_h = 15;
- const pad = 6;
- var tw = 0;
- for (const line of lines)
+ // check if any root lives under this directory
+ var has_root = false;
+ if (is_dir)
{
- const w = ctx.measureText(line).width;
- if (w > tw) tw = w;
+ for (const r of roots)
+ if (r.startsWith(full_path)) { has_root = true; break; }
}
- tw += pad * 2;
- const th = lines.length * line_h + pad * 2;
-
- // position: anchored below the node in screen space
- const node_sx = node.x * this._transform.scale + this._transform.x;
- const node_sy = node.y * this._transform.scale + this._transform.y;
- const node_sh = node.h * this._transform.scale;
- var tx = node_sx - tw / 2;
- var ty = node_sy + node_sh / 2 + 16;
-
- // clamp to canvas bounds
- 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);
- }
- // empty state message
- if (this._nodes.length == 0)
- {
- ctx.fillStyle = c.g1;
- ctx.font = "14px consolas, monospace";
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText("search for an entry to view its dependency graph",
- canvas.width / 2, canvas.height / 2);
- }
- }
-
- // -- hit testing ----
-
- _hit_test(mx, my)
- {
- // convert screen coords to graph coords
- const gx = (mx - this._transform.x) / this._transform.scale;
- const gy = (my - this._transform.y) / this._transform.scale;
-
- for (var i = this._nodes.length - 1; i >= 0; --i)
- {
- const n = this._nodes[i];
- const hw = n.w / 2, hh = n.h / 2;
- if (gx >= n.x - hw && gx <= n.x + hw &&
- gy >= n.y - hh && gy <= n.y + hh)
- return n;
- }
- return null;
- }
-
- // -- mouse interaction ----
-
- _on_mousedown(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)
- {
- this._drag = {
- type: "node",
- node: node,
- start_x: mx,
- start_y: my,
- node_start_x: node.x,
- node_start_y: node.y,
- 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;
+ const display = is_dir ? child.slice(0, -1) + "/ (" + children[child] + ")" : child;
+ item.text(display);
- if (this._drag.type == "pan")
+ if (is_dir)
{
- this._transform.x = this._drag.tx + dx;
- this._transform.y = this._drag.ty + dy;
- this._render();
+ item.classify("graph_entry_dir");
+ if (has_root)
+ item.classify("graph_entry_active");
+ item.on("click", () => {
+ this._render_tree_level(list, full_path);
+ });
}
- else if (this._drag.type == "node")
+ else
{
- 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;
- this._render();
+ item.classify("graph_entry_leaf");
+ if (roots.has(full_path))
+ item.classify("graph_entry_active");
+ item.on("click", () => {
+ this._select_entry(full_path);
+ });
}
- return;
}
- // hover
- const node = this._hit_test(mx, my);
- if (node !== this._hover_node)
- {
- this._hover_node = node;
- this._canvas.style.cursor = node ? "pointer" : "grab";
- this._hover_mouse = node ? { x: mx, y: my } : null;
- this._update_prop_panel(node);
- this._render();
- }
- else if (node)
+ if (prefix != "/")
{
- this._hover_mouse = { x: mx, y: my };
- this._render();
+ const back = list.inner().insertBefore(
+ document.createElement("div"), list.inner().firstChild);
+ back.textContent = "..";
+ back.className = "graph_entry_dir";
+ back.style.cursor = "pointer";
+ const parent = prefix.slice(0, prefix.slice(0, -1).lastIndexOf("/") + 1) || "/";
+ back.addEventListener("click", () => {
+ this._render_tree_level(list, parent);
+ });
}
}
- _on_mouseup(e)
+ _navigate_tree_to(opkey)
{
- if (!this._drag)
- return;
-
- const drag = this._drag;
- this._drag = null;
+ const last_slash = opkey.lastIndexOf("/");
+ const prefix = (last_slash >= 0) ? opkey.substring(0, last_slash + 1) : "/";
+ this._panel_filter.inner().value = "";
+ this._render_tree_level(this._panel_list, prefix);
- if (drag.type == "node" && !drag.moved)
- {
- // click on node - expand if not expanded
- const node = drag.node;
- if (!node.expanded && !node.unresolved)
- this._expand_node(node);
- }
+ const el = this._panel_list.inner();
+ for (const child of el.children)
+ if (child.classList.contains("graph_entry_active"))
+ { child.scrollIntoView({ block: "nearest" }); break; }
}
- _on_wheel(e)
+ _refresh_panel()
{
- 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));
-
- // zoom at cursor position
- 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();
+ const prefix = this._current_prefix || "/";
+ this._render_tree_level(this._panel_list, prefix);
}
- // -- search ----
-
- async _on_search(needle, dropdown)
+ _render_filtered_entries(list, needle, prefix)
{
- const el = dropdown.inner();
-
- if (needle.length < 2)
- {
- el.style.display = "none";
- return;
- }
-
- const indexer = await this._indexer;
- el.innerHTML = "";
+ list.inner().innerHTML = "";
+ const lwr = needle.toLowerCase();
var count = 0;
- for (const name of indexer.search(needle))
+ for (const name of this._all_names)
{
- if (count >= 30)
+ if (!name.startsWith(prefix))
+ continue;
+ if (name.toLowerCase().indexOf(lwr) < 0)
+ continue;
+ if (count >= 200)
+ {
+ list.tag().text("...").classify("graph_entries_more");
break;
- const item = dropdown.tag();
- item.text(name);
+ }
+ const item = list.tag();
+ item.text(name.substring(prefix.length));
+ item.classify("graph_entry_leaf");
item.on("click", () => {
- el.style.display = "none";
- this._search_input.inner().value = name;
- this._reset_graph();
- this.set_param("opkey", name);
- this._load_root(name);
+ this._select_entry(name);
});
count++;
}
+ }
- el.style.display = count > 0 ? "block" : "none";
+ _select_entry(name)
+ {
+ this._reset_graph();
+ this.set_param("opkey", name);
+ this._load_root(name);
}
}
diff --git a/src/zenserver/frontend/html/util/graphengine.js b/src/zenserver/frontend/html/util/graphengine.js
new file mode 100644
index 000000000..6d9d9b760
--- /dev/null
+++ b/src/zenserver/frontend/html/util/graphengine.js
@@ -0,0 +1,2297 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+////////////////////////////////////////////////////////////////////////////////
+// constants & helpers
+
+export function css_var(name)
+{
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+}
+
+export const NODE_PAD = 14;
+export const NODE_H = 26;
+export const NODE_R = 8;
+export const DOT_SPACE = 10;
+export const MAX_VISIBLE_DEPS = 50;
+
+const _measure_canvas = document.createElement("canvas");
+const _measure_ctx = _measure_canvas.getContext("2d");
+_measure_ctx.font = "11px consolas, monospace";
+
+export function short_name(opkey)
+{
+ const parts = opkey.replace(/\/$/, "").split("/");
+ return parts[parts.length - 1] || opkey;
+}
+
+export function measure_node_width(label)
+{
+ return _measure_ctx.measureText(label).width + NODE_PAD * 2 + DOT_SPACE;
+}
+
+export 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 + ")";
+}
+
+function friendly_kib(n)
+{
+ if (typeof n === "bigint")
+ n = Number(n);
+ if (n < 1024) return n + " B";
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MiB";
+ return (n / (1024 * 1024 * 1024)).toFixed(2) + " GiB";
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// 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
+
+export 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;
+ }
+ }
+ }
+ }
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// layout
+
+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;
+ }
+}
+
+export function layout_run(nodes, edges, cx, cy, n)
+{
+ for (var i = 0; i < n; ++i)
+ layout_step(nodes, edges, cx, cy);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// GraphEngine
+
+export class GraphEngine
+{
+ constructor(options)
+ {
+ this._canvas = options.canvas;
+ this._ctx = this._canvas.getContext("2d");
+ this._size_map = options.size_map || {};
+ this._on_expand = options.on_expand || null;
+ this._on_navigate = options.on_navigate || null;
+ this._on_hover = options.on_hover || null;
+ this._empty_message = options.empty_message || "empty graph";
+ this._friendly_kib = options.friendly_kib || friendly_kib;
+
+ this._nodes = [];
+ this._edges = [];
+ this._node_map = {};
+ this._expanded = new Set();
+ this._hidden_dep_types = new Set();
+
+ this._edge_index = new Map();
+ this._ctxmenu = null;
+ this._ctxmenu_node = null;
+ this._visible_set = null;
+ this._compression_fill = false;
+ this._compression_max = 1.0; // compression filter threshold (1.0 = show all)
+ this._reachable = null; // null = all reachable, Set = only these nodes
+ this._fade_raf = null; // requestAnimationFrame handle for fade animation
+
+ this._quick_filters = new Set(); // active quick-filter names
+ this._large_threshold = 0; // computed 80th-percentile raw_size
+ this._breadcrumb_el = null; // breadcrumb container element
+ this._breadcrumb_prefix = null; // current path filter (null = no filter)
+ this._breadcrumb_on_navigate = null; // callback when breadcrumb segment clicked
+
+ this._search_el = null; // search overlay DOM
+ this._search_matches = null; // array of matching nodes
+ this._search_match_set = null; // Set for O(1) lookup during render
+ this._search_index = -1; // current focused match
+
+ this._transform = { x: 0, y: 0, scale: 1.0 };
+ this._drag = null;
+ this._hover_node = null;
+ this._resizing = false;
+
+ this._init_colors();
+ this._bind_events();
+ if (options.splitter)
+ this._bind_splitter(options.splitter);
+ }
+
+ // -- read-only properties --------------------------------------------------
+
+ get nodes() { return this._nodes; }
+ get edges() { return this._edges; }
+ get node_map() { return this._node_map; }
+ get expanded() { return this._expanded; }
+ get edge_index() { return this._edge_index; }
+ get transform() { return this._transform; }
+ get hover_node() { return this._hover_node; }
+ get canvas() { return this._canvas; }
+ get visible_set() { return this._visible_set; }
+
+ // -- colors ----------------------------------------------------------------
+
+ _init_colors()
+ {
+ 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"];
+ }
+
+ // -- graph data methods ----------------------------------------------------
+
+ add_node(opkey, x, y, is_root, label)
+ {
+ if (this._node_map[opkey])
+ return this._node_map[opkey];
+
+ if (!label)
+ label = short_name(opkey);
+ 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_group_node(opkey, label, x, y, data)
+ {
+ if (this._node_map[opkey])
+ return this._node_map[opkey];
+
+ const w = measure_node_width(label) + 10;
+ const node = {
+ opkey: opkey,
+ 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: opkey,
+ prefix_node: data,
+ };
+ 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 });
+ }
+
+ reset()
+ {
+ this._nodes = [];
+ this._edges = [];
+ this._node_map = {};
+ this._expanded = new Set();
+ this._edge_index = new Map();
+ this._visible_set = null;
+ this._breadcrumb_prefix = null;
+ this._render_breadcrumb();
+ this._transform = { x: 0, y: 0, scale: 1.0 };
+ this._hover_node = null;
+ this._hide_context_menu();
+ this.render();
+ }
+
+ layout(cx, cy, iterations)
+ {
+ layout_run(this._nodes, this._edges, cx, cy, iterations);
+ }
+
+ remove_overlaps(iterations, padding)
+ {
+ remove_overlaps(this._nodes, iterations, padding);
+ }
+
+ 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);
+ }
+ }
+
+ // -- node operations -------------------------------------------------------
+
+ async expand_node(node)
+ {
+ if (this._expanded.has(node.opkey))
+ return;
+ if (!this._on_expand)
+ return;
+
+ this._expanded.add(node.opkey);
+ node.expanded = true;
+ node.loading = true;
+ this.render();
+
+ const pulse_raf = setInterval(() => this.render(), 50);
+
+ try
+ {
+ const deps = await this._on_expand(node.opkey);
+ if (!deps || deps.length == 0)
+ return;
+
+ // count dep types
+ const dep_types = {};
+ var dep_total = 0;
+ for (const dep of deps)
+ {
+ dep_types[dep.dep_type] = (dep_types[dep.dep_type] || 0) + 1;
+ dep_total++;
+ }
+ node.dep_count = dep_total;
+ node.dep_types = dep_types;
+
+ // snapshot existing nodes before adding children
+ const existing = new Set(this._nodes);
+
+ // determine arc placement
+ 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(deps.length, MAX_VISIBLE_DEPS);
+
+ // push non-root node away from parent to make room for children
+ 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;
+ }
+
+ var arc_start, arc_span;
+ if (node.is_root)
+ {
+ arc_start = 0;
+ arc_span = 2 * Math.PI;
+ }
+ else
+ {
+ arc_start = outward_angle - Math.PI / 2;
+ arc_span = Math.PI;
+ }
+
+ const radius = 200 + visible * 4;
+
+ var added = 0;
+ for (const dep of 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.unresolved || false;
+ this.add_edge(node, dep_node, dep.dep_type);
+ added++;
+ }
+
+ // pin existing nodes during layout so only new children move
+ 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();
+ }
+ finally
+ {
+ node.loading = false;
+ clearInterval(pulse_raf);
+ this.render();
+ }
+ }
+
+ collapse_node(node)
+ {
+ if (!this._expanded.has(node.opkey))
+ return;
+
+ 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.expanded)
+ this.collapse_node(child);
+ collect(child);
+ }
+ };
+ collect(node);
+
+ 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];
+
+ this._expanded.delete(node.opkey);
+ node.expanded = false;
+ node.dep_count = 0;
+ node.dep_types = null;
+ node.truncated = false;
+ }
+
+ 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];
+
+ this._expanded.delete(node.opkey);
+ node.expanded = false;
+ node.dep_count = 0;
+ node.dep_types = null;
+ node.truncated = false;
+ this.rebuild_edge_index();
+
+ node._skip_push = true;
+ await this.expand_node(node);
+ delete node._skip_push;
+ }
+
+ _find_parent(node)
+ {
+ 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
+ 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(Math.random() * candidates.length)];
+ }
+
+ mark_has_deps(opkey_set)
+ {
+ for (const node of this._nodes)
+ {
+ if (node.is_root)
+ continue;
+ if (opkey_set.has(node.opkey))
+ node.has_deps = true;
+ else if (!node.unresolved)
+ node.has_deps = false;
+ }
+ this.render();
+ }
+
+ // -- view operations -------------------------------------------------------
+
+ 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();
+ }
+
+ fit_subtree(node)
+ {
+ 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);
+
+ 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.fit_view();
+ }
+
+ clear_trace()
+ {
+ this._visible_set = null;
+ this.render();
+ }
+
+ set_compression_fill(enabled)
+ {
+ this._compression_fill = enabled;
+ this.render();
+ }
+
+ set_canvas_size(w, h)
+ {
+ this._canvas.width = w;
+ this._canvas.height = h;
+ this.render();
+ }
+
+ // -- rendering -------------------------------------------------------------
+
+ _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);
+ }
+
+ _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");
+ }
+
+ render()
+ {
+ const ctx = this._ctx;
+ if (!ctx) return;
+
+ // recompute reachable set: nodes reachable from roots via non-hidden edges
+ if (this._hidden_dep_types.size > 0)
+ {
+ const reachable = new Set();
+ const queue = [];
+ for (const n of this._nodes)
+ if (n.is_root) { reachable.add(n); queue.push(n); }
+ while (queue.length > 0)
+ {
+ const n = queue.shift();
+ const idx = this._edge_index.get(n);
+ if (idx)
+ {
+ for (const edge of idx.outgoing)
+ {
+ if (this._is_edge_hidden(edge)) continue;
+ if (reachable.has(edge.target)) continue;
+ reachable.add(edge.target);
+ queue.push(edge.target);
+ }
+ }
+ else
+ {
+ for (const edge of this._edges)
+ {
+ if (edge.source !== n) continue;
+ if (this._is_edge_hidden(edge)) continue;
+ if (reachable.has(edge.target)) continue;
+ reachable.add(edge.target);
+ queue.push(edge.target);
+ }
+ }
+ }
+ this._reachable = reachable;
+ }
+ else
+ this._reachable = null;
+
+ // animate reachable alpha: fade nodes in/out when edge types toggle
+ const fade_step = 0.08;
+ var needs_fade_frame = false;
+ for (const n of this._nodes)
+ {
+ const target = (!this._reachable || this._reachable.has(n)) ? 1.0 : 0.0;
+ if (n._reachable_alpha === undefined) n._reachable_alpha = target;
+ if (n._reachable_alpha !== target)
+ {
+ if (Math.abs(n._reachable_alpha - target) < fade_step * 1.5)
+ n._reachable_alpha = target;
+ else
+ {
+ n._reachable_alpha += target > n._reachable_alpha ? fade_step : -fade_step;
+ needs_fade_frame = true;
+ }
+ }
+ }
+ if (needs_fade_frame && !this._fade_raf)
+ {
+ this._fade_raf = requestAnimationFrame(() => {
+ this._fade_raf = null;
+ this.render();
+ });
+ }
+
+ 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;
+ 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;
+
+ // drawable = includes fading-out nodes (for smooth transitions)
+ const is_node_drawable = (n) => {
+ if (this._visible_set && !this._visible_set.has(n)) return false;
+ if (this._node_compression_alpha(n) <= 0) return false;
+ if (n._reachable_alpha !== undefined && n._reachable_alpha <= 0) return false;
+ return true;
+ };
+
+ // viewport culling on top of drawable check
+ const is_node_visible = (n) => {
+ if (!is_node_drawable(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 (!is_node_drawable(e.source) || !is_node_drawable(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();
+ }
+ };
+
+ // search and filter state (used by both edge and node drawing)
+ const search_active = this._search_match_set && this._search_match_set.size >= 0
+ && this._search_matches !== null;
+
+ // draw edges
+ const hover = this._hover_node;
+ const do_highlight = hover && !hover.is_root;
+
+ const filter_dim = 0.15;
+ const has_quick_filter = this._quick_filters.size > 0;
+ const bc_prefix = this._breadcrumb_prefix;
+
+ const edge_alpha = (e) => {
+ var a = Math.min(
+ this._node_compression_alpha(e.source),
+ this._node_compression_alpha(e.target));
+ const ra = e.source._reachable_alpha;
+ const rb = e.target._reachable_alpha;
+ if (ra !== undefined) a *= ra;
+ if (rb !== undefined) a *= rb;
+ if (search_active)
+ {
+ const sm = this._search_match_set;
+ if (!sm.has(e.source) && !sm.has(e.target)) a *= filter_dim;
+ }
+ if (has_quick_filter)
+ {
+ if (!this._quick_filter_match(e.source) && !this._quick_filter_match(e.target))
+ a *= filter_dim;
+ }
+ if (bc_prefix)
+ {
+ const bp = bc_prefix + "/";
+ if (!e.source.opkey.startsWith(bp) && !e.target.opkey.startsWith(bp))
+ a *= filter_dim;
+ }
+ return a;
+ };
+
+ 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;
+ const ea = edge_alpha(edge);
+ if (ea <= 0) continue;
+ if (do_highlight)
+ {
+ ctx.globalAlpha = 0.2 * ea;
+ draw_edge(edge);
+ ctx.globalAlpha = 1.0;
+ }
+ else
+ {
+ if (ea < 1.0) ctx.globalAlpha = ea;
+ draw_edge(edge);
+ if (ea < 1.0) ctx.globalAlpha = 1.0;
+ }
+ }
+
+ if (do_highlight)
+ {
+ 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)
+ {
+ for (const edge of idx.incoming)
+ {
+ if (this._is_edge_hidden(edge)) continue;
+ path_edges.add(edge);
+ trace(edge.source);
+ }
+ }
+ else
+ {
+ for (const edge of this._edges)
+ {
+ if (this._is_edge_hidden(edge)) continue;
+ if (edge.target === node)
+ {
+ 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);
+ }
+ else
+ {
+ for (const edge of this._edges)
+ if (edge.source === hover && !this._is_edge_hidden(edge))
+ path_edges.add(edge);
+ }
+ for (const edge of path_edges)
+ {
+ const ea = edge_alpha(edge);
+ if (ea <= 0) continue;
+ if (ea < 1.0) ctx.globalAlpha = ea;
+ draw_edge(edge, c.p0, 3);
+ if (ea < 1.0) ctx.globalAlpha = 1.0;
+ }
+ }
+ 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;
+ const lw = 1 / scale;
+
+ ctx.beginPath();
+ ctx.roundRect(x, y, nw, nh, NODE_R);
+
+ // group nodes: distinct fill + dashed border
+ if (node.is_group)
+ {
+ ctx.fillStyle = node === this._hover_node ? c.p2 : c.p3;
+ ctx.fill();
+ ctx.setLineDash([4 / scale, 3 / scale]);
+ ctx.strokeStyle = c.p1;
+ ctx.lineWidth = 1.5 * lw;
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ else
+ {
+ // fill
+ 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();
+
+ // border
+ if (node === this._hover_node)
+ {
+ ctx.strokeStyle = c.p0;
+ ctx.lineWidth = 1.5 * lw;
+ }
+ else if (node.is_root)
+ {
+ ctx.strokeStyle = c.p0;
+ ctx.lineWidth = 2 * lw;
+ }
+ else if (node.has_deps)
+ {
+ ctx.strokeStyle = c.p1;
+ ctx.lineWidth = 1.5 * lw;
+ }
+ else
+ {
+ ctx.strokeStyle = c.g2;
+ ctx.lineWidth = lw;
+ }
+ 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 * lw;
+ 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_size = !node.is_group && node_sizes && node_sizes.raw_size > 0n;
+ const text_x = has_size ? node.x + DOT_SPACE / 2 : node.x;
+ const has_dot = show_dots && has_size && !this._compression_fill;
+
+ if (node.unresolved && !this._compression_fill)
+ ctx.fillStyle = c.g1;
+ else if (this._compression_fill)
+ ctx.fillStyle = c.g4;
+ else
+ ctx.fillStyle = c.g0;
+ ctx.fillText(node.label, text_x, node.y);
+
+ // compression ratio dot
+ if (has_dot)
+ {
+ const dot_r = 3;
+ 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.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";
+ }
+ };
+
+ const draw_node_with_alpha = (node) => {
+ var a = this._node_compression_alpha(node);
+ if (node._reachable_alpha !== undefined) a *= node._reachable_alpha;
+ if (search_active && !this._search_match_set.has(node)) a *= filter_dim;
+ if (has_quick_filter && !this._quick_filter_match(node)) a *= filter_dim;
+ if (bc_prefix && !node.opkey.startsWith(bc_prefix + "/")) a *= filter_dim;
+ if (a <= 0) return;
+ if (a < 1.0) ctx.globalAlpha = a;
+ draw_node(node);
+ if (a < 1.0) ctx.globalAlpha = 1.0;
+ };
+
+ // pass 1: leaf nodes
+ for (const node of this._nodes)
+ if (!node.expanded && node !== this._hover_node && is_node_visible(node))
+ draw_node_with_alpha(node);
+ // pass 2: expanded nodes
+ for (const node of this._nodes)
+ if (node.expanded && node !== this._hover_node && is_node_visible(node))
+ draw_node_with_alpha(node);
+ // pass 3: hovered node
+ if (this._hover_node && scale >= 0.6 && this._is_node_filtered(this._hover_node))
+ draw_node_with_alpha(this._hover_node);
+
+ // pass 4: search match highlight rings
+ if (search_active && this._search_matches.length > 0)
+ {
+ for (var si = 0; si < this._search_matches.length; ++si)
+ {
+ const node = this._search_matches[si];
+ if (!is_node_visible(node)) continue;
+ const is_current = si === this._search_index;
+ const ring_pad = 4 / scale;
+ ctx.beginPath();
+ ctx.roundRect(
+ node.x - node.w / 2 - ring_pad,
+ node.y - node.h / 2 - ring_pad,
+ node.w + ring_pad * 2,
+ node.h + ring_pad * 2,
+ NODE_R + ring_pad);
+ ctx.strokeStyle = is_current ? c.p0 : c.p1;
+ ctx.lineWidth = (is_current ? 3 : 2) / scale;
+ ctx.stroke();
+ }
+ }
+
+ ctx.restore();
+
+ // zoomed-out hover: draw in screen space at readable size
+ if (this._hover_node && scale < 0.6 && this._is_node_filtered(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_size = !node.is_group && node_sizes && node_sizes.raw_size > 0n;
+ const tw = ctx.measureText(node.label).width + NODE_PAD * 2 + (has_size ? DOT_SPACE : 0);
+ const text_x = has_size ? sx + DOT_SPACE / 2 : sx;
+ const has_dot = has_size && !this._compression_fill;
+ 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());
+ }
+ else
+ {
+ const sizes = this._size_map[node.opkey];
+ if (sizes)
+ {
+ var size_line = "size: " + this._friendly_kib(sizes.size) +
+ " raw: " + this._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);
+ }
+
+ // empty state
+ if (this._nodes.length == 0)
+ {
+ ctx.fillStyle = c.g1;
+ ctx.font = "14px consolas, monospace";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(this._empty_message, canvas.width / 2, canvas.height / 2);
+ }
+ }
+
+ // -- hit testing -----------------------------------------------------------
+
+ _node_compression_alpha(n)
+ {
+ if (this._compression_max >= 1.0) return 1.0;
+ const sizes = this._size_map[n.opkey];
+ if (!sizes || !(sizes.raw_size > 0n)) return 1.0;
+ const ratio = 1.0 - Number(sizes.size) / Number(sizes.raw_size);
+ const fade = 0.1;
+ return Math.max(0, Math.min(1, (this._compression_max - ratio) / fade));
+ }
+
+ _is_node_filtered(n)
+ {
+ if (this._visible_set && !this._visible_set.has(n)) return false;
+ if (this._reachable && !this._reachable.has(n)) return false;
+ return this._node_compression_alpha(n) > 0;
+ }
+
+ _quick_filter_match(n)
+ {
+ if (this._quick_filters.size === 0) return true;
+ if (this._quick_filters.has("leaves") && !n.expanded && !n.is_group) return true;
+ if (this._quick_filters.has("unresolved") && n.unresolved) return true;
+ if (this._quick_filters.has("large"))
+ {
+ const sizes = this._size_map[n.opkey];
+ if (sizes && Number(sizes.raw_size) >= this._large_threshold) return true;
+ }
+ return false;
+ }
+
+ _compute_large_threshold()
+ {
+ const values = [];
+ for (const node of this._nodes)
+ {
+ const s = this._size_map[node.opkey];
+ if (s && s.raw_size > 0n)
+ values.push(Number(s.raw_size));
+ }
+ values.sort((a, b) => a - b);
+ this._large_threshold = values.length > 0 ? values[Math.floor(values.length * 0.8)] : 0;
+ }
+
+ _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._is_node_filtered(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 --------------------------------------------------------
+
+ // -- 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.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;
+ }
+ }
+
+ // -- search overlay --------------------------------------------------------
+
+ show_search()
+ {
+ if (this._search_el) return;
+
+ const overlay = document.createElement("div");
+ overlay.className = "graph_search_overlay";
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = "search nodes...";
+
+ const count = document.createElement("span");
+ count.className = "graph_search_count";
+
+ const prev_btn = document.createElement("button");
+ prev_btn.textContent = "\u25B2";
+ prev_btn.title = "previous match (Shift+Enter)";
+
+ const next_btn = document.createElement("button");
+ next_btn.textContent = "\u25BC";
+ next_btn.title = "next match (Enter)";
+
+ const close_btn = document.createElement("button");
+ close_btn.textContent = "\u2715";
+ close_btn.title = "close (Escape)";
+
+ overlay.appendChild(input);
+ overlay.appendChild(count);
+ overlay.appendChild(prev_btn);
+ overlay.appendChild(next_btn);
+ overlay.appendChild(close_btn);
+
+ // position overlay centered over the canvas (80% of canvas width)
+ const cw = this._canvas.clientWidth;
+ const cl = this._canvas.offsetLeft;
+ overlay.style.left = (cl + cw * 0.1) + "px";
+ overlay.style.width = (cw * 0.8) + "px";
+
+ this._canvas.parentElement.appendChild(overlay);
+ this._search_el = overlay;
+ this._search_count_el = count;
+
+ input.addEventListener("input", () => this._update_search(input.value));
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Escape")
+ {
+ this.hide_search();
+ e.preventDefault();
+ }
+ else if (e.key === "Enter" && e.shiftKey)
+ {
+ this._navigate_search(-1);
+ e.preventDefault();
+ }
+ else if (e.key === "Enter")
+ {
+ this._navigate_search(1);
+ e.preventDefault();
+ }
+ });
+ prev_btn.addEventListener("click", () => this._navigate_search(-1));
+ next_btn.addEventListener("click", () => this._navigate_search(1));
+ close_btn.addEventListener("click", () => this.hide_search());
+
+ input.focus();
+ }
+
+ hide_search()
+ {
+ const was_active = this._search_matches !== null;
+ if (this._search_el)
+ {
+ this._search_el.remove();
+ this._search_el = null;
+ this._search_count_el = null;
+ }
+ this._search_matches = null;
+ this._search_match_set = null;
+ this._search_index = -1;
+ if (was_active)
+ this.render();
+ }
+
+ _update_search(query)
+ {
+ const q = query.trim().toLowerCase();
+ if (q.length === 0)
+ {
+ this._search_matches = null;
+ this._search_match_set = null;
+ this._search_index = -1;
+ if (this._search_count_el)
+ this._search_count_el.textContent = "";
+ this.render();
+ return;
+ }
+
+ const matches = [];
+ for (const node of this._nodes)
+ {
+ if (!this._is_node_filtered(node)) continue;
+ if (node.opkey.toLowerCase().indexOf(q) >= 0 ||
+ node.label.toLowerCase().indexOf(q) >= 0)
+ matches.push(node);
+ }
+
+ this._search_matches = matches;
+ this._search_match_set = new Set(matches);
+ this._search_index = matches.length > 0 ? 0 : -1;
+
+ if (this._search_count_el)
+ {
+ if (matches.length > 0)
+ this._search_count_el.textContent = "1/" + matches.length;
+ else
+ this._search_count_el.textContent = "0 matches";
+ }
+
+ if (matches.length > 0)
+ this._focus_search_match();
+ else
+ this.render();
+ }
+
+ _navigate_search(delta)
+ {
+ if (!this._search_matches || this._search_matches.length === 0)
+ return;
+
+ this._search_index += delta;
+ const len = this._search_matches.length;
+ if (this._search_index < 0) this._search_index = len - 1;
+ if (this._search_index >= len) this._search_index = 0;
+
+ if (this._search_count_el)
+ this._search_count_el.textContent = (this._search_index + 1) + "/" + len;
+
+ this._focus_search_match();
+ }
+
+ _focus_search_match()
+ {
+ const node = this._search_matches[this._search_index];
+ if (!node) return;
+
+ // center view on the match with some padding
+ const pad = 200;
+ const w = node.w + pad * 2;
+ const h = node.h + pad * 2;
+ const scale = Math.min(this._canvas.width / w, this._canvas.height / h, 2.0);
+ this._transform.scale = Math.max(scale, 0.5);
+ this._transform.x = this._canvas.width / 2 - node.x * this._transform.scale;
+ this._transform.y = this._canvas.height / 2 - node.y * this._transform.scale;
+ this.render();
+ }
+
+ // -- legend ----------------------------------------------------------------
+
+ build_legend(container_el)
+ {
+ if (!container_el) return;
+
+ const make = (tag) => document.createElement(tag || "div");
+ const swatch = (bg, w) => {
+ const s = make("span");
+ s.className = "legend_swatch";
+ if (w) s.style.width = w;
+ s.style.backgroundImage = bg;
+ return s;
+ };
+
+ const toggle_dep = (name, item) => {
+ if (this._hidden_dep_types.has(name))
+ {
+ this._hidden_dep_types.delete(name);
+ item.classList.remove("legend_disabled");
+ }
+ else
+ {
+ this._hidden_dep_types.add(name);
+ item.classList.add("legend_disabled");
+ }
+ this.render();
+ };
+
+ // edge type swatches (clickable toggles)
+ for (const name in this._dep_colors)
+ {
+ const dep = this._dep_colors[name];
+ const item = make();
+ item.className = "legend_toggle";
+ if (dep.dash.length)
+ item.appendChild(swatch("repeating-linear-gradient(90deg, " + dep.color + " 0 6px, transparent 6px 10px)"));
+ else
+ {
+ const s = swatch("none");
+ s.style.backgroundColor = dep.color;
+ item.appendChild(s);
+ }
+ const label = make("span");
+ label.textContent = name;
+ item.appendChild(label);
+ item.addEventListener("click", () => toggle_dep(name, item));
+ container_el.appendChild(item);
+ }
+
+ // "other" edge type
+ {
+ const item = make();
+ item.className = "legend_toggle";
+ const s = swatch("none");
+ s.style.backgroundColor = this._colors.g1;
+ item.appendChild(s);
+ const label = make("span");
+ label.textContent = "other";
+ item.appendChild(label);
+ item.addEventListener("click", () => toggle_dep("$other", item));
+ container_el.appendChild(item);
+ }
+
+ // separator
+ {
+ const sep = make("span");
+ sep.className = "zen_toolbar_sep";
+ sep.textContent = "|";
+ container_el.appendChild(sep);
+ }
+
+ // compression ratio gradient + fill pill (in one item div)
+ {
+ const item = make();
+ const label = make("span");
+ label.textContent = "compression";
+ item.appendChild(label);
+ const scale_wrap = make();
+ scale_wrap.className = "legend_scale_wrap";
+ const scale = make("span");
+ scale.className = "legend_scale";
+ const stops = [...this._ratio_colors].reverse();
+ scale.style.backgroundImage =
+ "linear-gradient(90deg, " + stops.join(", ") + ")";
+ const lo = make("span");
+ lo.className = "legend_scale_lo";
+ lo.textContent = "low";
+ scale.appendChild(lo);
+ const hi = make("span");
+ hi.className = "legend_scale_hi";
+ hi.textContent = "high";
+ scale.appendChild(hi);
+ scale_wrap.appendChild(scale);
+ const slider = make("input");
+ slider.type = "range";
+ slider.min = "0";
+ slider.max = "100";
+ slider.value = "100";
+ slider.className = "legend_compression_slider";
+ slider.title = "filter by compression ratio";
+ slider.addEventListener("input", () => {
+ this._compression_max = parseInt(slider.value) / 100;
+ scale.style.setProperty("--filter-pos", slider.value + "%");
+ this.render();
+ });
+ scale_wrap.appendChild(slider);
+ item.appendChild(scale_wrap);
+ const pill = make("span");
+ pill.className = "legend_pill";
+ pill.textContent = "fill";
+ pill.addEventListener("click", () => {
+ this._compression_fill = !this._compression_fill;
+ pill.classList.toggle("active", this._compression_fill);
+ this.render();
+ });
+ item.appendChild(pill);
+ container_el.appendChild(item);
+ }
+
+ // separator
+ {
+ const sep = make("span");
+ sep.className = "zen_toolbar_sep";
+ sep.textContent = "|";
+ container_el.appendChild(sep);
+ }
+
+ // quick-filter pills
+ {
+ const add_pill = (name, label) => {
+ const pill = make("span");
+ pill.className = "legend_pill";
+ pill.textContent = label;
+ pill.addEventListener("click", () => {
+ if (this._quick_filters.has(name))
+ {
+ this._quick_filters.delete(name);
+ pill.classList.remove("active");
+ }
+ else
+ {
+ this._quick_filters.add(name);
+ pill.classList.add("active");
+ if (name === "large")
+ this._compute_large_threshold();
+ }
+ this.render();
+ });
+ container_el.appendChild(pill);
+ };
+ add_pill("leaves", "leaves");
+ add_pill("unresolved", "unresolved");
+ add_pill("large", "large");
+ }
+ }
+
+ // -- breadcrumb path filter ------------------------------------------------
+
+ build_breadcrumb(container_el)
+ {
+ if (!container_el) return;
+ this._breadcrumb_el = container_el;
+ this._render_breadcrumb();
+ }
+
+ set_breadcrumb_prefix(prefix, on_navigate)
+ {
+ this._breadcrumb_prefix = prefix;
+ this._breadcrumb_on_navigate = on_navigate || this._breadcrumb_on_navigate;
+ this._render_breadcrumb();
+ this.render();
+ }
+
+ _render_breadcrumb()
+ {
+ const el = this._breadcrumb_el;
+ if (!el) return;
+
+ el.innerHTML = "";
+ if (!this._breadcrumb_prefix) return;
+
+ const parts = this._breadcrumb_prefix.split("/").filter(p => p.length > 0);
+ if (parts.length === 0) return;
+
+ const make_seg = (text, onclick, is_active) => {
+ const span = document.createElement("span");
+ span.className = "graph_breadcrumb_seg" + (is_active ? " active" : "");
+ span.textContent = text;
+ if (onclick) span.addEventListener("click", onclick);
+ return span;
+ };
+
+ const make_sep = () => {
+ const span = document.createElement("span");
+ span.className = "graph_breadcrumb_sep";
+ span.textContent = "\u203A";
+ return span;
+ };
+
+ // root "/"
+ el.appendChild(make_seg("/", () => this._breadcrumb_navigate(null), false));
+ el.appendChild(make_sep());
+
+ // path segments
+ for (var i = 0; i < parts.length; ++i)
+ {
+ const prefix = "/" + parts.slice(0, i + 1).join("/");
+ const is_last = i === parts.length - 1;
+ el.appendChild(make_seg(parts[i], () => {
+ this._breadcrumb_navigate(is_last ? null : prefix);
+ }, is_last));
+ if (!is_last)
+ el.appendChild(make_sep());
+ }
+ }
+
+ _breadcrumb_navigate(prefix)
+ {
+ this._breadcrumb_prefix = prefix;
+ this._render_breadcrumb();
+ this.render();
+ if (this._breadcrumb_on_navigate)
+ this._breadcrumb_on_navigate(prefix);
+ }
+
+ // -- splitter --------------------------------------------------------------
+
+ _bind_splitter(splitter_el)
+ {
+ if (!splitter_el) return;
+ this._splitter = splitter_el;
+ // the panel to resize is the element right after the splitter in the flex layout
+ const resize_target = splitter_el.nextElementSibling;
+
+ const on_mousemove = (e) => {
+ const dx = e.clientX - this._resize_start_x;
+ var new_w = this._resize_start_w - dx;
+ const container_w = this._canvas.parentElement.clientWidth;
+ new_w = Math.max(80, Math.min(new_w, container_w - 200));
+ resize_target.style.width = new_w + "px";
+ this._canvas.width = this._canvas.clientWidth;
+ this._canvas.height = this._canvas.clientHeight;
+ this.render();
+ };
+
+ const on_mouseup = () => {
+ this._resizing = false;
+ this._splitter.classList.remove("active");
+ document.removeEventListener("mousemove", on_mousemove);
+ document.removeEventListener("mouseup", on_mouseup);
+ };
+
+ this._splitter_mousedown_fn = (e) => {
+ e.preventDefault();
+ this._resizing = true;
+ this._resize_start_x = e.clientX;
+ this._resize_start_w = resize_target.offsetWidth;
+ this._splitter.classList.add("active");
+ document.addEventListener("mousemove", on_mousemove);
+ document.addEventListener("mouseup", on_mouseup);
+ };
+
+ splitter_el.addEventListener("mousedown", this._splitter_mousedown_fn);
+ }
+
+ // -- mouse interaction -----------------------------------------------------
+
+ _bind_events()
+ {
+ this._on_mousedown_fn = (e) => this._on_mousedown(e);
+ this._on_mousemove_fn = (e) => this._on_mousemove(e);
+ this._on_mouseup_fn = (e) => this._on_mouseup(e);
+ this._on_dblclick_fn = (e) => this._on_dblclick(e);
+ this._on_wheel_fn = (e) => this._on_wheel(e);
+ this._on_contextmenu_fn = (e) => this._on_contextmenu(e);
+ this._on_doc_click_fn = (e) => {
+ if (this._ctxmenu && !this._ctxmenu.contains(e.target))
+ this._hide_context_menu();
+ };
+ this._on_keydown_fn = (e) => {
+ if (e.key === "f" && (e.ctrlKey || e.metaKey))
+ {
+ // only activate if canvas is visible
+ const rect = this._canvas.getBoundingClientRect();
+ if (rect.width > 0 && rect.height > 0)
+ {
+ e.preventDefault();
+ this.show_search();
+ }
+ }
+ };
+
+ this._canvas.addEventListener("mousedown", this._on_mousedown_fn);
+ this._canvas.addEventListener("mousemove", this._on_mousemove_fn);
+ this._canvas.addEventListener("mouseup", this._on_mouseup_fn);
+ this._canvas.addEventListener("mouseleave", this._on_mouseup_fn);
+ this._canvas.addEventListener("dblclick", this._on_dblclick_fn);
+ this._canvas.addEventListener("wheel", this._on_wheel_fn, { passive: false });
+ this._canvas.addEventListener("contextmenu", this._on_contextmenu_fn);
+ document.addEventListener("click", this._on_doc_click_fn);
+ document.addEventListener("keydown", this._on_keydown_fn);
+ }
+
+ _on_contextmenu(e)
+ {
+ e.preventDefault();
+ const rect = this._canvas.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);
+ }
+
+ _on_mousedown(e)
+ {
+ this._hide_context_menu();
+ if (this._resizing) return;
+ 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
+ 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)
+ {
+ if (this._resizing) return;
+ 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;
+ if (this._drag.descendants)
+ {
+ 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";
+ if (this._on_hover)
+ this._on_hover(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);
+ else if (node && node.opkey && this._on_navigate)
+ this._on_navigate(node.opkey);
+ }
+
+ _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();
+ }
+
+ // -- lifecycle -------------------------------------------------------------
+
+ destroy()
+ {
+ this._canvas.removeEventListener("mousedown", this._on_mousedown_fn);
+ this._canvas.removeEventListener("mousemove", this._on_mousemove_fn);
+ this._canvas.removeEventListener("mouseup", this._on_mouseup_fn);
+ this._canvas.removeEventListener("mouseleave", this._on_mouseup_fn);
+ this._canvas.removeEventListener("dblclick", this._on_dblclick_fn);
+ this._canvas.removeEventListener("wheel", this._on_wheel_fn);
+ this._canvas.removeEventListener("contextmenu", this._on_contextmenu_fn);
+ document.removeEventListener("click", this._on_doc_click_fn);
+ document.removeEventListener("keydown", this._on_keydown_fn);
+ if (this._splitter)
+ this._splitter.removeEventListener("mousedown", this._splitter_mousedown_fn);
+ this._hide_context_menu();
+ this.hide_search();
+ if (this._fade_raf)
+ {
+ cancelAnimationFrame(this._fade_raf);
+ this._fade_raf = null;
+ }
+ this._ctx = null;
+ }
+}
diff --git a/src/zenserver/frontend/html/util/minigraph.js b/src/zenserver/frontend/html/util/minigraph.js
index be7e1c713..322ed976e 100644
--- a/src/zenserver/frontend/html/util/minigraph.js
+++ b/src/zenserver/frontend/html/util/minigraph.js
@@ -2,314 +2,48 @@
"use strict";
-////////////////////////////////////////////////////////////////////////////////
-function css_var(name)
-{
- return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
-}
-
-const NODE_PAD = 20;
-const NODE_H = 32;
-const NODE_R = 6;
-const DOT_SPACE = 10;
-const MAX_VISIBLE_DEPS = 50;
-
-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 + ")";
-}
+import { GraphEngine, short_name, layout_run, MAX_VISIBLE_DEPS } from "./graphengine.js"
////////////////////////////////////////////////////////////////////////////////
-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;
-
- for (var i = 0; i < nodes.length; ++i)
- {
- var fx = 0, fy = 0;
-
- for (var j = 0; j < nodes.length; ++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)
+function _friendly_kib(n)
{
- for (var i = 0; i < n; ++i)
- layout_step(nodes, edges, cx, cy);
+ if (typeof n === "bigint")
+ n = Number(n);
+ if (n < 1024) return n + " B";
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MiB";
+ return (n / (1024 * 1024 * 1024)).toFixed(2) + " GiB";
}
////////////////////////////////////////////////////////////////////////////////
export class MiniGraph
{
- constructor(canvas_el, prop_panel_el, splitter_el, legend_el, root_opkey, root_label, deps, size_map, on_navigate, on_expand)
+ constructor(canvas_el, legend_el, root_opkey, root_label, deps, size_map, on_navigate, on_expand)
{
- this._canvas = canvas_el;
- this._ctx = canvas_el.getContext("2d");
- this._prop_panel = prop_panel_el;
- this._size_map = size_map || {};
- this._on_navigate = on_navigate;
- this._on_expand = on_expand;
- this._resizing = false;
- this._hidden_dep_types = new Set();
-
- this._nodes = [];
- this._edges = [];
- this._node_map = {};
- this._expanded = new Set();
-
- this._transform = { x: 0, y: 0, scale: 1.0 };
- this._drag = null;
- this._hover_node = null;
+ this._engine = new GraphEngine({
+ canvas: canvas_el,
+ size_map: size_map || {},
+ on_navigate: on_navigate,
+ on_expand: on_expand,
+ empty_message: "no dependencies",
+ friendly_kib: _friendly_kib,
+ });
- this._init_colors();
this._build_graph(root_opkey, root_label, deps);
- this._build_legend(legend_el);
- this._bind_events();
- this._bind_splitter(splitter_el);
- this._fit_view();
- }
-
- _init_colors()
- {
- 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: [] };
- // compression ratio color stops (viridis): purple (good) → yellow (poor)
- this._ratio_colors = ["#482878", "#2d708e", "#20a386", "#75d054", "#fde725"];
- }
-
- _build_legend(el)
- {
- if (!el) return;
-
- const make = (tag) => document.createElement(tag || "div");
- const swatch = (bg, w) => {
- const s = make("span");
- s.className = "legend_swatch";
- if (w) s.style.width = w;
- s.style.backgroundImage = bg;
- return s;
- };
-
- const toggle_dep = (name, item) => {
- if (this._hidden_dep_types.has(name))
- {
- this._hidden_dep_types.delete(name);
- item.classList.remove("legend_disabled");
- }
- else
- {
- this._hidden_dep_types.add(name);
- item.classList.add("legend_disabled");
- }
- this._render();
- };
-
- // edge type swatches (clickable toggles)
- for (const name in this._dep_colors)
- {
- const dep = this._dep_colors[name];
- const item = make();
- item.className = "legend_toggle";
- if (dep.dash.length)
- item.appendChild(swatch("repeating-linear-gradient(90deg, " + dep.color + " 0 6px, transparent 6px 10px)"));
- else
- {
- const s = swatch("none");
- s.style.backgroundColor = dep.color;
- item.appendChild(s);
- }
- const label = make("span");
- label.textContent = name;
- item.appendChild(label);
- item.addEventListener("click", () => toggle_dep(name, item));
- el.appendChild(item);
- }
-
- // "other" edge type (clickable toggle)
- {
- const item = make();
- item.className = "legend_toggle";
- const s = swatch("none");
- s.style.backgroundColor = this._colors.g1;
- item.appendChild(s);
- const label = make("span");
- label.textContent = "other";
- item.appendChild(label);
- item.addEventListener("click", () => toggle_dep("$other", item));
- el.appendChild(item);
- }
-
- // separator
- {
- const sep = make("span");
- sep.className = "zen_toolbar_sep";
- sep.textContent = "|";
- el.appendChild(sep);
- }
-
- // compression ratio gradient
- {
- const item = make();
- const label = make("span");
- label.textContent = "compression:";
- item.appendChild(label);
- const scale = make("span");
- scale.className = "legend_scale";
- const stops = [...this._ratio_colors].reverse();
- scale.style.backgroundImage =
- "linear-gradient(90deg, " + stops.join(", ") + ")";
- const lo = make("span");
- lo.className = "legend_scale_lo";
- lo.textContent = "low";
- scale.appendChild(lo);
- const hi = make("span");
- hi.className = "legend_scale_hi";
- hi.textContent = "high";
- scale.appendChild(hi);
- item.appendChild(scale);
- el.appendChild(item);
- }
- }
-
- _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");
- }
-
- _add_node(opkey, x, y, is_root)
- {
- if (this._node_map[opkey])
- return this._node_map[opkey];
-
- const label = short_name(opkey);
- const pad = is_root ? NODE_PAD * 3 : 0;
- const node = {
- opkey: opkey,
- label: label,
- w: measure_node_width(label) + pad,
- h: NODE_H + (is_root ? 10 : 0),
- 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 });
+ this._engine.build_legend(legend_el);
+ this._engine.fit_view();
}
_build_graph(root_opkey, root_label, deps)
{
- const cx = this._canvas.width / 2;
- const cy = this._canvas.height / 2;
+ const engine = this._engine;
+ const cx = engine.canvas.width / 2;
+ const cy = engine.canvas.height / 2;
// root node
- const root = this._add_node(root_opkey, cx, cy, true);
+ const root = engine.add_node(root_opkey, cx, cy, true);
root.expanded = true;
- this._expanded.add(root_opkey);
+ engine.expanded.add(root_opkey);
// count dep types
const dep_types = {};
@@ -326,759 +60,27 @@ export class MiniGraph
const dep = deps[i];
const t = count > 1 ? i / (count - 1) : 0.5;
const angle = t * 2 * Math.PI;
- const dep_node = this._add_node(
+ const dep_node = engine.add_node(
dep.opkey,
cx + Math.cos(angle) * radius,
cy + Math.sin(angle) * radius,
false
);
dep_node.unresolved = dep.unresolved || false;
- this._add_edge(root, dep_node, dep.dep_type);
+ engine.add_edge(root, dep_node, dep.dep_type);
}
- layout_run(this._nodes, this._edges, cx, cy, 200);
+ engine.layout(cx, cy, 200);
+ engine.rebuild_edge_index();
}
- async _expand_node(node)
+ mark_has_deps(opkey_set)
{
- if (this._expanded.has(node.opkey))
- return;
- if (!this._on_expand)
- return;
-
- this._expanded.add(node.opkey);
- node.expanded = true;
-
- const deps = await this._on_expand(node.opkey);
- if (!deps || deps.length == 0)
- {
- this._update_prop_panel(node);
- this._render();
- return;
- }
-
- // count dep types
- const dep_types = {};
- var dep_total = 0;
- for (const dep of deps)
- {
- dep_types[dep.dep_type] = (dep_types[dep.dep_type] || 0) + 1;
- dep_total++;
- }
- node.dep_count = dep_total;
- node.dep_types = dep_types;
-
- // determine arc: full circle for root, semi-circle for non-root
- var arc_start, arc_span;
- if (node.is_root)
- {
- arc_start = 0;
- arc_span = 2 * Math.PI;
- }
- else
- {
- const root = this._nodes.find(n => n.is_root);
- const root_angle = root
- ? Math.atan2(node.y - root.y, node.x - root.x)
- : 0;
- arc_start = root_angle - Math.PI / 2;
- arc_span = Math.PI;
- }
-
- const visible = Math.min(deps.length, MAX_VISIBLE_DEPS);
- const radius = 150 + visible * 3;
-
- var added = 0;
- for (const dep of 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() * 30;
- 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.unresolved || false;
- this._add_edge(node, dep_node, dep.dep_type);
- added++;
- }
-
- const cx = this._canvas.width / 2;
- const cy = this._canvas.height / 2;
- layout_run(this._nodes, this._edges, cx, cy, 200);
- this._update_prop_panel(node);
- this._render();
- }
-
- _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);
- }
-
- // -- property panel ----
-
- _update_prop_panel(node)
- {
- const el = this._prop_panel;
- if (!el) return;
-
- if (!node)
- {
- el.innerHTML = '<div class="minigraph_props_empty">hover a node</div>';
- return;
- }
-
- var html = "";
-
- const row = (label, value) => {
- html += '<div class="minigraph_props_row">'
- + '<span class="minigraph_props_label">' + label + '</span>'
- + '<span>' + value + '</span></div>';
- };
-
- row("name", node.label);
- row("path", node.opkey);
-
- 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 + "%");
- }
- }
-
- // count incoming/outgoing edges
- var incoming = 0, outgoing = 0;
- for (const edge of this._edges)
- {
- if (edge.source === node) outgoing++;
- if (edge.target === node) incoming++;
- }
- if (outgoing > 0 || node.dep_count > 0)
- {
- var dep_str = "" + (node.dep_count || outgoing);
- 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);
- }
- if (incoming > 0)
- row("imported by", incoming + " node" + (incoming > 1 ? "s" : ""));
-
- // depth from root
- const depth = this._depth_to_root(node);
- if (depth >= 0)
- row("depth", depth);
-
- // status
- const tags = [];
- if (node.is_root) tags.push("root");
- if (node.expanded) tags.push("expanded");
- if (node.unresolved) tags.push("unresolved");
- if (node.truncated) tags.push("truncated");
- if (tags.length > 0)
- row("status", tags.join(", "));
-
- el.innerHTML = html;
- }
-
- _depth_to_root(node)
- {
- const visited = new Set();
- var current = node;
- var depth = 0;
- while (current && !current.is_root)
- {
- if (visited.has(current)) return -1;
- visited.add(current);
- var parent = null;
- for (const edge of this._edges)
- {
- if (edge.target === current)
- {
- parent = edge.source;
- break;
- }
- }
- current = parent;
- depth++;
- }
- return current ? depth : -1;
- }
-
- // -- splitter ----
-
- _sync_canvas_size()
- {
- this._canvas.width = this._canvas.clientWidth;
- this._canvas.height = this._canvas.clientHeight;
- }
-
- _bind_splitter(splitter_el)
- {
- if (!splitter_el) return;
- this._splitter = splitter_el;
-
- const on_mousemove = (e) => {
- const dx = e.clientX - this._resize_start_x;
- var new_w = this._resize_start_w - dx;
- const container_w = this._canvas.parentElement.clientWidth;
- new_w = Math.max(80, Math.min(new_w, container_w - 200));
- this._prop_panel.style.width = new_w + "px";
- this._sync_canvas_size();
- this._render();
- };
-
- const on_mouseup = () => {
- this._resizing = false;
- this._splitter.classList.remove("active");
- document.removeEventListener("mousemove", on_mousemove);
- document.removeEventListener("mouseup", on_mouseup);
- };
-
- this._splitter_mousedown_fn = (e) => {
- e.preventDefault();
- this._resizing = true;
- this._resize_start_x = e.clientX;
- this._resize_start_w = this._prop_panel.offsetWidth;
- this._splitter.classList.add("active");
- document.addEventListener("mousemove", on_mousemove);
- document.addEventListener("mouseup", on_mouseup);
- };
-
- splitter_el.addEventListener("mousedown", this._splitter_mousedown_fn);
- }
-
- // -- rendering ----
-
- _render()
- {
- const ctx = this._ctx;
- if (!ctx) return;
-
- const c = this._colors;
- const canvas = this._canvas;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
-
- ctx.save();
- ctx.translate(this._transform.x, this._transform.y);
- ctx.scale(this._transform.scale, this._transform.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 / this._transform.scale;
- ctx.setLineDash(dep.dash.map(v => v / this._transform.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();
-
- // arrowhead
- 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 / this._transform.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 — two passes: dim non-highlighted, then highlighted on top
- const hover = this._hover_node;
- const do_highlight = hover && !hover.is_root;
-
- // pass 1: non-highlighted edges
- for (const edge of this._edges)
- {
- if (this._is_edge_hidden(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);
- }
-
- // pass 2: highlighted edges for hover node
- if (do_highlight)
- {
- const path_edges = new Set();
- const visited = new Set();
- const trace = (node) => {
- if (visited.has(node)) return;
- visited.add(node);
- for (const edge of this._edges)
- {
- if (this._is_edge_hidden(edge))
- continue;
- if (edge.target === node)
- {
- path_edges.add(edge);
- trace(edge.source);
- }
- }
- };
- trace(hover);
-
- for (const edge of this._edges)
- if (edge.source === hover && !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);
-
- if (node === this._hover_node)
- ctx.fillStyle = c.p2;
- else if (node.is_root)
- ctx.fillStyle = c.p0;
- else if (node.unresolved)
- ctx.fillStyle = c.g3;
- else
- ctx.fillStyle = c.g3;
- ctx.fill();
-
- const node_sizes = this._size_map[node.opkey];
- const has_dot = !node.is_root && !node.unresolved && node_sizes && node_sizes.raw_size > 0n;
- const text_x = has_dot ? node.x + DOT_SPACE / 2 : node.x;
-
- ctx.fillStyle = (node.is_root || node === this._hover_node) ? c.g4 : c.g0;
- if (node.unresolved)
- ctx.fillStyle = c.g1;
- ctx.fillText(node.label, text_x, node.y);
-
- // compression ratio dot
- 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();
- }
-
- // truncated indicator
- 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";
- }
- };
-
- // pass 1: leaf nodes
- for (const node of this._nodes)
- if (!node.is_root && !node.expanded && node !== this._hover_node)
- draw_node(node);
-
- // pass 2: expanded nodes
- for (const node of this._nodes)
- if (node.expanded && !node.is_root && node !== this._hover_node)
- draw_node(node);
-
- // pass 3: root
- for (const node of this._nodes)
- if (node.is_root)
- draw_node(node);
-
- // pass 4: hovered node
- if (this._hover_node && this._transform.scale >= 0.6)
- draw_node(this._hover_node);
-
- ctx.restore();
-
- // zoomed-out hover: draw in screen space at readable size
- if (this._hover_node && this._transform.scale < 0.6)
- {
- const node = this._hover_node;
- const sx = node.x * this._transform.scale + this._transform.x;
- const sy = node.y * this._transform.scale + this._transform.y;
-
- ctx.font = "11px consolas, monospace";
- ctx.textBaseline = "middle";
- ctx.textAlign = "center";
- const tw = ctx.measureText(node.label).width + NODE_PAD * 2;
- 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, sx, sy);
- }
-
- // 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];
- 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");
- if (node.unresolved) lines.push("[unresolved]");
-
- 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 * this._transform.scale + this._transform.x;
- const node_sy = node.y * this._transform.scale + this._transform.y;
- const node_sh = node.h * this._transform.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);
- }
-
- // empty state
- if (this._nodes.length == 0)
- {
- ctx.fillStyle = c.g1;
- ctx.font = "14px consolas, monospace";
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText("no dependencies", 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;
-
- for (var i = this._nodes.length - 1; i >= 0; --i)
- {
- const n = this._nodes[i];
- const hw = n.w / 2, hh = n.h / 2;
- if (gx >= n.x - hw && gx <= n.x + hw &&
- gy >= n.y - hh && gy <= n.y + hh)
- return n;
- }
- return null;
- }
-
- // -- mouse interaction ----
-
- _bind_events()
- {
- this._on_mousedown_fn = (e) => this._on_mousedown(e);
- this._on_mousemove_fn = (e) => this._on_mousemove(e);
- this._on_mouseup_fn = (e) => this._on_mouseup(e);
- this._on_dblclick_fn = (e) => this._on_dblclick(e);
- this._on_wheel_fn = (e) => this._on_wheel(e);
-
- this._canvas.addEventListener("mousedown", this._on_mousedown_fn);
- this._canvas.addEventListener("mousemove", this._on_mousemove_fn);
- this._canvas.addEventListener("mouseup", this._on_mouseup_fn);
- this._canvas.addEventListener("mouseleave", this._on_mouseup_fn);
- this._canvas.addEventListener("dblclick", this._on_dblclick_fn);
- this._canvas.addEventListener("wheel", this._on_wheel_fn, { passive: false });
- }
-
- _on_mousedown(e)
- {
- if (this._resizing) 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)
- {
- this._drag = {
- type: "node",
- node: node,
- start_x: mx,
- start_y: my,
- node_start_x: node.x,
- node_start_y: node.y,
- 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)
- {
- if (this._resizing) return;
- 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;
- 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.opkey && !node.expanded && !node.unresolved)
- 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.opkey && this._on_navigate)
- this._on_navigate(node.opkey);
- }
-
- _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();
- }
-
- _fit_view()
- {
- if (this._nodes.length == 0)
- return;
-
- var min_x = Infinity, min_y = Infinity;
- var max_x = -Infinity, max_y = -Infinity;
- for (const n of this._nodes)
- {
- 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();
+ this._engine.mark_has_deps(opkey_set);
}
destroy()
{
- this._canvas.removeEventListener("mousedown", this._on_mousedown_fn);
- this._canvas.removeEventListener("mousemove", this._on_mousemove_fn);
- this._canvas.removeEventListener("mouseup", this._on_mouseup_fn);
- this._canvas.removeEventListener("mouseleave", this._on_mouseup_fn);
- this._canvas.removeEventListener("dblclick", this._on_dblclick_fn);
- this._canvas.removeEventListener("wheel", this._on_wheel_fn);
- if (this._splitter)
- this._splitter.removeEventListener("mousedown", this._splitter_mousedown_fn);
+ this._engine.destroy();
}
}
-
-////////////////////////////////////////////////////////////////////////////////
-function _friendly_kib(n)
-{
- if (typeof n === "bigint")
- n = Number(n);
- if (n < 1024) return n + " B";
- if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
- if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MiB";
- return (n / (1024 * 1024 * 1024)).toFixed(2) + " GiB";
-}
diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css
index 6e54adc87..0c0ac2de8 100644
--- a/src/zenserver/frontend/html/zen.css
+++ b/src/zenserver/frontend/html/zen.css
@@ -233,18 +233,80 @@ a {
}
}
+.legend_scale_wrap {
+ display: inline-flex;
+ flex-direction: column;
+ width: 6em;
+ vertical-align: middle;
+ position: relative;
+ top: 2px;
+}
.legend_scale {
position: relative;
- display: inline-flex;
+ display: flex;
justify-content: space-between;
align-items: center;
- width: 6em;
+ width: 100%;
padding: 0 0.2em;
height: 1.1em;
- vertical-align: middle;
+ box-sizing: border-box;
+ border-radius: 2px;
+ overflow: hidden;
.legend_scale_lo, .legend_scale_hi {
font-size: 0.8em;
text-shadow: 0 0 3px var(--theme_g4), 0 0 3px var(--theme_g4);
+ position: relative;
+ z-index: 1;
+ }
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: var(--filter-pos, 100%);
+ right: 0;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.65);
+ pointer-events: none;
+ }
+}
+.legend_compression_slider {
+ width: 100%;
+ height: 6px;
+ margin: 2px 0 -6px 0;
+ padding: 0;
+ cursor: pointer;
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ border: none;
+ outline: none;
+ &::-webkit-slider-runnable-track {
+ background: transparent;
+ height: 6px;
+ border: none;
+ }
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 8px;
+ height: 6px;
+ background: var(--theme_g0);
+ clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
+ cursor: pointer;
+ border: none;
+ }
+ &::-moz-range-track {
+ background: transparent;
+ height: 6px;
+ border: none;
+ }
+ &::-moz-range-thumb {
+ width: 8px;
+ height: 6px;
+ background: var(--theme_g0);
+ border: none;
+ clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
+ cursor: pointer;
}
}
@@ -821,6 +883,85 @@ html:has(#graph), html:has(#graph-debug-playground) {
}
}
+/* graph breadcrumb --------------------------------------------------------- */
+
+.graph_breadcrumb {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0;
+ font: 11px consolas, monospace;
+ padding: 0.3em 0;
+ min-height: 1.4em;
+ .graph_breadcrumb_seg {
+ padding: 0.15em 0.4em;
+ cursor: pointer;
+ color: var(--theme_ln);
+ border-radius: 3px;
+ white-space: nowrap;
+ &:hover {
+ background-color: var(--theme_p4);
+ color: var(--theme_g0);
+ }
+ &.active {
+ color: var(--theme_g0);
+ font-weight: bold;
+ }
+ }
+ .graph_breadcrumb_sep {
+ color: var(--theme_g1);
+ padding: 0 0.1em;
+ user-select: none;
+ }
+}
+
+/* graph search overlay ----------------------------------------------------- */
+
+.graph_search_overlay {
+ position: absolute;
+ top: 8px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background-color: var(--theme_g3);
+ border: 1px solid var(--theme_g2);
+ border-radius: 6px;
+ padding: 4px 8px;
+ font: 11px consolas, monospace;
+ z-index: 100;
+ box-shadow: 2px 4px 12px rgba(0,0,0,0.3);
+ input {
+ background: transparent;
+ border: none;
+ color: var(--theme_g0);
+ font: inherit;
+ flex: 1;
+ min-width: 0;
+ outline: none;
+ &::placeholder {
+ color: var(--theme_g1);
+ }
+ }
+ .graph_search_count {
+ color: var(--theme_g1);
+ white-space: nowrap;
+ min-width: 4em;
+ text-align: right;
+ }
+ button {
+ background: none;
+ border: none;
+ color: var(--theme_g1);
+ cursor: pointer;
+ padding: 0 2px;
+ font: inherit;
+ line-height: 1;
+ &:hover {
+ color: var(--theme_g0);
+ }
+ }
+}
+
/* loading overlay --------------------------------------------------------- */
#graph-debug-playground .graph_loading {