diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/zenserver/frontend/html/pages/entry.js | 36 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/graph-debug-playground.js | 2326 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/graph.js | 1335 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/graphengine.js | 2297 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/minigraph.js | 1060 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 147 |
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> <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> <b>expand</b> / <b>collapse</b> / <b>re-expand</b> — manage children</p>' + + '<p> <b>fit subtree</b> — zoom to fit node and descendants</p>' + + '<p> <b>trace to root</b> — hide everything except paths to root</p>' + + '<p> <b>pin</b> / <b>unpin</b> — lock a node in place</p>' + + '<p><b>scroll wheel</b> to zoom in/out at cursor position</p>' + + '<p><b>drag empty space</b> to pan the view</p>' + + '<p><b>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 { |