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/util | |
| 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/util')
| -rw-r--r-- | src/zenserver/frontend/html/util/minigraph.js | 1084 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/widgets.js | 13 |
2 files changed, 1095 insertions, 2 deletions
diff --git a/src/zenserver/frontend/html/util/minigraph.js b/src/zenserver/frontend/html/util/minigraph.js new file mode 100644 index 000000000..be7e1c713 --- /dev/null +++ b/src/zenserver/frontend/html/util/minigraph.js @@ -0,0 +1,1084 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +//////////////////////////////////////////////////////////////////////////////// +function css_var(name) +{ + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +const NODE_PAD = 20; +const NODE_H = 32; +const NODE_R = 6; +const DOT_SPACE = 10; +const MAX_VISIBLE_DEPS = 50; + +const _measure_canvas = document.createElement("canvas"); +const _measure_ctx = _measure_canvas.getContext("2d"); +_measure_ctx.font = "11px consolas, monospace"; + +function short_name(opkey) +{ + const parts = opkey.replace(/\/$/, "").split("/"); + return parts[parts.length - 1] || opkey; +} + +function measure_node_width(label) +{ + return _measure_ctx.measureText(label).width + NODE_PAD * 2 + DOT_SPACE; +} + +function lerp_color(a, b, t) +{ + const pa = [parseInt(a.slice(1,3),16), parseInt(a.slice(3,5),16), parseInt(a.slice(5,7),16)]; + const pb = [parseInt(b.slice(1,3),16), parseInt(b.slice(3,5),16), parseInt(b.slice(5,7),16)]; + const r = Math.round(pa[0] + (pb[0] - pa[0]) * t); + const g = Math.round(pa[1] + (pb[1] - pa[1]) * t); + const bl = Math.round(pa[2] + (pb[2] - pa[2]) * t); + return "rgb(" + r + "," + g + "," + bl + ")"; +} + +//////////////////////////////////////////////////////////////////////////////// +function layout_step(nodes, edges, cx, cy) +{ + const n = nodes.length; + const repulsion = 80000 + n * 2000; + const spring_rest = 120 + n * 2; + const spring_k = 0.004; + const gravity = 0.01; + const damping = 0.85; + + for (var i = 0; i < nodes.length; ++i) + { + var fx = 0, fy = 0; + + for (var j = 0; j < nodes.length; ++j) + { + if (i == j) + continue; + var dx = nodes[i].x - nodes[j].x; + var dy = nodes[i].y - nodes[j].y; + var dist_sq = dx * dx + dy * dy; + if (dist_sq < 1) + dist_sq = 1; + var f = repulsion / dist_sq; + var dist = Math.sqrt(dist_sq); + fx += f * dx / dist; + fy += f * dy / dist; + } + + fx += (cx - nodes[i].x) * gravity; + fy += (cy - nodes[i].y) * gravity; + + nodes[i].fx = fx; + nodes[i].fy = fy; + } + + for (const edge of edges) + { + const a = edge.source; + const b = edge.target; + var dx = b.x - a.x; + var dy = b.y - a.y; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) + dist = 1; + var f = spring_k * (dist - spring_rest); + var fx = f * dx / dist; + var fy = f * dy / dist; + a.fx += fx; + a.fy += fy; + b.fx -= fx; + b.fy -= fy; + } + + for (const node of nodes) + { + if (node.pinned) + continue; + node.vx = (node.vx + node.fx) * damping; + node.vy = (node.vy + node.fy) * damping; + node.x += node.vx; + node.y += node.vy; + } +} + +function layout_run(nodes, edges, cx, cy, n) +{ + for (var i = 0; i < n; ++i) + layout_step(nodes, edges, cx, cy); +} + +//////////////////////////////////////////////////////////////////////////////// +export class MiniGraph +{ + constructor(canvas_el, prop_panel_el, splitter_el, legend_el, root_opkey, root_label, deps, size_map, on_navigate, on_expand) + { + this._canvas = canvas_el; + this._ctx = canvas_el.getContext("2d"); + this._prop_panel = prop_panel_el; + this._size_map = size_map || {}; + this._on_navigate = on_navigate; + this._on_expand = on_expand; + this._resizing = false; + this._hidden_dep_types = new Set(); + + this._nodes = []; + this._edges = []; + this._node_map = {}; + this._expanded = new Set(); + + this._transform = { x: 0, y: 0, scale: 1.0 }; + this._drag = null; + this._hover_node = null; + + this._init_colors(); + this._build_graph(root_opkey, root_label, deps); + this._build_legend(legend_el); + this._bind_events(); + this._bind_splitter(splitter_el); + this._fit_view(); + } + + _init_colors() + { + this._colors = { + p0: css_var("--theme_p0"), + p1: css_var("--theme_p1"), + p2: css_var("--theme_p2"), + p3: css_var("--theme_p3"), + p4: css_var("--theme_p4"), + g0: css_var("--theme_g0"), + g1: css_var("--theme_g1"), + g2: css_var("--theme_g2"), + g3: css_var("--theme_g3"), + g4: css_var("--theme_g4"), + }; + this._dep_colors = { + "imported": { color: this._colors.p0, dash: [] }, + "native": { color: "#496", dash: [] }, + "soft": { color: "#c84", dash: [6, 4] }, + }; + this._dep_default = { color: this._colors.g1, dash: [] }; + // compression ratio color stops (viridis): purple (good) → yellow (poor) + this._ratio_colors = ["#482878", "#2d708e", "#20a386", "#75d054", "#fde725"]; + } + + _build_legend(el) + { + if (!el) return; + + const make = (tag) => document.createElement(tag || "div"); + const swatch = (bg, w) => { + const s = make("span"); + s.className = "legend_swatch"; + if (w) s.style.width = w; + s.style.backgroundImage = bg; + return s; + }; + + const toggle_dep = (name, item) => { + if (this._hidden_dep_types.has(name)) + { + this._hidden_dep_types.delete(name); + item.classList.remove("legend_disabled"); + } + else + { + this._hidden_dep_types.add(name); + item.classList.add("legend_disabled"); + } + this._render(); + }; + + // edge type swatches (clickable toggles) + for (const name in this._dep_colors) + { + const dep = this._dep_colors[name]; + const item = make(); + item.className = "legend_toggle"; + if (dep.dash.length) + item.appendChild(swatch("repeating-linear-gradient(90deg, " + dep.color + " 0 6px, transparent 6px 10px)")); + else + { + const s = swatch("none"); + s.style.backgroundColor = dep.color; + item.appendChild(s); + } + const label = make("span"); + label.textContent = name; + item.appendChild(label); + item.addEventListener("click", () => toggle_dep(name, item)); + el.appendChild(item); + } + + // "other" edge type (clickable toggle) + { + const item = make(); + item.className = "legend_toggle"; + const s = swatch("none"); + s.style.backgroundColor = this._colors.g1; + item.appendChild(s); + const label = make("span"); + label.textContent = "other"; + item.appendChild(label); + item.addEventListener("click", () => toggle_dep("$other", item)); + el.appendChild(item); + } + + // separator + { + const sep = make("span"); + sep.className = "zen_toolbar_sep"; + sep.textContent = "|"; + el.appendChild(sep); + } + + // compression ratio gradient + { + const item = make(); + const label = make("span"); + label.textContent = "compression:"; + item.appendChild(label); + const scale = make("span"); + scale.className = "legend_scale"; + const stops = [...this._ratio_colors].reverse(); + scale.style.backgroundImage = + "linear-gradient(90deg, " + stops.join(", ") + ")"; + const lo = make("span"); + lo.className = "legend_scale_lo"; + lo.textContent = "low"; + scale.appendChild(lo); + const hi = make("span"); + hi.className = "legend_scale_hi"; + hi.textContent = "high"; + scale.appendChild(hi); + item.appendChild(scale); + el.appendChild(item); + } + } + + _is_edge_hidden(edge) + { + const dep_type = edge.dep_type; + if (this._dep_colors[dep_type]) + return this._hidden_dep_types.has(dep_type); + return this._hidden_dep_types.has("$other"); + } + + _add_node(opkey, x, y, is_root) + { + if (this._node_map[opkey]) + return this._node_map[opkey]; + + const label = short_name(opkey); + const pad = is_root ? NODE_PAD * 3 : 0; + const node = { + opkey: opkey, + label: label, + w: measure_node_width(label) + pad, + h: NODE_H + (is_root ? 10 : 0), + x: x, y: y, + vx: 0, vy: 0, + fx: 0, fy: 0, + is_root: is_root || false, + expanded: false, + pinned: is_root || false, + dep_count: 0, + truncated: false, + }; + this._nodes.push(node); + this._node_map[opkey] = node; + return node; + } + + _add_edge(source, target, dep_type) + { + for (const e of this._edges) + if (e.source === source && e.target === target) + return; + this._edges.push({ source: source, target: target, dep_type: dep_type }); + } + + _build_graph(root_opkey, root_label, deps) + { + const cx = this._canvas.width / 2; + const cy = this._canvas.height / 2; + + // root node + const root = this._add_node(root_opkey, cx, cy, true); + root.expanded = true; + this._expanded.add(root_opkey); + + // count dep types + const dep_types = {}; + for (const dep of deps) + dep_types[dep.dep_type] = (dep_types[dep.dep_type] || 0) + 1; + root.dep_count = deps.length; + root.dep_types = dep_types; + + // dep nodes arranged in a circle + const count = deps.length; + const radius = 150 + count * 3; + for (var i = 0; i < count; ++i) + { + const dep = deps[i]; + const t = count > 1 ? i / (count - 1) : 0.5; + const angle = t * 2 * Math.PI; + const dep_node = this._add_node( + dep.opkey, + cx + Math.cos(angle) * radius, + cy + Math.sin(angle) * radius, + false + ); + dep_node.unresolved = dep.unresolved || false; + this._add_edge(root, dep_node, dep.dep_type); + } + + layout_run(this._nodes, this._edges, cx, cy, 200); + } + + async _expand_node(node) + { + if (this._expanded.has(node.opkey)) + return; + if (!this._on_expand) + return; + + this._expanded.add(node.opkey); + node.expanded = true; + + const deps = await this._on_expand(node.opkey); + if (!deps || deps.length == 0) + { + this._update_prop_panel(node); + this._render(); + return; + } + + // count dep types + const dep_types = {}; + var dep_total = 0; + for (const dep of deps) + { + dep_types[dep.dep_type] = (dep_types[dep.dep_type] || 0) + 1; + dep_total++; + } + node.dep_count = dep_total; + node.dep_types = dep_types; + + // determine arc: full circle for root, semi-circle for non-root + var arc_start, arc_span; + if (node.is_root) + { + arc_start = 0; + arc_span = 2 * Math.PI; + } + else + { + const root = this._nodes.find(n => n.is_root); + const root_angle = root + ? Math.atan2(node.y - root.y, node.x - root.x) + : 0; + arc_start = root_angle - Math.PI / 2; + arc_span = Math.PI; + } + + const visible = Math.min(deps.length, MAX_VISIBLE_DEPS); + const radius = 150 + visible * 3; + + var added = 0; + for (const dep of deps) + { + if (added >= MAX_VISIBLE_DEPS) + { + node.truncated = true; + break; + } + + const t = visible > 1 ? added / (visible - 1) : 0.5; + const angle = arc_start + t * arc_span; + const r = radius + Math.random() * 30; + const dep_node = this._add_node( + dep.opkey, + node.x + Math.cos(angle) * r, + node.y + Math.sin(angle) * r, + false + ); + dep_node.unresolved = dep.unresolved || false; + this._add_edge(node, dep_node, dep.dep_type); + added++; + } + + const cx = this._canvas.width / 2; + const cy = this._canvas.height / 2; + layout_run(this._nodes, this._edges, cx, cy, 200); + this._update_prop_panel(node); + this._render(); + } + + _node_color(node) + { + const sizes = this._size_map[node.opkey]; + if (!sizes || sizes.raw_size == 0n) + return this._colors.g1; + + const ratio = 1.0 - Number(sizes.size) / Number(sizes.raw_size); + const stops = this._ratio_colors; + const t = Math.max(0, Math.min(1, 1.0 - ratio)) * (stops.length - 1); + const i = Math.min(Math.floor(t), stops.length - 2); + return lerp_color(stops[i], stops[i + 1], t - i); + } + + // -- property panel ---- + + _update_prop_panel(node) + { + const el = this._prop_panel; + if (!el) return; + + if (!node) + { + el.innerHTML = '<div class="minigraph_props_empty">hover a node</div>'; + return; + } + + var html = ""; + + const row = (label, value) => { + html += '<div class="minigraph_props_row">' + + '<span class="minigraph_props_label">' + label + '</span>' + + '<span>' + value + '</span></div>'; + }; + + row("name", node.label); + row("path", node.opkey); + + const sizes = this._size_map[node.opkey]; + if (sizes) + { + row("size", _friendly_kib(sizes.size)); + row("raw size", _friendly_kib(sizes.raw_size)); + if (sizes.raw_size > 0n) + { + const pct = (100 * (1.0 - Number(sizes.size) / Number(sizes.raw_size))).toFixed(1); + row("compression", pct + "%"); + } + } + + // count incoming/outgoing edges + var incoming = 0, outgoing = 0; + for (const edge of this._edges) + { + if (edge.source === node) outgoing++; + if (edge.target === node) incoming++; + } + if (outgoing > 0 || node.dep_count > 0) + { + var dep_str = "" + (node.dep_count || outgoing); + if (node.dep_types) + { + const parts = []; + for (const t in node.dep_types) + parts.push(t + ": " + node.dep_types[t]); + dep_str += " (" + parts.join(", ") + ")"; + } + row("imports", dep_str); + } + if (incoming > 0) + row("imported by", incoming + " node" + (incoming > 1 ? "s" : "")); + + // depth from root + const depth = this._depth_to_root(node); + if (depth >= 0) + row("depth", depth); + + // status + const tags = []; + if (node.is_root) tags.push("root"); + if (node.expanded) tags.push("expanded"); + if (node.unresolved) tags.push("unresolved"); + if (node.truncated) tags.push("truncated"); + if (tags.length > 0) + row("status", tags.join(", ")); + + el.innerHTML = html; + } + + _depth_to_root(node) + { + const visited = new Set(); + var current = node; + var depth = 0; + while (current && !current.is_root) + { + if (visited.has(current)) return -1; + visited.add(current); + var parent = null; + for (const edge of this._edges) + { + if (edge.target === current) + { + parent = edge.source; + break; + } + } + current = parent; + depth++; + } + return current ? depth : -1; + } + + // -- splitter ---- + + _sync_canvas_size() + { + this._canvas.width = this._canvas.clientWidth; + this._canvas.height = this._canvas.clientHeight; + } + + _bind_splitter(splitter_el) + { + if (!splitter_el) return; + this._splitter = splitter_el; + + const on_mousemove = (e) => { + const dx = e.clientX - this._resize_start_x; + var new_w = this._resize_start_w - dx; + const container_w = this._canvas.parentElement.clientWidth; + new_w = Math.max(80, Math.min(new_w, container_w - 200)); + this._prop_panel.style.width = new_w + "px"; + this._sync_canvas_size(); + this._render(); + }; + + const on_mouseup = () => { + this._resizing = false; + this._splitter.classList.remove("active"); + document.removeEventListener("mousemove", on_mousemove); + document.removeEventListener("mouseup", on_mouseup); + }; + + this._splitter_mousedown_fn = (e) => { + e.preventDefault(); + this._resizing = true; + this._resize_start_x = e.clientX; + this._resize_start_w = this._prop_panel.offsetWidth; + this._splitter.classList.add("active"); + document.addEventListener("mousemove", on_mousemove); + document.addEventListener("mouseup", on_mouseup); + }; + + splitter_el.addEventListener("mousedown", this._splitter_mousedown_fn); + } + + // -- rendering ---- + + _render() + { + const ctx = this._ctx; + if (!ctx) return; + + const c = this._colors; + const canvas = this._canvas; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.save(); + ctx.translate(this._transform.x, this._transform.y); + ctx.scale(this._transform.scale, this._transform.scale); + + const rect_edge = (cx, cy, hw, hh, sx, sy) => { + var dx = sx - cx; + var dy = sy - cy; + if (dx == 0 && dy == 0) dx = 1; + const sx_t = hw / (Math.abs(dx) || 1); + const sy_t = hh / (Math.abs(dy) || 1); + const t = Math.min(sx_t, sy_t); + return [cx + dx * t, cy + dy * t]; + }; + + const draw_edge = (edge, color, width) => { + const dep = this._dep_colors[edge.dep_type] || this._dep_default; + const ec = color || dep.color; + const lw = width || 1.5; + ctx.strokeStyle = ec; + ctx.lineWidth = lw / this._transform.scale; + ctx.setLineDash(dep.dash.map(v => v / this._transform.scale)); + + const s = edge.source; + const t = edge.target; + const [sx, sy] = rect_edge(s.x, s.y, s.w / 2, s.h / 2, t.x, t.y); + const [tx, ty] = rect_edge(t.x, t.y, t.w / 2, t.h / 2, s.x, s.y); + + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(tx, ty); + ctx.stroke(); + + // arrowhead + const dx = tx - sx; + const dy = ty - sy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0) + { + const ux = dx / dist; + const uy = dy / dist; + const arrow_size = 8 / this._transform.scale; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(tx, ty); + ctx.lineTo(tx - ux * arrow_size - uy * arrow_size * 0.5, + ty - uy * arrow_size + ux * arrow_size * 0.5); + ctx.lineTo(tx - ux * arrow_size + uy * arrow_size * 0.5, + ty - uy * arrow_size - ux * arrow_size * 0.5); + ctx.closePath(); + ctx.fillStyle = ec; + ctx.fill(); + } + }; + + // draw edges — two passes: dim non-highlighted, then highlighted on top + const hover = this._hover_node; + const do_highlight = hover && !hover.is_root; + + // pass 1: non-highlighted edges + for (const edge of this._edges) + { + if (this._is_edge_hidden(edge)) + continue; + if (do_highlight && (edge.source === hover || edge.target === hover)) + continue; + if (do_highlight) + { + ctx.globalAlpha = 0.2; + draw_edge(edge); + ctx.globalAlpha = 1.0; + } + else + draw_edge(edge); + } + + // pass 2: highlighted edges for hover node + if (do_highlight) + { + const path_edges = new Set(); + const visited = new Set(); + const trace = (node) => { + if (visited.has(node)) return; + visited.add(node); + for (const edge of this._edges) + { + if (this._is_edge_hidden(edge)) + continue; + if (edge.target === node) + { + path_edges.add(edge); + trace(edge.source); + } + } + }; + trace(hover); + + for (const edge of this._edges) + if (edge.source === hover && !this._is_edge_hidden(edge)) + path_edges.add(edge); + + for (const edge of path_edges) + draw_edge(edge, c.p0, 3); + } + ctx.setLineDash([]); + + // draw nodes + const font_size = 11; + ctx.font = font_size + "px consolas, monospace"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + + const draw_node = (node) => { + const nw = node.w; + const nh = node.h; + const x = node.x - nw / 2; + const y = node.y - nh / 2; + + ctx.beginPath(); + ctx.roundRect(x, y, nw, nh, NODE_R); + + if (node === this._hover_node) + ctx.fillStyle = c.p2; + else if (node.is_root) + ctx.fillStyle = c.p0; + else if (node.unresolved) + ctx.fillStyle = c.g3; + else + ctx.fillStyle = c.g3; + ctx.fill(); + + const node_sizes = this._size_map[node.opkey]; + const has_dot = !node.is_root && !node.unresolved && node_sizes && node_sizes.raw_size > 0n; + const text_x = has_dot ? node.x + DOT_SPACE / 2 : node.x; + + ctx.fillStyle = (node.is_root || node === this._hover_node) ? c.g4 : c.g0; + if (node.unresolved) + ctx.fillStyle = c.g1; + ctx.fillText(node.label, text_x, node.y); + + // compression ratio dot + if (has_dot) + { + const dot_r = 4; + const label_w = _measure_ctx.measureText(node.label).width; + const dot_x = text_x - label_w / 2 - dot_r - 4; + ctx.beginPath(); + ctx.arc(dot_x, node.y, dot_r, 0, Math.PI * 2); + ctx.fillStyle = this._node_color(node); + ctx.fill(); + } + + // truncated indicator + if (node.truncated) + { + const extra = node.dep_count - MAX_VISIBLE_DEPS; + ctx.fillStyle = c.g1; + ctx.font = (font_size * 0.8) + "px consolas, monospace"; + ctx.fillText("+" + extra + " more", node.x, node.y + node.h / 2 + font_size * 0.7); + ctx.font = font_size + "px consolas, monospace"; + } + }; + + // pass 1: leaf nodes + for (const node of this._nodes) + if (!node.is_root && !node.expanded && node !== this._hover_node) + draw_node(node); + + // pass 2: expanded nodes + for (const node of this._nodes) + if (node.expanded && !node.is_root && node !== this._hover_node) + draw_node(node); + + // pass 3: root + for (const node of this._nodes) + if (node.is_root) + draw_node(node); + + // pass 4: hovered node + if (this._hover_node && this._transform.scale >= 0.6) + draw_node(this._hover_node); + + ctx.restore(); + + // zoomed-out hover: draw in screen space at readable size + if (this._hover_node && this._transform.scale < 0.6) + { + const node = this._hover_node; + const sx = node.x * this._transform.scale + this._transform.x; + const sy = node.y * this._transform.scale + this._transform.y; + + ctx.font = "11px consolas, monospace"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + const tw = ctx.measureText(node.label).width + NODE_PAD * 2; + const th = NODE_H; + ctx.beginPath(); + ctx.roundRect(sx - tw / 2, sy - th / 2, tw, th, NODE_R); + ctx.fillStyle = c.p2; + ctx.fill(); + ctx.fillStyle = c.g4; + ctx.fillText(node.label, sx, sy); + } + + // tooltip + if (this._hover_node) + { + const node = this._hover_node; + ctx.font = "11px consolas, monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + + const lines = [node.opkey]; + const sizes = this._size_map[node.opkey]; + if (sizes) + { + var size_line = "size: " + _friendly_kib(sizes.size) + + " raw: " + _friendly_kib(sizes.raw_size); + if (sizes.raw_size > 0n) + { + const pct = (100 * (1.0 - Number(sizes.size) / Number(sizes.raw_size))).toFixed(0); + size_line += " (" + pct + "% compressed)"; + } + lines.push(size_line); + } + if (node.dep_count > 0) + { + var dep_str = "deps: " + node.dep_count; + if (node.dep_types) + { + const parts = []; + for (const t in node.dep_types) + parts.push(t + ": " + node.dep_types[t]); + dep_str += " (" + parts.join(", ") + ")"; + } + lines.push(dep_str); + } + else if (node.expanded) + lines.push("deps: none"); + if (node.unresolved) lines.push("[unresolved]"); + + const line_h = 15; + const pad = 6; + var tw = 0; + for (const line of lines) + { + const w = ctx.measureText(line).width; + if (w > tw) tw = w; + } + tw += pad * 2; + const th = lines.length * line_h + pad * 2; + + const node_sx = node.x * this._transform.scale + this._transform.x; + const node_sy = node.y * this._transform.scale + this._transform.y; + const node_sh = node.h * this._transform.scale; + var tx = node_sx - tw / 2; + var ty = node_sy + node_sh / 2 + 16; + + if (tx < 4) tx = 4; + if (tx + tw > canvas.width - 4) tx = canvas.width - tw - 4; + if (ty + th > canvas.height - 4) ty = node_sy - node_sh / 2 - th - 16; + + ctx.shadowColor = "rgba(0,0,0,0.3)"; + ctx.shadowBlur = 8; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + ctx.fillStyle = c.g3; + ctx.fillRect(tx, ty, tw, th); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.strokeStyle = c.g2; + ctx.lineWidth = 1; + ctx.strokeRect(tx, ty, tw, th); + + ctx.fillStyle = c.g0; + for (var i = 0; i < lines.length; ++i) + ctx.fillText(lines[i], tx + pad, ty + pad + i * line_h); + } + + // empty state + if (this._nodes.length == 0) + { + ctx.fillStyle = c.g1; + ctx.font = "14px consolas, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("no dependencies", canvas.width / 2, canvas.height / 2); + } + } + + // -- hit testing ---- + + _hit_test(mx, my) + { + const gx = (mx - this._transform.x) / this._transform.scale; + const gy = (my - this._transform.y) / this._transform.scale; + + for (var i = this._nodes.length - 1; i >= 0; --i) + { + const n = this._nodes[i]; + const hw = n.w / 2, hh = n.h / 2; + if (gx >= n.x - hw && gx <= n.x + hw && + gy >= n.y - hh && gy <= n.y + hh) + return n; + } + return null; + } + + // -- mouse interaction ---- + + _bind_events() + { + this._on_mousedown_fn = (e) => this._on_mousedown(e); + this._on_mousemove_fn = (e) => this._on_mousemove(e); + this._on_mouseup_fn = (e) => this._on_mouseup(e); + this._on_dblclick_fn = (e) => this._on_dblclick(e); + this._on_wheel_fn = (e) => this._on_wheel(e); + + this._canvas.addEventListener("mousedown", this._on_mousedown_fn); + this._canvas.addEventListener("mousemove", this._on_mousemove_fn); + this._canvas.addEventListener("mouseup", this._on_mouseup_fn); + this._canvas.addEventListener("mouseleave", this._on_mouseup_fn); + this._canvas.addEventListener("dblclick", this._on_dblclick_fn); + this._canvas.addEventListener("wheel", this._on_wheel_fn, { passive: false }); + } + + _on_mousedown(e) + { + if (this._resizing) return; + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + + if (node) + { + this._drag = { + type: "node", + node: node, + start_x: mx, + start_y: my, + node_start_x: node.x, + node_start_y: node.y, + moved: false, + }; + node.pinned = true; + } + else + { + this._drag = { + type: "pan", + start_x: mx, + start_y: my, + tx: this._transform.x, + ty: this._transform.y, + }; + } + } + + _on_mousemove(e) + { + if (this._resizing) return; + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + if (this._drag) + { + const dx = mx - this._drag.start_x; + const dy = my - this._drag.start_y; + + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) + this._drag.moved = true; + + if (this._drag.type == "pan") + { + this._transform.x = this._drag.tx + dx; + this._transform.y = this._drag.ty + dy; + this._render(); + } + else if (this._drag.type == "node") + { + const node = this._drag.node; + node.x = this._drag.node_start_x + dx / this._transform.scale; + node.y = this._drag.node_start_y + dy / this._transform.scale; + this._render(); + } + return; + } + + const node = this._hit_test(mx, my); + if (node !== this._hover_node) + { + this._hover_node = node; + this._canvas.style.cursor = node ? "pointer" : "grab"; + this._update_prop_panel(node); + this._render(); + } + } + + _on_mouseup(e) + { + if (!this._drag) + return; + + const drag = this._drag; + this._drag = null; + + if (drag.type == "node" && !drag.moved) + { + const node = drag.node; + if (node.opkey && !node.expanded && !node.unresolved) + this._expand_node(node); + } + } + + _on_dblclick(e) + { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = this._hit_test(mx, my); + + if (node && node.opkey && this._on_navigate) + this._on_navigate(node.opkey); + } + + _on_wheel(e) + { + e.preventDefault(); + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const old_scale = this._transform.scale; + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + var new_scale = old_scale * factor; + new_scale = Math.max(0.05, Math.min(4.0, new_scale)); + + this._transform.x = mx - (mx - this._transform.x) * (new_scale / old_scale); + this._transform.y = my - (my - this._transform.y) * (new_scale / old_scale); + this._transform.scale = new_scale; + this._render(); + } + + _fit_view() + { + if (this._nodes.length == 0) + return; + + var min_x = Infinity, min_y = Infinity; + var max_x = -Infinity, max_y = -Infinity; + for (const n of this._nodes) + { + const hw = n.w / 2, hh = n.h / 2; + if (n.x - hw < min_x) min_x = n.x - hw; + if (n.y - hh < min_y) min_y = n.y - hh; + if (n.x + hw > max_x) max_x = n.x + hw; + if (n.y + hh > max_y) max_y = n.y + hh; + } + + const pad = 40; + const w = max_x - min_x + pad * 2; + const h = max_y - min_y + pad * 2; + const scale = Math.min(this._canvas.width / w, this._canvas.height / h, 4.0); + const cx = (min_x + max_x) / 2; + const cy = (min_y + max_y) / 2; + + this._transform.scale = Math.max(scale, 0.2); + this._transform.x = this._canvas.width / 2 - cx * this._transform.scale; + this._transform.y = this._canvas.height / 2 - cy * this._transform.scale; + this._render(); + } + + destroy() + { + this._canvas.removeEventListener("mousedown", this._on_mousedown_fn); + this._canvas.removeEventListener("mousemove", this._on_mousemove_fn); + this._canvas.removeEventListener("mouseup", this._on_mouseup_fn); + this._canvas.removeEventListener("mouseleave", this._on_mouseup_fn); + this._canvas.removeEventListener("dblclick", this._on_dblclick_fn); + this._canvas.removeEventListener("wheel", this._on_wheel_fn); + if (this._splitter) + this._splitter.removeEventListener("mousedown", this._splitter_mousedown_fn); + } +} + +//////////////////////////////////////////////////////////////////////////////// +function _friendly_kib(n) +{ + if (typeof n === "bigint") + n = Number(n); + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB"; + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MiB"; + return (n / (1024 * 1024 * 1024)).toFixed(2) + " GiB"; +} diff --git a/src/zenserver/frontend/html/util/widgets.js b/src/zenserver/frontend/html/util/widgets.js index 32a3f4d28..d96299a0b 100644 --- a/src/zenserver/frontend/html/util/widgets.js +++ b/src/zenserver/frontend/html/util/widgets.js @@ -276,8 +276,17 @@ export class WidgetHost if (this._depth == 1) node.classify("zen_sector"); - node.tag("h" + this._depth).text(name); - return new WidgetHost(node, this._depth + 1); + var header = node.tag().classify("zen_section_header"); + header.tag("h" + this._depth).text(name); + + var host = new WidgetHost(node, this._depth + 1); + host._header = header; + return host; + } + + header() + { + return this._header; } add_widget(type, ...args) |