diff options
| author | MtBntChvn <[email protected]> | 2026-02-18 15:16:50 +0000 |
|---|---|---|
| committer | MtBntChvn <[email protected]> | 2026-02-18 15:16:50 +0000 |
| commit | 71044b95cd44c7b7fbbe4dfeb182c32c29deb685 (patch) | |
| tree | e7231945635e27490fa113ced45a629f1992bee0 /src/zenserver/frontend | |
| parent | add selective request logging support to http.sys (#762) (diff) | |
| download | zen-71044b95cd44c7b7fbbe4dfeb182c32c29deb685.tar.xz zen-71044b95cd44c7b7fbbe4dfeb182c32c29deb685.zip | |
add interactive dependency graph view to dashboard
Canvas-based force-directed graph for exploring package dependencies.
Nodes show short names with full path on hover tooltip (with size/dep info).
Supports pan, zoom, node drag, click-to-expand, double-click to promote
nodes to root, right-click to demote. Includes side panel with browsable
entry tree and filter. Linked from project page and entry detail page.
Diffstat (limited to 'src/zenserver/frontend')
| -rw-r--r-- | src/zenserver/frontend/html.zip | bin | 163229 -> 184943 bytes | |||
| -rw-r--r-- | src/zenserver/frontend/html/pages/entry.js | 8 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/graph.js | 1086 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/project.js | 1 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 97 |
5 files changed, 1192 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html.zip b/src/zenserver/frontend/html.zip Binary files differindex 5d33302dd..049a3c126 100644 --- a/src/zenserver/frontend/html.zip +++ b/src/zenserver/frontend/html.zip diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js index 08589b090..0e0dd1523 100644 --- a/src/zenserver/frontend/html/pages/entry.js +++ b/src/zenserver/frontend/html/pages/entry.js @@ -142,6 +142,14 @@ export class Page extends ZenPage const name = entry.find("key").as_value(); var section = this.add_section(name); + const nav = section.add_widget(Toolbar, true); + nav.left().add("graph").link("", { + "page": "graph", + "project": this.get_param("project"), + "oplog": this.get_param("oplog"), + "opkey": this.get_param("opkey"), + }); + // tree { var tree = entry.find("$tree"); diff --git a/src/zenserver/frontend/html/pages/graph.js b/src/zenserver/frontend/html/pages/graph.js new file mode 100644 index 000000000..2c8ab72b3 --- /dev/null +++ b/src/zenserver/frontend/html/pages/graph.js @@ -0,0 +1,1086 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Toolbar, ProgressBar } from "../util/widgets.js" +import { create_indexer } from "../indexer/indexer.js" + +//////////////////////////////////////////////////////////////////////////////// +function css_var(name) +{ + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +const NODE_PAD = 20; +const NODE_H = 32; +const NODE_R = 6; +const MAX_VISIBLE_DEPS = 50; + +// offscreen canvas for text measurement +const _measure_canvas = document.createElement("canvas"); +const _measure_ctx = _measure_canvas.getContext("2d"); +_measure_ctx.font = "11px consolas, monospace"; + +function short_name(opkey) +{ + const parts = opkey.replace(/\/$/, "").split("/"); + return parts[parts.length - 1] || opkey; +} + +function measure_node_width(label) +{ + return _measure_ctx.measureText(label).width + NODE_PAD * 2; +} + +//////////////////////////////////////////////////////////////////////////////// +function layout_step(nodes, edges, cx, cy) +{ + const n = nodes.length; + const repulsion = 80000 + n * 2000; + const spring_rest = 120 + n * 2; + const spring_k = 0.004; + const gravity = 0.01; + const damping = 0.85; + + for (var i = 0; i < nodes.length; ++i) + { + var fx = 0, fy = 0; + + // repulsion from all other nodes + for (var j = 0; j < nodes.length; ++j) + { + if (i == j) + continue; + var dx = nodes[i].x - nodes[j].x; + var dy = nodes[i].y - nodes[j].y; + var dist_sq = dx * dx + dy * dy; + if (dist_sq < 1) + dist_sq = 1; + var f = repulsion / dist_sq; + var dist = Math.sqrt(dist_sq); + fx += f * dx / dist; + fy += f * dy / dist; + } + + // gravity toward center + fx += (cx - nodes[i].x) * gravity; + fy += (cy - nodes[i].y) * gravity; + + nodes[i].fx = fx; + nodes[i].fy = fy; + } + + // spring attraction along edges + for (const edge of edges) + { + const a = edge.source; + const b = edge.target; + var dx = b.x - a.x; + var dy = b.y - a.y; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) + dist = 1; + var f = spring_k * (dist - spring_rest); + var fx = f * dx / dist; + var fy = f * dy / dist; + a.fx += fx; + a.fy += fy; + b.fx -= fx; + b.fy -= fy; + } + + // apply forces + for (const node of nodes) + { + if (node.pinned) + continue; + node.vx = (node.vx + node.fx) * damping; + node.vy = (node.vy + node.fy) * damping; + node.x += node.vx; + node.y += node.vy; + } +} + +function layout_run(nodes, edges, cx, cy, n) +{ + for (var i = 0; i < n; ++i) + layout_step(nodes, edges, cx, cy); +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + main() + { + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); + const opkey = this.get_param("opkey"); + + this.set_title("graph"); + + this._nodes = []; + this._edges = []; + this._node_map = {}; + this._tree_cache = {}; + this._expanded = new Set(); + + this._transform = { x: 0, y: 0, scale: 1.0 }; + this._drag = null; + this._hover_node = null; + + this._colors = { + p0: css_var("--theme_p0"), + p1: css_var("--theme_p1"), + p2: css_var("--theme_p2"), + p3: css_var("--theme_p3"), + p4: css_var("--theme_p4"), + g0: css_var("--theme_g0"), + g1: css_var("--theme_g1"), + g2: css_var("--theme_g2"), + g3: css_var("--theme_g3"), + g4: css_var("--theme_g4"), + }; + this._dep_colors = { + "imported": { color: this._colors.p0, dash: [] }, + "native": { color: "#496", dash: [] }, + "soft": { color: "#c84", dash: [6, 4] }, + }; + this._dep_default = { color: this._colors.g1, dash: [] }; + + this._indexer = this._load_indexer(project, oplog); + const section = this.add_section(project + " - " + oplog); + this._build(section, opkey); + } + + async _load_indexer(project, oplog) + { + const progress_bar = this.add_widget(ProgressBar); + progress_bar.set_progress("indexing"); + var indexer = create_indexer(project, oplog, (...args) => { + progress_bar.set_progress(...args); + }); + indexer = await indexer; + progress_bar.destroy(); + return indexer; + } + + async _build(section, opkey) + { + const indexer = await this._indexer; + + // build size lookup from indexer + this._size_map = {}; + for (const [name, size, raw_size] of indexer.enum_all()) + this._size_map[name] = { size: size, raw_size: raw_size }; + + // toolbar + const toolbar = section.add_widget(Toolbar); + const left = toolbar.left(); + const right = toolbar.right(); + + const search_wrap = left.add("", "div"); + search_wrap.inner().style.position = "relative"; + const search_input = search_wrap.tag("input"); + search_input.attr("type", "text"); + search_input.attr("placeholder", "search entry..."); + search_input.inner().style.width = "30em"; + + const dropdown = search_wrap.tag().id("graph_search_results"); + dropdown.inner().style.display = "none"; + + search_input.inner().addEventListener("input", (e) => { + this._on_search(e.target.value, dropdown); + }); + search_input.inner().addEventListener("focus", (e) => { + if (e.target.value.length > 0) + this._on_search(e.target.value, dropdown); + }); + document.addEventListener("click", (e) => { + if (!search_wrap.inner().contains(e.target)) + dropdown.inner().style.display = "none"; + }); + + right.add("fit").on_click(() => this._fit_view()); + right.add("reset").on_click(() => this._reset_graph()); + + this._search_input = search_input; + this._dropdown = dropdown; + + // canvas + entry list + const view = section.tag().id("graph_view"); + const canvas_el = view.tag("canvas").inner(); + this._canvas = canvas_el; + + // entry list panel + const panel = view.tag().id("graph_entries"); + const panel_filter = panel.tag("input"); + panel_filter.attr("type", "text"); + panel_filter.attr("placeholder", "filter..."); + const panel_list = panel.tag().id("graph_entries_list"); + this._populate_entries(panel_list, panel_filter); + + const resize = () => { + const rect = canvas_el.getBoundingClientRect(); + var h = window.visualViewport.height - rect.top - 50; + if (h < 300) h = 300; + canvas_el.style.height = h + "px"; + canvas_el.width = canvas_el.offsetWidth; + canvas_el.height = h; + panel.inner().style.height = h + "px"; + this._render(); + }; + resize(); + window.addEventListener("resize", resize); + + this._ctx = canvas_el.getContext("2d"); + + canvas_el.addEventListener("mousedown", (e) => this._on_mousedown(e)); + canvas_el.addEventListener("mousemove", (e) => this._on_mousemove(e)); + canvas_el.addEventListener("mouseup", (e) => this._on_mouseup(e)); + canvas_el.addEventListener("mouseleave", (e) => this._on_mouseup(e)); + canvas_el.addEventListener("dblclick", (e) => this._on_dblclick(e)); + canvas_el.addEventListener("contextmenu", (e) => this._on_contextmenu(e)); + canvas_el.addEventListener("wheel", (e) => this._on_wheel(e), { passive: false }); + + // legend + const legend = section.tag().id("graph_legend"); + for (const name in this._dep_colors) + { + const dep = this._dep_colors[name]; + const item = legend.tag(); + const swatch = item.tag("span"); + swatch.classify("legend_swatch"); + if (dep.dash.length) + { + swatch.inner().style.backgroundImage = + "repeating-linear-gradient(90deg, " + dep.color + " 0 6px, transparent 6px 10px)"; + } + else + swatch.inner().style.backgroundColor = dep.color; + item.tag("span").text(name); + } + { + const item = legend.tag(); + const swatch = item.tag("span"); + swatch.classify("legend_swatch"); + swatch.inner().style.backgroundColor = this._colors.g1; + item.tag("span").text("other"); + } + + if (opkey) + this._load_root(opkey); + } + + async _load_root(opkey) + { + const cx = this._canvas.width / 2; + const cy = this._canvas.height / 2; + + const node = this._add_node(opkey, cx, cy, true); + await this._expand_node(node); + } + + async _populate_entries(list, filter_input) + { + const indexer = await this._indexer; + const all_names = []; + for (const name of indexer.enum_names()) + all_names.push(name); + all_names.sort(); + this._all_names = all_names; + + filter_input.inner().addEventListener("input", (e) => { + const needle = e.target.value; + if (needle.length >= 2) + this._render_filtered_entries(list, needle); + else + this._render_tree_level(list, "/"); + }); + + this._render_tree_level(list, "/"); + } + + _render_tree_level(list, prefix) + { + list.inner().innerHTML = ""; + const children = {}; + for (const name of this._all_names) + { + if (!name.startsWith(prefix)) + continue; + var rest = name.substr(prefix.length); + const slash = rest.indexOf("/"); + if (slash != -1) + rest = rest.substr(0, slash + 1); + if (children[rest] === undefined) + children[rest] = 0; + children[rest]++; + } + + const sorted = Object.keys(children).sort((a, b) => { + const ad = a.endsWith("/"), bd = b.endsWith("/"); + if (ad != bd) return ad ? -1 : 1; + return a < b ? -1 : a > b ? 1 : 0; + }); + + for (const child of sorted) + { + const item = list.tag(); + const is_dir = child.endsWith("/"); + const display = is_dir ? child.slice(0, -1) + "/ (" + children[child] + ")" : child; + item.text(display); + + if (is_dir) + { + item.classify("graph_entry_dir"); + item.on("click", () => { + this._render_tree_level(list, prefix + child); + }); + } + else + { + const full_name = prefix + child; + item.classify("graph_entry_leaf"); + item.on("click", () => { + this._select_entry(full_name); + }); + } + } + + if (prefix != "/") + { + const back = list.inner().insertBefore( + document.createElement("div"), list.inner().firstChild); + back.textContent = ".."; + back.className = "graph_entry_dir"; + back.style.cursor = "pointer"; + const parent = prefix.slice(0, prefix.slice(0, -1).lastIndexOf("/") + 1) || "/"; + back.addEventListener("click", () => { + this._render_tree_level(list, parent); + }); + } + } + + _render_filtered_entries(list, needle) + { + list.inner().innerHTML = ""; + const lwr = needle.toLowerCase(); + var count = 0; + for (const name of this._all_names) + { + if (name.toLowerCase().indexOf(lwr) < 0) + continue; + if (count >= 200) + { + list.tag().text("...").classify("graph_entries_more"); + break; + } + const item = list.tag(); + item.text(name); + item.classify("graph_entry_leaf"); + item.on("click", () => { + this._select_entry(name); + }); + count++; + } + } + + _select_entry(name) + { + this._reset_graph(); + this.set_param("opkey", name); + this._search_input.inner().value = name; + this._load_root(name); + } + + _add_node(opkey, x, y, is_root) + { + if (this._node_map[opkey]) + return this._node_map[opkey]; + + const label = short_name(opkey); + const pad = is_root ? NODE_PAD * 3 : 0; + const node = { + opkey: opkey, + label: label, + w: measure_node_width(label) + pad, + h: NODE_H + (is_root ? 10 : 0), + x: x, y: y, + vx: 0, vy: 0, + fx: 0, fy: 0, + is_root: is_root || false, + expanded: false, + pinned: is_root || false, + dep_count: 0, + truncated: false, + }; + this._nodes.push(node); + this._node_map[opkey] = node; + return node; + } + + _add_edge(source, target, dep_type) + { + for (const e of this._edges) + if (e.source === source && e.target === target) + return; + this._edges.push({ source: source, target: target, dep_type: dep_type }); + } + + async _fetch_tree(opkey) + { + if (opkey in this._tree_cache) + return this._tree_cache[opkey]; + + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); + const cbo = await new Fetcher() + .resource("prj", project, "oplog", oplog, "entries") + .param("opkey", opkey) + .cbo(); + + if (!cbo) + { + this._tree_cache[opkey] = null; + return null; + } + + const entry_field = cbo.as_object().find("entry"); + if (!entry_field) + { + this._tree_cache[opkey] = null; + return null; + } + + const entry = entry_field.as_object(); + var tree = entry.find("$tree"); + if (tree != undefined) + tree = tree.as_object().to_js_object(); + else + tree = this._convert_legacy_to_tree(entry); + + if (!tree) + { + this._tree_cache[opkey] = null; + return null; + } + + delete tree["$id"]; + this._tree_cache[opkey] = tree; + return tree; + } + + _convert_legacy_to_tree(entry) + { + const raw_pkgst_entry = entry.find("packagestoreentry"); + if (raw_pkgst_entry == undefined) + return null; + + const tree = {}; + + const pkg_data = entry.find("packagedata"); + if (pkg_data) + { + var id = 0n; + for (var item of pkg_data.as_array()) + { + var pkg_id = item.as_object().find("id"); + if (pkg_id == undefined) + continue; + + pkg_id = pkg_id.as_value().subarray(0, 8); + for (var i = 7; i >= 0; --i) + { + id <<= 8n; + id |= BigInt(pkg_id[i]); + } + break; + } + tree["$id"] = id; + } + + const pkgst_entry = raw_pkgst_entry.as_object(); + for (const field of pkgst_entry) + { + const field_name = field.get_name(); + if (!field_name.endsWith("importedpackageids")) + continue; + + var dep_name = field_name.slice(0, -18); + if (dep_name.length == 0) + dep_name = "imported"; + + var out = tree[dep_name] = []; + for (var item of field.as_array()) + out.push(item.as_value(BigInt)); + } + + return tree; + } + + async _expand_node(node) + { + if (this._expanded.has(node.opkey)) + return; + + this._expanded.add(node.opkey); + node.expanded = true; + + const tree = await this._fetch_tree(node.opkey); + if (!tree) + { + this._render(); + return; + } + + const indexer = await this._indexer; + + var dep_total = 0; + const dep_types = {}; + for (const dep_name in tree) + { + const count = tree[dep_name].length; + dep_total += count; + dep_types[dep_name] = count; + } + node.dep_count = dep_total; + node.dep_types = dep_types; + + var added = 0; + const angle_step = (2 * Math.PI) / Math.min(dep_total, MAX_VISIBLE_DEPS); + var angle = 0; + + for (const dep_name in tree) + { + for (const dep_id of tree[dep_name]) + { + if (added >= MAX_VISIBLE_DEPS) + { + node.truncated = true; + break; + } + + var opkey = indexer.lookup_id(dep_id); + var is_unresolved = false; + if (!opkey) + { + opkey = "0x" + dep_id.toString(16).padStart(16, "0"); + is_unresolved = true; + } + + const radius = 150 + dep_total * 3 + Math.random() * 50; + const dx = node.x + Math.cos(angle) * radius; + const dy = node.y + Math.sin(angle) * radius; + const dep_node = this._add_node(opkey, dx, dy, false); + dep_node.unresolved = is_unresolved; + this._add_edge(node, dep_node, dep_name); + angle += angle_step; + added++; + } + if (node.truncated) + break; + } + + const cx = this._canvas.width / 2; + const cy = this._canvas.height / 2; + layout_run(this._nodes, this._edges, cx, cy, 200); + this._fit_view(); + } + + _reset_graph() + { + this._nodes = []; + this._edges = []; + this._node_map = {}; + this._expanded = new Set(); + this._transform = { x: 0, y: 0, scale: 1.0 }; + this._render(); + } + + _fit_view() + { + if (this._nodes.length == 0) + return; + + var min_x = Infinity, min_y = Infinity; + var max_x = -Infinity, max_y = -Infinity; + for (const n of this._nodes) + { + const hw = n.w / 2, hh = n.h / 2; + if (n.x - hw < min_x) min_x = n.x - hw; + if (n.y - hh < min_y) min_y = n.y - hh; + if (n.x + hw > max_x) max_x = n.x + hw; + if (n.y + hh > max_y) max_y = n.y + hh; + } + + const pad = 40; + const w = max_x - min_x + pad * 2; + const h = max_y - min_y + pad * 2; + const scale = Math.min(this._canvas.width / w, this._canvas.height / h, 4.0); + const cx = (min_x + max_x) / 2; + const cy = (min_y + max_y) / 2; + + this._transform.scale = Math.max(scale, 0.2); + this._transform.x = this._canvas.width / 2 - cx * this._transform.scale; + this._transform.y = this._canvas.height / 2 - cy * this._transform.scale; + this._render(); + } + + // -- rendering ---- + + _render() + { + const ctx = this._ctx; + if (!ctx) return; + + const c = this._colors; + const canvas = this._canvas; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.save(); + ctx.translate(this._transform.x, this._transform.y); + ctx.scale(this._transform.scale, this._transform.scale); + + // find where a line from (sx,sy) to the center of a rect intersects the rect border + const rect_edge = (cx, cy, hw, hh, sx, sy) => { + var dx = sx - cx; + var dy = sy - cy; + if (dx == 0 && dy == 0) dx = 1; + // scale so the point lies on the rect boundary + const sx_t = hw / (Math.abs(dx) || 1); + const sy_t = hh / (Math.abs(dy) || 1); + const t = Math.min(sx_t, sy_t); + return [cx + dx * t, cy + dy * t]; + }; + + // draw edges + for (const edge of this._edges) + { + const dep = this._dep_colors[edge.dep_type] || this._dep_default; + ctx.strokeStyle = dep.color; + ctx.lineWidth = 1.5 / this._transform.scale; + ctx.setLineDash(dep.dash.map(v => v / this._transform.scale)); + + const s = edge.source; + const t = edge.target; + const [sx, sy] = rect_edge(s.x, s.y, s.w / 2, s.h / 2, t.x, t.y); + const [tx, ty] = rect_edge(t.x, t.y, t.w / 2, t.h / 2, s.x, s.y); + + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(tx, ty); + ctx.stroke(); + + // arrowhead at target edge + const dx = tx - sx; + const dy = ty - sy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0) + { + const ux = dx / dist; + const uy = dy / dist; + const arrow_size = 8 / this._transform.scale; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(tx, ty); + ctx.lineTo(tx - ux * arrow_size - uy * arrow_size * 0.5, + ty - uy * arrow_size + ux * arrow_size * 0.5); + ctx.lineTo(tx - ux * arrow_size + uy * arrow_size * 0.5, + ty - uy * arrow_size - ux * arrow_size * 0.5); + ctx.closePath(); + ctx.fillStyle = dep.color; + ctx.fill(); + } + } + ctx.setLineDash([]); + + // draw nodes — two passes: leaf nodes first, then root/expanded on top + const font_size = 11; + ctx.font = font_size + "px consolas, monospace"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + + const draw_node = (node) => { + const nw = node.w; + const nh = node.h; + const x = node.x - nw / 2; + const y = node.y - nh / 2; + + ctx.beginPath(); + ctx.roundRect(x, y, nw, nh, NODE_R); + + if (node === this._hover_node) + ctx.fillStyle = c.p2; + else if (node.is_root) + ctx.fillStyle = c.p0; + else if (node.expanded) + ctx.fillStyle = c.p4; + else if (node.unresolved) + ctx.fillStyle = c.g3; + else + ctx.fillStyle = c.g3; + ctx.fill(); + + ctx.fillStyle = (node.is_root || node === this._hover_node) ? c.g4 : c.g0; + if (node.unresolved) + ctx.fillStyle = c.g1; + ctx.fillText(node.label, node.x, node.y); + + if (node.truncated) + { + const extra = node.dep_count - MAX_VISIBLE_DEPS; + ctx.fillStyle = c.g1; + ctx.font = (font_size * 0.8) + "px consolas, monospace"; + ctx.fillText("+" + extra + " more", node.x, node.y + node.h / 2 + font_size * 0.7); + ctx.font = font_size + "px consolas, monospace"; + } + }; + + // pass 1: leaf nodes (not root, not expanded) + for (const node of this._nodes) + if (!node.is_root && !node.expanded && node !== this._hover_node) + draw_node(node); + + // pass 2: expanded nodes + for (const node of this._nodes) + if (node.expanded && !node.is_root && node !== this._hover_node) + draw_node(node); + + // pass 3: root node (always on top) + for (const node of this._nodes) + if (node.is_root) + draw_node(node); + + // pass 4: hovered node (topmost) + if (this._hover_node && this._transform.scale >= 0.6) + draw_node(this._hover_node); + + ctx.restore(); + + // pass 4b: when zoomed out, draw hovered node in screen space at readable size + if (this._hover_node && this._transform.scale < 0.6) + { + const node = this._hover_node; + const sx = node.x * this._transform.scale + this._transform.x; + const sy = node.y * this._transform.scale + this._transform.y; + + ctx.font = "11px consolas, monospace"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + const tw = ctx.measureText(node.label).width + NODE_PAD * 2; + const th = NODE_H; + ctx.beginPath(); + ctx.roundRect(sx - tw / 2, sy - th / 2, tw, th, NODE_R); + ctx.fillStyle = c.p2; + ctx.fill(); + ctx.fillStyle = c.g4; + ctx.fillText(node.label, sx, sy); + } + + // tooltip for hover node + if (this._hover_node) + { + const node = this._hover_node; + ctx.font = "11px consolas, monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + + // build tooltip lines + const lines = [node.opkey]; + const sizes = this._size_map[node.opkey]; + if (sizes) + { + lines.push("size: " + Friendly.kib(sizes.size) + + " raw: " + Friendly.kib(sizes.raw_size)); + } + if (node.dep_count > 0) + { + var dep_str = "deps: " + node.dep_count; + if (node.dep_types) + { + const parts = []; + for (const t in node.dep_types) + parts.push(t + ": " + node.dep_types[t]); + dep_str += " (" + parts.join(", ") + ")"; + } + lines.push(dep_str); + } + else if (node.expanded) + lines.push("deps: none"); + + if (node.is_root) lines.push("[root]"); + if (node.unresolved) lines.push("[unresolved]"); + + // measure tooltip size + const line_h = 15; + const pad = 6; + var tw = 0; + for (const line of lines) + { + const w = ctx.measureText(line).width; + if (w > tw) tw = w; + } + tw += pad * 2; + const th = lines.length * line_h + pad * 2; + + // position: anchored below the node in screen space + const node_sx = node.x * this._transform.scale + this._transform.x; + const node_sy = node.y * this._transform.scale + this._transform.y; + const node_sh = node.h * this._transform.scale; + var tx = node_sx - tw / 2; + var ty = node_sy + node_sh / 2 + 16; + + // clamp to canvas bounds + if (tx < 4) tx = 4; + if (tx + tw > canvas.width - 4) tx = canvas.width - tw - 4; + if (ty + th > canvas.height - 4) ty = node_sy - node_sh / 2 - th - 16; + + ctx.shadowColor = "rgba(0,0,0,0.3)"; + ctx.shadowBlur = 8; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + ctx.fillStyle = c.g3; + ctx.fillRect(tx, ty, tw, th); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.strokeStyle = c.g2; + ctx.lineWidth = 1; + ctx.strokeRect(tx, ty, tw, th); + + ctx.fillStyle = c.g0; + for (var i = 0; i < lines.length; ++i) + ctx.fillText(lines[i], tx + pad, ty + pad + i * line_h); + } + + // empty state message + if (this._nodes.length == 0) + { + ctx.fillStyle = c.g1; + ctx.font = "14px consolas, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("search for an entry to view its dependency graph", + canvas.width / 2, canvas.height / 2); + } + } + + // -- hit testing ---- + + _hit_test(mx, my) + { + // convert screen coords to graph coords + const gx = (mx - this._transform.x) / this._transform.scale; + const gy = (my - this._transform.y) / this._transform.scale; + + for (var i = this._nodes.length - 1; i >= 0; --i) + { + const n = this._nodes[i]; + const hw = n.w / 2, hh = n.h / 2; + if (gx >= n.x - hw && gx <= n.x + hw && + gy >= n.y - hh && gy <= n.y + hh) + return n; + } + return null; + } + + // -- mouse interaction ---- + + _on_mousedown(e) + { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + + if (node) + { + this._drag = { + type: "node", + node: node, + start_x: mx, + start_y: my, + node_start_x: node.x, + node_start_y: node.y, + moved: false, + }; + node.pinned = true; + } + else + { + this._drag = { + type: "pan", + start_x: mx, + start_y: my, + tx: this._transform.x, + ty: this._transform.y, + }; + } + } + + _on_mousemove(e) + { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + if (this._drag) + { + const dx = mx - this._drag.start_x; + const dy = my - this._drag.start_y; + + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) + this._drag.moved = true; + + if (this._drag.type == "pan") + { + this._transform.x = this._drag.tx + dx; + this._transform.y = this._drag.ty + dy; + this._render(); + } + else if (this._drag.type == "node") + { + const node = this._drag.node; + node.x = this._drag.node_start_x + dx / this._transform.scale; + node.y = this._drag.node_start_y + dy / this._transform.scale; + this._render(); + } + return; + } + + // hover + const node = this._hit_test(mx, my); + if (node !== this._hover_node) + { + this._hover_node = node; + this._canvas.style.cursor = node ? "pointer" : "grab"; + this._hover_mouse = node ? { x: mx, y: my } : null; + this._render(); + } + else if (node) + { + this._hover_mouse = { x: mx, y: my }; + this._render(); + } + } + + _on_mouseup(e) + { + if (!this._drag) + return; + + const drag = this._drag; + this._drag = null; + + if (drag.type == "node" && !drag.moved) + { + // click on node - expand if not expanded + const node = drag.node; + if (!node.expanded && !node.unresolved) + this._expand_node(node); + } + } + + _on_dblclick(e) + { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + if (node && !node.unresolved) + this._promote_to_root(node); + } + + _promote_to_root(node) + { + node.is_root = true; + node.pinned = true; + // resize to root padding + const pad = NODE_PAD * 3; + node.w = measure_node_width(node.label) + pad; + node.h = NODE_H + 10; + if (!node.expanded) + this._expand_node(node); + else + this._render(); + } + + _on_contextmenu(e) + { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + if (node && node.is_root) + { + e.preventDefault(); + this._demote_from_root(node); + } + } + + _demote_from_root(node) + { + node.is_root = false; + node.pinned = false; + node.w = measure_node_width(node.label); + node.h = NODE_H; + this._render(); + } + + _on_wheel(e) + { + e.preventDefault(); + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const old_scale = this._transform.scale; + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + var new_scale = old_scale * factor; + new_scale = Math.max(0.2, Math.min(4.0, new_scale)); + + // zoom at cursor position + this._transform.x = mx - (mx - this._transform.x) * (new_scale / old_scale); + this._transform.y = my - (my - this._transform.y) * (new_scale / old_scale); + this._transform.scale = new_scale; + this._render(); + } + + // -- search ---- + + async _on_search(needle, dropdown) + { + const el = dropdown.inner(); + + if (needle.length < 2) + { + el.style.display = "none"; + return; + } + + const indexer = await this._indexer; + el.innerHTML = ""; + var count = 0; + for (const name of indexer.search(needle)) + { + if (count >= 30) + break; + const item = dropdown.tag(); + item.text(name); + item.on("click", () => { + el.style.display = "none"; + this._search_input.inner().value = name; + this._reset_graph(); + this.set_param("opkey", name); + this._load_root(name); + }); + count++; + } + + el.style.display = count > 0 ? "block" : "none"; + } +} diff --git a/src/zenserver/frontend/html/pages/project.js b/src/zenserver/frontend/html/pages/project.js index 42ae30c8c..3ae2d6034 100644 --- a/src/zenserver/frontend/html/pages/project.js +++ b/src/zenserver/frontend/html/pages/project.js @@ -55,6 +55,7 @@ export class Page extends ZenPage const action_tb = new Toolbar(cell, true).left(); this.as_link(action_tb.add("list"), "oplog", name); this.as_link(action_tb.add("tree"), "tree", name); + this.as_link(action_tb.add("graph"), "graph", name); action_tb.add("drop").on_click((x) => this.drop_oplog(x), name); info = await info; diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index cc53c0519..cc9cd30e8 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -503,3 +503,100 @@ html:has(#map) { } } } + +/* graph -------------------------------------------------------------------- */ + +html:has(#graph) { + height: 100%; + body, #container, #graph { + height: 100%; + } +} +#graph { + #graph_view { + position: relative; + display: flex; + gap: 0; + canvas { + flex: 1; + min-width: 0; + border: 1px solid var(--theme_g2); + cursor: grab; + } + canvas:active { + cursor: grabbing; + } + } + #graph_entries { + width: 22em; + border: 1px solid var(--theme_g2); + border-left: none; + display: flex; + flex-direction: column; + overflow: hidden; + > input { + width: 100%; + border: none; + border-bottom: 1px solid var(--theme_g2); + padding: 0.4em 0.6em; + font-size: 0.9em; + } + #graph_entries_list { + overflow-y: auto; + flex: 1; + > div { + padding: 0.2em 0.6em; + cursor: pointer; + font-size: 0.85em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + > div:hover { + background-color: var(--theme_p4); + } + .graph_entry_dir { + font-weight: bold; + } + .graph_entry_leaf { + color: var(--theme_ln); + } + .graph_entries_more { + color: var(--theme_g1); + cursor: default; + } + } + } + #graph_search_results { + position: absolute; + z-index: 1; + background-color: var(--theme_g4); + border: 1px solid var(--theme_g2); + max-height: 20em; + overflow-y: auto; + width: 30em; + > div { + padding: 0.3em 0.75em; + cursor: pointer; + } + > div:hover { + background-color: var(--theme_p4); + } + } + #graph_legend { + display: flex; + gap: 1.5em; + margin-top: 0.5em; + font-size: 0.85em; + > div { + display: flex; + align-items: center; + gap: 0.3em; + } + .legend_swatch { + width: 1.5em; + height: 0.3em; + display: inline-block; + } + } +} |