aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/util
diff options
context:
space:
mode:
authorMtBntChvn <[email protected]>2026-02-18 23:37:21 +0000
committerMtBntChvn <[email protected]>2026-02-18 23:37:21 +0000
commitfad55e1fb31e383dcffb6d0f0f331f639d235deb (patch)
tree4ee87ede78cea0eeb33663196344c31d5a742a6d /src/zenserver/frontend/html/util
parentadd interactive dependency graph view to dashboard (diff)
downloadzen-fad55e1fb31e383dcffb6d0f0f331f639d235deb.tar.xz
zen-fad55e1fb31e383dcffb6d0f0f331f639d235deb.zip
add uniform view navigation links to dashboard pages
Adds list/tree/graph links to the section header of the oplog, tree, and graph pages. Links are displayed in a row at the top right, on the same line as the section heading, with the border extending under them. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Diffstat (limited to 'src/zenserver/frontend/html/util')
-rw-r--r--src/zenserver/frontend/html/util/minigraph.js1084
-rw-r--r--src/zenserver/frontend/html/util/widgets.js13
2 files changed, 1095 insertions, 2 deletions
diff --git a/src/zenserver/frontend/html/util/minigraph.js b/src/zenserver/frontend/html/util/minigraph.js
new file mode 100644
index 000000000..be7e1c713
--- /dev/null
+++ b/src/zenserver/frontend/html/util/minigraph.js
@@ -0,0 +1,1084 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"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 + ")";
+}
+
+////////////////////////////////////////////////////////////////////////////////
+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)
+{
+ for (var i = 0; i < n; ++i)
+ layout_step(nodes, edges, cx, cy);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+export class MiniGraph
+{
+ constructor(canvas_el, prop_panel_el, splitter_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._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 });
+ }
+
+ _build_graph(root_opkey, root_label, deps)
+ {
+ const cx = this._canvas.width / 2;
+ const cy = this._canvas.height / 2;
+
+ // root node
+ const root = this._add_node(root_opkey, cx, cy, true);
+ root.expanded = true;
+ this._expanded.add(root_opkey);
+
+ // count dep types
+ const dep_types = {};
+ for (const dep of deps)
+ dep_types[dep.dep_type] = (dep_types[dep.dep_type] || 0) + 1;
+ root.dep_count = deps.length;
+ root.dep_types = dep_types;
+
+ // dep nodes arranged in a circle
+ const count = deps.length;
+ const radius = 150 + count * 3;
+ for (var i = 0; i < count; ++i)
+ {
+ 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(
+ 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);
+ }
+
+ layout_run(this._nodes, this._edges, cx, cy, 200);
+ }
+
+ async _expand_node(node)
+ {
+ 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();
+ }
+
+ 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);
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+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/util/widgets.js b/src/zenserver/frontend/html/util/widgets.js
index 32a3f4d28..d96299a0b 100644
--- a/src/zenserver/frontend/html/util/widgets.js
+++ b/src/zenserver/frontend/html/util/widgets.js
@@ -276,8 +276,17 @@ export class WidgetHost
if (this._depth == 1)
node.classify("zen_sector");
- node.tag("h" + this._depth).text(name);
- return new WidgetHost(node, this._depth + 1);
+ var header = node.tag().classify("zen_section_header");
+ header.tag("h" + this._depth).text(name);
+
+ var host = new WidgetHost(node, this._depth + 1);
+ host._header = header;
+ return host;
+ }
+
+ header()
+ {
+ return this._header;
}
add_widget(type, ...args)