diff options
| author | MtBntChvn <[email protected]> | 2026-02-18 23:37:21 +0000 |
|---|---|---|
| committer | MtBntChvn <[email protected]> | 2026-02-18 23:37:21 +0000 |
| commit | fad55e1fb31e383dcffb6d0f0f331f639d235deb (patch) | |
| tree | 4ee87ede78cea0eeb33663196344c31d5a742a6d /src/zenserver/frontend/html | |
| parent | add interactive dependency graph view to dashboard (diff) | |
| download | zen-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.js | 122 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/graph.js | 473 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/oplog.js | 8 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/page.js | 17 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/tree.js | 1 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/minigraph.js | 1084 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/widgets.js | 13 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 155 |
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; |