aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html
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
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')
-rw-r--r--src/zenserver/frontend/html/pages/entry.js122
-rw-r--r--src/zenserver/frontend/html/pages/graph.js473
-rw-r--r--src/zenserver/frontend/html/pages/oplog.js8
-rw-r--r--src/zenserver/frontend/html/pages/page.js17
-rw-r--r--src/zenserver/frontend/html/pages/tree.js1
-rw-r--r--src/zenserver/frontend/html/util/minigraph.js1084
-rw-r--r--src/zenserver/frontend/html/util/widgets.js13
-rw-r--r--src/zenserver/frontend/html/zen.css155
8 files changed, 1762 insertions, 111 deletions
diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js
index 0e0dd1523..894d8315b 100644
--- a/src/zenserver/frontend/html/pages/entry.js
+++ b/src/zenserver/frontend/html/pages/entry.js
@@ -7,6 +7,9 @@ import { Fetcher } from "../util/fetcher.js"
import { Friendly } from "../util/friendly.js"
import { Table, PropTable, Toolbar, ProgressBar } from "../util/widgets.js"
import { create_indexer } from "../indexer/indexer.js"
+import { MiniGraph } from "../util/minigraph.js"
+
+////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////
export class Page extends ZenPage
@@ -44,10 +47,72 @@ export class Page extends ZenPage
{
const indexer = await this._indexer;
+ // build size map once for mini-graph compression dots
+ if (!this._size_map)
+ {
+ this._size_map = {};
+ for (const [name, size, raw_size] of indexer.enum_all())
+ this._size_map[name] = { size: size, raw_size: raw_size };
+ }
+
+ const root_opkey = this.get_param("opkey");
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+
+ // shared expand callback: fetches an entry's tree and resolves deps
+ const on_expand = async (opkey) => {
+ const cbo = await new Fetcher()
+ .resource("prj", project, "oplog", oplog, "entries")
+ .param("opkey", opkey)
+ .cbo();
+ if (!cbo) return null;
+
+ const entry_field = cbo.as_object().find("entry");
+ if (!entry_field) return null;
+
+ const entry_obj = entry_field.as_object();
+ var dep_tree = entry_obj.find("$tree");
+ if (dep_tree != undefined)
+ dep_tree = dep_tree.as_object().to_js_object();
+ else
+ dep_tree = this._convert_legacy_to_tree(entry_obj);
+ if (!dep_tree) return null;
+ delete dep_tree["$id"];
+
+ const result = [];
+ for (const dep_name in dep_tree)
+ {
+ for (const dep_id of dep_tree[dep_name])
+ {
+ const resolved = indexer.lookup_id(dep_id);
+ result.push({
+ opkey: resolved || ("0x" + dep_id.toString(16).padStart(16, "0")),
+ dep_type: dep_name,
+ unresolved: !resolved,
+ });
+ }
+ }
+ return result;
+ };
+
for (const dep_name in tree)
{
- const dep_section = section.add_section(dep_name);
- const table = dep_section.add_widget(Table, ["name", "id"], Table.Flag_PackRight);
+ const dep_node = section.tag();
+
+ // heading with inline toggle
+ const heading = dep_node.tag("h3");
+ heading.tag("span").text(dep_name);
+ const toggle = heading.tag("span");
+ toggle.classify("zen_minigraph_toggle");
+ const btn_table = toggle.tag("span").text("table");
+ const btn_graph = toggle.tag("span").text("graph");
+ btn_table.classify("zen_action");
+ btn_table.classify("active");
+ btn_graph.classify("zen_action");
+
+ // table container
+ const table_wrap = dep_node.tag();
+ const table = new Table(table_wrap, ["name", "id"], Table.Flag_PackRight);
for (const dep_id of tree[dep_name])
{
const cell_values = ["", dep_id.toString(16).padStart(16, "0")];
@@ -56,6 +121,59 @@ export class Page extends ZenPage
var opkey = indexer.lookup_id(dep_id);
row.get_cell(0).text(opkey).on_click((k) => this.view_opkey(k), opkey);
}
+
+ // graph container (hidden)
+ const graph_outer = dep_node.tag();
+ graph_outer.classify("zen_minigraph_wrap");
+ graph_outer.inner().style.display = "none";
+ const graph_wrap = graph_outer.tag();
+ graph_wrap.classify("zen_minigraph");
+ const legend_el = graph_outer.tag().classify("minigraph_legend").inner();
+ var mini_graph = null;
+
+ btn_table.on("click", () => {
+ table_wrap.inner().style.display = "";
+ graph_outer.inner().style.display = "none";
+ btn_table.classify("active");
+ btn_graph.inner().classList.remove("active");
+ });
+
+ btn_graph.on("click", () => {
+ table_wrap.inner().style.display = "none";
+ graph_outer.inner().style.display = "";
+ btn_graph.classify("active");
+ btn_table.inner().classList.remove("active");
+
+ // lazy init
+ 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;
+ canvas_el.height = canvas_el.clientHeight;
+
+ const deps = [];
+ for (const dep_id of tree[dep_name])
+ {
+ const opkey = indexer.lookup_id(dep_id);
+ const unresolved = !opkey;
+ const resolved = opkey || ("0x" + dep_id.toString(16).padStart(16, "0"));
+ deps.push({ opkey: resolved, dep_type: dep_name, unresolved: unresolved });
+ }
+
+ const root_label = root_opkey.replace(/\/$/, "").split("/").pop() || root_opkey;
+ mini_graph = new MiniGraph(
+ canvas_el, prop_el, splitter_el, legend_el,
+ root_opkey, root_label, deps, this._size_map,
+ (opkey) => this.view_opkey(opkey),
+ on_expand
+ );
+ }
+ });
}
}
diff --git a/src/zenserver/frontend/html/pages/graph.js b/src/zenserver/frontend/html/pages/graph.js
index 2c8ab72b3..cce4dd3c1 100644
--- a/src/zenserver/frontend/html/pages/graph.js
+++ b/src/zenserver/frontend/html/pages/graph.js
@@ -30,9 +30,22 @@ function short_name(opkey)
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;
+ 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 + ")";
}
////////////////////////////////////////////////////////////////////////////////
@@ -126,6 +139,7 @@ export class Page extends ZenPage
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;
@@ -150,8 +164,12 @@ export class Page extends ZenPage
};
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);
+ this.add_view_links(section, "graph");
this._build(section, opkey);
}
@@ -192,11 +210,11 @@ export class Page extends ZenPage
dropdown.inner().style.display = "none";
search_input.inner().addEventListener("input", (e) => {
- this._on_search(e.target.value, dropdown);
+ this._on_search(e.target.value.trim(), dropdown);
});
search_input.inner().addEventListener("focus", (e) => {
- if (e.target.value.length > 0)
- this._on_search(e.target.value, dropdown);
+ 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))
@@ -209,8 +227,9 @@ export class Page extends ZenPage
this._search_input = search_input;
this._dropdown = dropdown;
- // canvas + entry list
- const view = section.tag().id("graph_view");
+ // 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;
@@ -220,8 +239,14 @@ export class Page extends ZenPage
panel_filter.attr("type", "text");
panel_filter.attr("placeholder", "filter...");
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();
var h = window.visualViewport.height - rect.top - 50;
@@ -241,16 +266,28 @@ export class Page extends ZenPage
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");
+ 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)
@@ -261,13 +298,34 @@ export class Page extends ZenPage
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");
}
if (opkey)
@@ -281,6 +339,7 @@ export class Page extends ZenPage
const node = this._add_node(opkey, cx, cy, true);
await this._expand_node(node);
+ this._navigate_tree_to(opkey);
}
async _populate_entries(list, filter_input)
@@ -293,7 +352,7 @@ export class Page extends ZenPage
this._all_names = all_names;
filter_input.inner().addEventListener("input", (e) => {
- const needle = e.target.value;
+ const needle = e.target.value.trim();
if (needle.length >= 2)
this._render_filtered_entries(list, needle);
else
@@ -306,6 +365,7 @@ export class Page extends ZenPage
_render_tree_level(list, prefix)
{
list.inner().innerHTML = "";
+ this._current_prefix = prefix;
const children = {};
for (const name of this._all_names)
{
@@ -326,26 +386,45 @@ export class Page extends ZenPage
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, prefix + child);
+ this._render_tree_level(list, full_path);
});
}
else
{
- const full_name = prefix + child;
item.classify("graph_entry_leaf");
+ if (roots.has(full_path))
+ item.classify("graph_entry_active");
item.on("click", () => {
- this._select_entry(full_name);
+ this._select_entry(full_path);
});
}
}
@@ -364,6 +443,123 @@ export class Page extends ZenPage
}
}
+ _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);
+ }
+
+ _update_prop_panel(node)
+ {
+ const el = this._prop_panel.inner();
+
+ if (!node)
+ {
+ el.innerHTML = '<div class="graph_props_empty">hover a node</div>';
+ return;
+ }
+
+ var html = "";
+
+ const row = (label, value) => {
+ html += '<div class="graph_props_row">'
+ + '<span class="graph_props_label">' + label + '</span>'
+ + '<span>' + value + '</span></div>';
+ };
+
+ row("name", node.label);
+ row("path", node.opkey);
+
+ 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;
+ }
+
_render_filtered_entries(list, needle)
{
list.inner().innerHTML = "";
@@ -422,6 +618,21 @@ export class Page extends ZenPage
return node;
}
+ _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);
+ }
+
_add_edge(source, target, dep_type)
{
for (const e of this._edges)
@@ -538,31 +749,19 @@ export class Page extends ZenPage
const indexer = await this._indexer;
+ // 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;
- }
- 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)
@@ -570,24 +769,67 @@ export class Page extends ZenPage
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++;
+ const sizes = this._size_map[opkey];
+ const size = sizes ? Number(sizes.raw_size) : 0;
+ all_deps.push({ opkey, dep_name, is_unresolved, size });
}
- if (node.truncated)
+ }
+ 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;
+ });
+
+ // 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._fit_view();
+ this._render();
}
_reset_graph()
@@ -629,6 +871,14 @@ export class Page extends ZenPage
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()
@@ -656,12 +906,14 @@ export class Page extends ZenPage
return [cx + dx * t, cy + dy * t];
};
- // draw edges
- for (const edge of this._edges)
- {
+ // 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;
- ctx.strokeStyle = dep.color;
- ctx.lineWidth = 1.5 / this._transform.scale;
+ 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;
@@ -674,7 +926,6 @@ export class Page extends ZenPage
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);
@@ -691,9 +942,60 @@ export class Page extends ZenPage
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.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)
+ {
+ 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 (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);
}
ctx.setLineDash([]);
@@ -716,18 +1018,32 @@ export class Page extends ZenPage
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();
+ 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, node.x, node.y);
+ 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)
{
@@ -793,8 +1109,14 @@ export class Page extends ZenPage
const sizes = this._size_map[node.opkey];
if (sizes)
{
- lines.push("size: " + Friendly.kib(sizes.size) +
- " raw: " + Friendly.kib(sizes.raw_size));
+ 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)
{
@@ -959,6 +1281,7 @@ export class Page extends ZenPage
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)
@@ -985,52 +1308,6 @@ export class Page extends ZenPage
}
}
- _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();
@@ -1041,7 +1318,7 @@ export class Page extends ZenPage
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));
+ 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);
diff --git a/src/zenserver/frontend/html/pages/oplog.js b/src/zenserver/frontend/html/pages/oplog.js
index 879fc4c97..e68a20813 100644
--- a/src/zenserver/frontend/html/pages/oplog.js
+++ b/src/zenserver/frontend/html/pages/oplog.js
@@ -33,6 +33,7 @@ export class Page extends ZenPage
this.set_title("oplog - " + oplog);
var section = this.add_section(project + " - " + oplog);
+ this.add_view_links(section, "list");
oplog_info = await oplog_info;
this._index_max = oplog_info["opcount"];
@@ -72,13 +73,6 @@ export class Page extends ZenPage
left.add(count).on_click(handler, count);
}
- left.sep();
- left.add("tree").link("", {
- "page" : "tree",
- "project" : this.get_param("project"),
- "oplog" : this.get_param("oplog"),
- });
-
const right = nav.right();
right.add(Friendly.sep(oplog_info["opcount"]));
right.add("(" + Friendly.kib(oplog_info["totalsize"]) + ")");
diff --git a/src/zenserver/frontend/html/pages/page.js b/src/zenserver/frontend/html/pages/page.js
index 9a9541904..086603f91 100644
--- a/src/zenserver/frontend/html/pages/page.js
+++ b/src/zenserver/frontend/html/pages/page.js
@@ -95,6 +95,23 @@ export class ZenPage extends PageBase
super.set_title(...args);
}
+ add_view_links(section, current)
+ {
+ const links = section.header().tag().classify("zen_view_nav");
+ for (const view of ["list", "tree", "graph"])
+ {
+ const item = links.tag();
+ item.text(view);
+ if (view === current)
+ continue;
+ item.link("", {
+ "page" : (view === "list") ? "oplog" : view,
+ "project" : this.get_param("project"),
+ "oplog" : this.get_param("oplog"),
+ });
+ }
+ }
+
generate_crumbs()
{
const auto_name = this.get_param("page") || "start";
diff --git a/src/zenserver/frontend/html/pages/tree.js b/src/zenserver/frontend/html/pages/tree.js
index 08a578492..31d303477 100644
--- a/src/zenserver/frontend/html/pages/tree.js
+++ b/src/zenserver/frontend/html/pages/tree.js
@@ -20,6 +20,7 @@ export class Page extends ZenPage
this.set_title("tree - " + oplog);
const section = this.add_section(project + " - " + oplog);
+ this.add_view_links(section, "tree");
this._create_tree(section);
this._expand(this._root);
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)
diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css
index cc9cd30e8..ad64bef85 100644
--- a/src/zenserver/frontend/html/zen.css
+++ b/src/zenserver/frontend/html/zen.css
@@ -104,8 +104,6 @@ a {
h1 {
font-size: 1.5em;
- width: 100%;
- border-bottom: 1px solid var(--theme_g2);
}
h2 {
@@ -122,6 +120,23 @@ a {
font-weight: normal;
}
+ .zen_section_header {
+ display: flex;
+ align-items: baseline;
+ > h1, > h2, > h3 {
+ margin-top: 0;
+ }
+ &:has(> h1) {
+ border-bottom: 1px solid var(--theme_g2);
+ }
+ }
+
+ .zen_view_nav {
+ margin-left: auto;
+ display: flex;
+ gap: 0.7em;
+ }
+
margin-bottom: 3em;
> *:not(h1) {
margin-left: 2em;
@@ -205,6 +220,34 @@ a {
}
}
+/* legend toggle ------------------------------------------------------------ */
+
+.legend_toggle {
+ cursor: pointer;
+ user-select: none;
+ &:hover {
+ text-decoration: underline;
+ }
+ &.legend_disabled {
+ opacity: 0.3;
+ }
+}
+
+.legend_scale {
+ position: relative;
+ display: inline-flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 6em;
+ padding: 0 0.2em;
+ height: 1.1em;
+ vertical-align: middle;
+ .legend_scale_lo, .legend_scale_hi {
+ font-size: 0.8em;
+ text-shadow: 0 0 3px var(--theme_g4), 0 0 3px var(--theme_g4);
+ }
+}
+
/* modal -------------------------------------------------------------------- */
@@ -432,6 +475,86 @@ a {
text-align: right;
}
}
+ h3:has(.zen_minigraph_toggle) {
+ display: flex;
+ align-items: center;
+ }
+ .zen_minigraph_toggle {
+ display: flex;
+ gap: 1em;
+ margin-left: auto;
+ font-size: 0.9em;
+ .active {
+ font-weight: bold;
+ text-decoration: underline;
+ }
+ }
+ .zen_minigraph {
+ position: relative;
+ display: flex;
+ height: 20em;
+ canvas {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid var(--theme_g2);
+ cursor: grab;
+ }
+ canvas:active {
+ cursor: grabbing;
+ }
+ .minigraph_splitter {
+ width: 4px;
+ cursor: col-resize;
+ background-color: var(--theme_g2);
+ flex-shrink: 0;
+ &:hover, &.active {
+ background-color: var(--theme_p1);
+ }
+ }
+ .minigraph_props {
+ width: 18em;
+ flex-shrink: 0;
+ border: 1px solid var(--theme_g2);
+ border-left: none;
+ padding: 0.5em 0.6em;
+ font-size: 0.85em;
+ overflow-y: auto;
+ .minigraph_props_empty {
+ color: var(--theme_g1);
+ padding: 1em 0;
+ text-align: center;
+ }
+ .minigraph_props_row {
+ display: flex;
+ padding: 0.15em 0;
+ }
+ .minigraph_props_label {
+ color: var(--theme_g1);
+ min-width: 8em;
+ }
+ .minigraph_props_row > span:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+ .minigraph_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;
+ }
+ }
}
/* tree --------------------------------------------------------------------- */
@@ -561,11 +684,39 @@ html:has(#graph) {
.graph_entry_leaf {
color: var(--theme_ln);
}
+ .graph_entry_active {
+ color: var(--theme_p0);
+ }
.graph_entries_more {
color: var(--theme_g1);
cursor: default;
}
}
+ #graph_props {
+ border-top: 1px solid var(--theme_g2);
+ padding: 0.5em 0.6em;
+ font-size: 0.85em;
+ overflow-y: auto;
+ min-height: 6em;
+ .graph_props_empty {
+ color: var(--theme_g1);
+ padding: 1em 0;
+ text-align: center;
+ }
+ .graph_props_row {
+ display: flex;
+ padding: 0.15em 0;
+ }
+ .graph_props_label {
+ color: var(--theme_g1);
+ min-width: 8em;
+ }
+ .graph_props_row > span:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
}
#graph_search_results {
position: absolute;