aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend
diff options
context:
space:
mode:
authorMtBntChvn <[email protected]>2026-02-18 15:16:50 +0000
committerMtBntChvn <[email protected]>2026-02-18 15:16:50 +0000
commit71044b95cd44c7b7fbbe4dfeb182c32c29deb685 (patch)
treee7231945635e27490fa113ced45a629f1992bee0 /src/zenserver/frontend
parentadd selective request logging support to http.sys (#762) (diff)
downloadzen-71044b95cd44c7b7fbbe4dfeb182c32c29deb685.tar.xz
zen-71044b95cd44c7b7fbbe4dfeb182c32c29deb685.zip
add interactive dependency graph view to dashboard
Canvas-based force-directed graph for exploring package dependencies. Nodes show short names with full path on hover tooltip (with size/dep info). Supports pan, zoom, node drag, click-to-expand, double-click to promote nodes to root, right-click to demote. Includes side panel with browsable entry tree and filter. Linked from project page and entry detail page.
Diffstat (limited to 'src/zenserver/frontend')
-rw-r--r--src/zenserver/frontend/html.zipbin163229 -> 184943 bytes
-rw-r--r--src/zenserver/frontend/html/pages/entry.js8
-rw-r--r--src/zenserver/frontend/html/pages/graph.js1086
-rw-r--r--src/zenserver/frontend/html/pages/project.js1
-rw-r--r--src/zenserver/frontend/html/zen.css97
5 files changed, 1192 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html.zip b/src/zenserver/frontend/html.zip
index 5d33302dd..049a3c126 100644
--- a/src/zenserver/frontend/html.zip
+++ b/src/zenserver/frontend/html.zip
Binary files differ
diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js
index 08589b090..0e0dd1523 100644
--- a/src/zenserver/frontend/html/pages/entry.js
+++ b/src/zenserver/frontend/html/pages/entry.js
@@ -142,6 +142,14 @@ 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.js b/src/zenserver/frontend/html/pages/graph.js
new file mode 100644
index 000000000..2c8ab72b3
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/graph.js
@@ -0,0 +1,1086 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+import { ZenPage } from "./page.js"
+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;
+}
+
+function measure_node_width(label)
+{
+ return _measure_ctx.measureText(label).width + NODE_PAD * 2;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+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);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ main()
+ {
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+ const opkey = this.get_param("opkey");
+
+ this.set_title("graph");
+
+ this._nodes = [];
+ this._edges = [];
+ this._node_map = {};
+ this._tree_cache = {};
+ this._expanded = 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: [] };
+
+ this._indexer = this._load_indexer(project, oplog);
+ const section = this.add_section(project + " - " + oplog);
+ this._build(section, opkey);
+ }
+
+ async _load_indexer(project, oplog)
+ {
+ const progress_bar = this.add_widget(ProgressBar);
+ progress_bar.set_progress("indexing");
+ var indexer = create_indexer(project, oplog, (...args) => {
+ progress_bar.set_progress(...args);
+ });
+ indexer = await indexer;
+ progress_bar.destroy();
+ return indexer;
+ }
+
+ async _build(section, opkey)
+ {
+ const indexer = await this._indexer;
+
+ // build size lookup from indexer
+ this._size_map = {};
+ for (const [name, size, raw_size] of indexer.enum_all())
+ this._size_map[name] = { size: size, raw_size: raw_size };
+
+ // toolbar
+ const toolbar = section.add_widget(Toolbar);
+ 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, dropdown);
+ });
+ search_input.inner().addEventListener("focus", (e) => {
+ if (e.target.value.length > 0)
+ this._on_search(e.target.value, dropdown);
+ });
+ document.addEventListener("click", (e) => {
+ if (!search_wrap.inner().contains(e.target))
+ dropdown.inner().style.display = "none";
+ });
+
+ right.add("fit").on_click(() => this._fit_view());
+ right.add("reset").on_click(() => this._reset_graph());
+
+ this._search_input = search_input;
+ this._dropdown = dropdown;
+
+ // canvas + entry list
+ const view = section.tag().id("graph_view");
+ const canvas_el = view.tag("canvas").inner();
+ this._canvas = canvas_el;
+
+ // entry list panel
+ const panel = view.tag().id("graph_entries");
+ const panel_filter = panel.tag("input");
+ panel_filter.attr("type", "text");
+ panel_filter.attr("placeholder", "filter...");
+ const panel_list = panel.tag().id("graph_entries_list");
+ this._populate_entries(panel_list, panel_filter);
+
+ const resize = () => {
+ const rect = canvas_el.getBoundingClientRect();
+ var h = window.visualViewport.height - rect.top - 50;
+ if (h < 300) h = 300;
+ canvas_el.style.height = h + "px";
+ canvas_el.width = canvas_el.offsetWidth;
+ canvas_el.height = h;
+ panel.inner().style.height = h + "px";
+ this._render();
+ };
+ resize();
+ window.addEventListener("resize", resize);
+
+ 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("contextmenu", (e) => this._on_contextmenu(e));
+ canvas_el.addEventListener("wheel", (e) => this._on_wheel(e), { passive: false });
+
+ // legend
+ const legend = section.tag().id("graph_legend");
+ for (const name in this._dep_colors)
+ {
+ const dep = this._dep_colors[name];
+ const item = legend.tag();
+ 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);
+ }
+ {
+ const item = legend.tag();
+ const swatch = item.tag("span");
+ swatch.classify("legend_swatch");
+ swatch.inner().style.backgroundColor = this._colors.g1;
+ item.tag("span").text("other");
+ }
+
+ if (opkey)
+ this._load_root(opkey);
+ }
+
+ async _load_root(opkey)
+ {
+ const cx = this._canvas.width / 2;
+ const cy = this._canvas.height / 2;
+
+ const node = this._add_node(opkey, cx, cy, true);
+ await this._expand_node(node);
+ }
+
+ 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;
+ if (needle.length >= 2)
+ this._render_filtered_entries(list, needle);
+ else
+ this._render_tree_level(list, "/");
+ });
+
+ this._render_tree_level(list, "/");
+ }
+
+ _render_tree_level(list, prefix)
+ {
+ list.inner().innerHTML = "";
+ 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;
+ });
+
+ for (const child of sorted)
+ {
+ const item = list.tag();
+ const is_dir = child.endsWith("/");
+ const display = is_dir ? child.slice(0, -1) + "/ (" + children[child] + ")" : child;
+ item.text(display);
+
+ if (is_dir)
+ {
+ item.classify("graph_entry_dir");
+ item.on("click", () => {
+ this._render_tree_level(list, prefix + child);
+ });
+ }
+ else
+ {
+ const full_name = prefix + child;
+ item.classify("graph_entry_leaf");
+ item.on("click", () => {
+ this._select_entry(full_name);
+ });
+ }
+ }
+
+ 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);
+ });
+ }
+ }
+
+ _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;
+ }
+
+ _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 });
+ }
+
+ async _fetch_tree(opkey)
+ {
+ if (opkey in this._tree_cache)
+ return this._tree_cache[opkey];
+
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+ const cbo = await new Fetcher()
+ .resource("prj", project, "oplog", oplog, "entries")
+ .param("opkey", opkey)
+ .cbo();
+
+ if (!cbo)
+ {
+ this._tree_cache[opkey] = null;
+ return null;
+ }
+
+ const entry_field = cbo.as_object().find("entry");
+ if (!entry_field)
+ {
+ this._tree_cache[opkey] = null;
+ return null;
+ }
+
+ const entry = entry_field.as_object();
+ var tree = entry.find("$tree");
+ if (tree != undefined)
+ tree = tree.as_object().to_js_object();
+ else
+ tree = this._convert_legacy_to_tree(entry);
+
+ if (!tree)
+ {
+ this._tree_cache[opkey] = null;
+ return null;
+ }
+
+ delete tree["$id"];
+ this._tree_cache[opkey] = tree;
+ return tree;
+ }
+
+ _convert_legacy_to_tree(entry)
+ {
+ const raw_pkgst_entry = entry.find("packagestoreentry");
+ if (raw_pkgst_entry == undefined)
+ return null;
+
+ const tree = {};
+
+ const pkg_data = entry.find("packagedata");
+ if (pkg_data)
+ {
+ var id = 0n;
+ for (var item of pkg_data.as_array())
+ {
+ var pkg_id = item.as_object().find("id");
+ if (pkg_id == undefined)
+ continue;
+
+ pkg_id = pkg_id.as_value().subarray(0, 8);
+ for (var i = 7; i >= 0; --i)
+ {
+ id <<= 8n;
+ id |= BigInt(pkg_id[i]);
+ }
+ break;
+ }
+ tree["$id"] = id;
+ }
+
+ const pkgst_entry = raw_pkgst_entry.as_object();
+ for (const field of pkgst_entry)
+ {
+ const field_name = field.get_name();
+ if (!field_name.endsWith("importedpackageids"))
+ continue;
+
+ var dep_name = field_name.slice(0, -18);
+ if (dep_name.length == 0)
+ dep_name = "imported";
+
+ var out = tree[dep_name] = [];
+ for (var item of field.as_array())
+ out.push(item.as_value(BigInt));
+ }
+
+ return tree;
+ }
+
+ async _expand_node(node)
+ {
+ if (this._expanded.has(node.opkey))
+ return;
+
+ this._expanded.add(node.opkey);
+ node.expanded = true;
+
+ const tree = await this._fetch_tree(node.opkey);
+ if (!tree)
+ {
+ this._render();
+ return;
+ }
+
+ const indexer = await this._indexer;
+
+ var dep_total = 0;
+ const dep_types = {};
+ for (const dep_name in tree)
+ {
+ const count = tree[dep_name].length;
+ dep_total += count;
+ dep_types[dep_name] = count;
+ }
+ node.dep_count = dep_total;
+ node.dep_types = dep_types;
+
+ var added = 0;
+ const angle_step = (2 * Math.PI) / Math.min(dep_total, MAX_VISIBLE_DEPS);
+ var angle = 0;
+
+ for (const dep_name in tree)
+ {
+ for (const dep_id of tree[dep_name])
+ {
+ if (added >= MAX_VISIBLE_DEPS)
+ {
+ node.truncated = true;
+ break;
+ }
+
+ 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 radius = 150 + dep_total * 3 + Math.random() * 50;
+ const dx = node.x + Math.cos(angle) * radius;
+ const dy = node.y + Math.sin(angle) * radius;
+ const dep_node = this._add_node(opkey, dx, dy, false);
+ dep_node.unresolved = is_unresolved;
+ this._add_edge(node, dep_node, dep_name);
+ angle += angle_step;
+ added++;
+ }
+ if (node.truncated)
+ break;
+ }
+
+ const cx = this._canvas.width / 2;
+ const cy = this._canvas.height / 2;
+ layout_run(this._nodes, this._edges, cx, cy, 200);
+ this._fit_view();
+ }
+
+ _reset_graph()
+ {
+ this._nodes = [];
+ this._edges = [];
+ this._node_map = {};
+ this._expanded = new Set();
+ this._transform = { x: 0, y: 0, scale: 1.0 };
+ 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();
+ }
+
+ // -- 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
+ for (const edge of this._edges)
+ {
+ const dep = this._dep_colors[edge.dep_type] || this._dep_default;
+ ctx.strokeStyle = dep.color;
+ ctx.lineWidth = 1.5 / 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 at target edge
+ 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 = dep.color;
+ ctx.fill();
+ }
+ }
+ 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.expanded)
+ ctx.fillStyle = c.p4;
+ else if (node.unresolved)
+ ctx.fillStyle = c.g3;
+ else
+ ctx.fillStyle = c.g3;
+ ctx.fill();
+
+ ctx.fillStyle = (node.is_root || node === this._hover_node) ? c.g4 : c.g0;
+ if (node.unresolved)
+ ctx.fillStyle = c.g1;
+ ctx.fillText(node.label, node.x, node.y);
+
+ 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();
+
+ // 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);
+ }
+
+ // tooltip for hover node
+ if (this._hover_node)
+ {
+ 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)
+ {
+ lines.push("size: " + Friendly.kib(sizes.size) +
+ " raw: " + Friendly.kib(sizes.raw_size));
+ }
+ 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]");
+
+ // measure tooltip size
+ 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;
+
+ // 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;
+
+ 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;
+ }
+
+ // 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._render();
+ }
+ else if (node)
+ {
+ this._hover_mouse = { x: mx, y: my };
+ this._render();
+ }
+ }
+
+ _on_mouseup(e)
+ {
+ if (!this._drag)
+ return;
+
+ const drag = this._drag;
+ this._drag = null;
+
+ 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);
+ }
+ }
+
+ _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.unresolved)
+ this._promote_to_root(node);
+ }
+
+ _promote_to_root(node)
+ {
+ node.is_root = true;
+ node.pinned = true;
+ // resize to root padding
+ const pad = NODE_PAD * 3;
+ node.w = measure_node_width(node.label) + pad;
+ node.h = NODE_H + 10;
+ if (!node.expanded)
+ this._expand_node(node);
+ else
+ this._render();
+ }
+
+ _on_contextmenu(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.is_root)
+ {
+ e.preventDefault();
+ this._demote_from_root(node);
+ }
+ }
+
+ _demote_from_root(node)
+ {
+ node.is_root = false;
+ node.pinned = false;
+ node.w = measure_node_width(node.label);
+ node.h = NODE_H;
+ this._render();
+ }
+
+ _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.2, 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();
+ }
+
+ // -- search ----
+
+ async _on_search(needle, dropdown)
+ {
+ const el = dropdown.inner();
+
+ if (needle.length < 2)
+ {
+ el.style.display = "none";
+ return;
+ }
+
+ const indexer = await this._indexer;
+ el.innerHTML = "";
+ var count = 0;
+ for (const name of indexer.search(needle))
+ {
+ if (count >= 30)
+ break;
+ const item = dropdown.tag();
+ item.text(name);
+ 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);
+ });
+ count++;
+ }
+
+ el.style.display = count > 0 ? "block" : "none";
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/project.js b/src/zenserver/frontend/html/pages/project.js
index 42ae30c8c..3ae2d6034 100644
--- a/src/zenserver/frontend/html/pages/project.js
+++ b/src/zenserver/frontend/html/pages/project.js
@@ -55,6 +55,7 @@ export class Page extends ZenPage
const action_tb = new Toolbar(cell, true).left();
this.as_link(action_tb.add("list"), "oplog", name);
this.as_link(action_tb.add("tree"), "tree", name);
+ this.as_link(action_tb.add("graph"), "graph", name);
action_tb.add("drop").on_click((x) => this.drop_oplog(x), name);
info = await info;
diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css
index cc53c0519..cc9cd30e8 100644
--- a/src/zenserver/frontend/html/zen.css
+++ b/src/zenserver/frontend/html/zen.css
@@ -503,3 +503,100 @@ html:has(#map) {
}
}
}
+
+/* graph -------------------------------------------------------------------- */
+
+html:has(#graph) {
+ height: 100%;
+ body, #container, #graph {
+ height: 100%;
+ }
+}
+#graph {
+ #graph_view {
+ position: relative;
+ display: flex;
+ gap: 0;
+ canvas {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid var(--theme_g2);
+ cursor: grab;
+ }
+ canvas:active {
+ cursor: grabbing;
+ }
+ }
+ #graph_entries {
+ width: 22em;
+ border: 1px solid var(--theme_g2);
+ border-left: none;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ > input {
+ width: 100%;
+ border: none;
+ border-bottom: 1px solid var(--theme_g2);
+ padding: 0.4em 0.6em;
+ font-size: 0.9em;
+ }
+ #graph_entries_list {
+ overflow-y: auto;
+ flex: 1;
+ > div {
+ padding: 0.2em 0.6em;
+ cursor: pointer;
+ font-size: 0.85em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ > div:hover {
+ background-color: var(--theme_p4);
+ }
+ .graph_entry_dir {
+ font-weight: bold;
+ }
+ .graph_entry_leaf {
+ color: var(--theme_ln);
+ }
+ .graph_entries_more {
+ color: var(--theme_g1);
+ cursor: default;
+ }
+ }
+ }
+ #graph_search_results {
+ position: absolute;
+ z-index: 1;
+ background-color: var(--theme_g4);
+ border: 1px solid var(--theme_g2);
+ max-height: 20em;
+ overflow-y: auto;
+ width: 30em;
+ > div {
+ padding: 0.3em 0.75em;
+ cursor: pointer;
+ }
+ > div:hover {
+ background-color: var(--theme_p4);
+ }
+ }
+ #graph_legend {
+ display: flex;
+ gap: 1.5em;
+ margin-top: 0.5em;
+ font-size: 0.85em;
+ > div {
+ display: flex;
+ align-items: center;
+ gap: 0.3em;
+ }
+ .legend_swatch {
+ width: 1.5em;
+ height: 0.3em;
+ display: inline-block;
+ }
+ }
+}