Morgan Lunt 8745968186
code-modernization: harden topology viewer and template injection
Fixes from an adversarial review of the new viewer:

- pin d3 to 7.9.0 and load it via dynamic import with an explicit
  error panel when the CDN is unreachable (previously a blocked CDN
  produced a silent dark page — a real concern for restricted networks)
- coerce ids/names/loc at intake: a single missing name or non-numeric
  loc previously threw inside the render loop or propagated NaN through
  the pack layout, blanking the canvas with no error
- normalize flows/steps/edges defensively (null entries, missing steps,
  numeric ids vs string lookups)
- mirror the level-of-detail reveal rule in the hit test so clicks
  can't select nodes that aren't drawn
- scope the Escape shortcut so clearing the search box doesn't reset
  the viewport; set zoom clickDistance(4) so trackpad jitter doesn't
  swallow selection clicks
- round canvas backing-store size (fractional devicePixelRatio caused
  a reallocation every frame on 125%/150% display scaling)
- modernize-map: use braced ${CLAUDE_PLUGIN_ROOT} so substitution
  actually happens, assert the injection marker exists in the template,
  and correct the documented failure mode
2026-06-09 08:48:04 -07:00

494 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>System topology</title>
<style>
:root {
--bg: #1e1e1e; --panel: #252526; --panel-2: #2d2d2d;
--text: #d4d4d4; --muted: #8a8a8a; --accent: #cc785c;
--border: #3a3a3a; --blue: #5ba0e0; --green: #6bb85a;
}
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; overflow: hidden; background: var(--bg);
color: var(--text); font: 14px/1.45 system-ui, sans-serif; }
#map { position: absolute; inset: 0; cursor: grab; }
#map.dragging { cursor: grabbing; }
#hud { position: absolute; top: 12px; left: 12px; width: 280px;
display: flex; flex-direction: column; gap: 8px; z-index: 2; }
.panel { background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 12px; }
.panel h1 { font-size: 15px; color: var(--accent); margin-bottom: 2px; }
.panel .sub { color: var(--muted); font-size: 12px; }
#search { width: 100%; padding: 6px 8px; border-radius: 6px;
border: 1px solid var(--border); background: var(--panel-2);
color: var(--text); font: inherit; outline: none; }
#search:focus { border-color: var(--accent); }
#results { max-height: 180px; overflow-y: auto; margin-top: 4px; }
#results div { padding: 4px 6px; border-radius: 4px; cursor: pointer;
font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#results div:hover { background: var(--panel-2); }
#results .kind { color: var(--muted); font-size: 11px; margin-left: 6px; }
.toggles label { display: flex; align-items: center; gap: 7px;
font-size: 13px; padding: 2px 0; cursor: pointer; }
.swatch { width: 14px; height: 3px; border-radius: 2px; display: inline-block; }
#flows select { width: 100%; margin-top: 4px; padding: 5px;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; font: inherit; }
#sidebar { position: absolute; top: 12px; right: 12px; bottom: 12px;
width: 320px; overflow-y: auto; z-index: 2; display: none; }
#sidebar.open { display: block; }
#sidebar h2 { font-size: 16px; word-break: break-word; }
#sidebar .meta { color: var(--muted); font-size: 12px; margin: 2px 0 10px; }
#sidebar h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); margin: 12px 0 4px; }
#sidebar .link { color: var(--blue); cursor: pointer; display: block;
padding: 2px 0; font-size: 13px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; }
#sidebar .link:hover { color: var(--accent); }
#sidebar ol { padding-left: 20px; } #sidebar ol li { margin: 6px 0; font-size: 13px; }
#sidebar .closebtn { float: right; cursor: pointer; color: var(--muted);
font-size: 18px; line-height: 1; } #sidebar .closebtn:hover { color: var(--text); }
.badge { display: inline-block; padding: 1px 7px; border-radius: 9px;
font-size: 11px; border: 1px solid var(--border); color: var(--muted); margin-right: 4px; }
.badge.entry { border-color: var(--accent); color: var(--accent); }
#hint { position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
color: var(--muted); font-size: 12px; z-index: 2; background: var(--panel);
border: 1px solid var(--border); border-radius: 6px; padding: 4px 12px; }
#err { position: absolute; inset: 0; display: none; place-items: center; z-index: 9; }
</style>
</head>
<body>
<canvas id="map"></canvas>
<div id="hud">
<div class="panel">
<h1 id="title">System topology</h1>
<div class="sub" id="stats"></div>
</div>
<div class="panel">
<input id="search" type="search" placeholder="Search modules, stores, jobs…" autocomplete="off">
<div id="results"></div>
</div>
<div class="panel toggles" id="toggles"></div>
<div class="panel" id="flows" style="display:none">
<div class="sub">Business flow walkthrough</div>
<select id="flowSel"><option value="">— none —</option></select>
</div>
<details class="panel" id="obs" style="display:none">
<summary style="cursor:pointer;color:var(--muted);font-size:12px">Architect observations</summary>
<ul id="obsList" style="padding-left:18px;margin-top:6px;font-size:12.5px"></ul>
</details>
</div>
<aside id="sidebar" class="panel"></aside>
<div id="hint">scroll to zoom · drag to pan · click a node · double-click to zoom in · Esc to reset</div>
<div id="err" class="panel"><p>No topology data found in this file.<br>
Re-run <code>/modernize-map</code> to regenerate it.</p></div>
<script type="module">
function fail(html) {
const err = document.getElementById("err");
err.querySelector("p").innerHTML = html;
err.style.display = "grid";
}
// Version-pinned so the page renders the same bytes every time. On
// networks that block the CDN, vendor the file locally and point this
// import at it — everything else on the page is self-contained.
let d3;
try {
d3 = await import("https://cdn.jsdelivr.net/npm/d3@7.9.0/+esm");
} catch {
fail("Could not load the d3 library from <code>cdn.jsdelivr.net</code>.<br>" +
"This page needs one-time network access to that CDN. On a restricted " +
"network, download d3 locally and update the import at the top of the " +
"script in this file.");
throw new Error("d3 load failed");
}
// Injected by /modernize-map: the contents of topology.json replace the
// null after the marker comment. Schema documented in the command file.
const DATA = /*__TOPOLOGY_DATA__*/ null;
if (!DATA || !DATA.root) {
fail("No topology data found in this file.<br>Re-run <code>/modernize-map</code> to regenerate it.");
throw new Error("no data");
}
// ── Layout: circle-pack in a fixed world space ─────────────────────────
// d3.pack gives every node (x, y, r) once; zooming is purely a canvas
// transform, so the layout never recomputes and pan/zoom stays at 60fps.
const WORLD = 8000;
// Leaves without a meaningful LOC (data stores, screens, jobs) get a floor
// based on the median module size so they stay visible next to code.
// LLM-generated data is messy: coerce loc to a finite number everywhere
// (a single NaN propagates through pack() and blanks the whole canvas).
const numLoc = d => { const l = Number(d.loc); return Number.isFinite(l) && l > 0 ? l : 0; };
const locs = [];
(function walk(d) { if (d.children && d.children.length) d.children.forEach(walk);
else if (numLoc(d) > 1) locs.push(numLoc(d)); })(DATA.root);
locs.sort((a, b) => a - b);
const FLOOR = Math.max(1, Math.round((locs[Math.floor(locs.length / 2)] || 100) * 0.4));
const root = d3.hierarchy(DATA.root, d => d.children)
.sum(d => (d.children && d.children.length ? 0 : Math.max(numLoc(d), d.kind === "module" ? 1 : FLOOR)))
.sort((a, b) => b.value - a.value);
d3.pack().size([WORLD, WORLD]).padding(d => Math.max(3, 24 / (d.depth + 1)))(root);
const nodes = [];
const byId = new Map();
root.each(d => {
const id = String(d.data.id ?? d.data.name ?? `n${nodes.length}`);
const n = { id, name: String(d.data.name ?? d.data.id ?? "?"),
kind: d.data.kind || "module",
language: d.data.language || null, file: d.data.file || null,
loc: numLoc(d.data), x: d.x, y: d.y, r: d.r, depth: d.depth,
parent: d.parent ? String(d.parent.data.id ?? "") : null,
container: !!(d.children && d.children.length) };
nodes.push(n); byId.set(n.id, n);
});
nodes.sort((a, b) => b.r - a.r); // painters: parents under children
const entrySet = new Set((DATA.entryPoints || []).map(String));
const edges = (DATA.edges || [])
.filter(e => e && byId.has(String(e.source)) && byId.has(String(e.target)))
.map(e => ({ source: String(e.source), target: String(e.target), kind: e.kind }));
const fanIn = new Map(), fanOut = new Map();
for (const e of edges) {
fanOut.set(e.source, (fanOut.get(e.source) || 0) + 1);
fanIn.set(e.target, (fanIn.get(e.target) || 0) + 1);
}
// ── Colors ──────────────────────────────────────────────────────────────
const KIND_FILL = { system: "#242424", domain: null, module: "#4a6580",
datastore: "#3d6a5a", job: "#7a5d8a", screen: "#8a6d4a" };
const LANG_FILL = { cobol: "#5e7a9e", java: "#9a6848", c: "#6e6a58",
"c++": "#6e6a58", python: "#5a7850", javascript: "#9a8348",
typescript: "#4a6580", csharp: "#5f7a5f", rpg: "#7d6580", pl1: "#806858",
natural: "#587a78", sql: "#3d6a5a" };
const EDGE_STYLE = {
call: { color: "rgba(201,182,144,0.30)", label: "calls" },
dispatch: { color: "rgba(160,130,200,0.35)", label: "dynamic dispatch" },
read: { color: "rgba(91,160,224,0.30)", label: "reads" },
write: { color: "rgba(224,122,82,0.40)", label: "writes" },
};
function fillFor(n) {
if (n.kind === "domain" || (n.container && n.kind !== "system"))
return n.depth % 2 ? "#2d2d2d" : "#262626";
if (n.kind !== "module" && KIND_FILL[n.kind]) return KIND_FILL[n.kind];
return (n.language && LANG_FILL[n.language.toLowerCase()]) || "#4a6580";
}
// ── State ───────────────────────────────────────────────────────────────
const canvas = document.getElementById("map");
const ctx = canvas.getContext("2d");
let transform = d3.zoomIdentity;
let hovered = null, selected = null, activeFlow = null;
const kindOn = Object.fromEntries(Object.keys(EDGE_STYLE).map(k => [k, true]));
// ── Render ──────────────────────────────────────────────────────────────
const MIN_R = 1.5, LABEL_R = 22, REVEAL_R = 55;
let raf = null;
const requestDraw = () => { if (!raf) raf = requestAnimationFrame(() => { raf = null; draw(); }); };
function visible(n, k, tx, ty, vw, vh) {
const sx = n.x * k + tx, sy = n.y * k + ty, sr = n.r * k;
return sx + sr > -60 && sx - sr < vw + 60 && sy + sr > -60 && sy - sr < vh + 60;
}
function flowNodeSet() {
if (!activeFlow) return null;
const s = new Set();
for (const step of activeFlow.steps) for (const id of step.nodes || []) s.add(id);
return s;
}
function draw() {
const dpr = window.devicePixelRatio || 1;
const vw = innerWidth, vh = innerHeight;
const bw = Math.round(vw * dpr), bh = Math.round(vh * dpr);
if (canvas.width !== bw) { canvas.width = bw; canvas.style.width = vw + "px"; }
if (canvas.height !== bh) { canvas.height = bh; canvas.style.height = vh + "px"; }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#1e1e1e"; ctx.fillRect(0, 0, vw, vh);
const { x: tx, y: ty, k } = transform;
const flowSet = flowNodeSet();
ctx.textAlign = "center"; ctx.textBaseline = "middle";
// Nodes
for (const n of nodes) {
const sr = n.r * k;
if (sr < MIN_R) continue;
if (n.parent) {
const p = byId.get(n.parent);
if (p && p.r * k < REVEAL_R) continue; // LOD: reveal children gradually
}
if (!visible(n, k, tx, ty, vw, vh)) continue;
const sx = n.x * k + tx, sy = n.y * k + ty;
const inFlow = flowSet ? flowSet.has(n.id) : true;
const isSel = selected === n.id, isHov = hovered === n.id;
ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2);
ctx.globalAlpha = (n.container ? 0.55 : 0.85) * (inFlow ? 1 : 0.18);
ctx.fillStyle = isSel ? "rgba(204,120,92,0.25)" : fillFor(n);
ctx.fill(); ctx.globalAlpha = 1;
ctx.lineWidth = isSel ? 2.5 : isHov ? 1.8 : n.container ? 1 : 0.8;
ctx.strokeStyle = isSel || isHov ? "#cc785c"
: entrySet.has(n.id) ? "rgba(204,120,92,0.9)"
: n.container ? "#3a3a3a" : "rgba(212,212,212,0.18)";
if (entrySet.has(n.id) && !isSel && !isHov) ctx.lineWidth = 1.6;
ctx.globalAlpha = inFlow ? 1 : 0.25; ctx.stroke(); ctx.globalAlpha = 1;
if (sr > LABEL_R) {
const fs = Math.min(17, Math.max(9, sr / 6));
ctx.font = `${n.container ? "600" : "400"} ${fs}px system-ui, sans-serif`;
ctx.fillStyle = n.container ? "#8a8a8a" : "#e8e8e8";
ctx.globalAlpha = inFlow ? 1 : 0.3;
const ly = n.container && sr > 55 ? sy - sr + fs + 5 : sy;
const label = n.name.length > 28 ? n.name.slice(0, 26) + "…" : n.name;
ctx.fillText(label, sx, ly);
ctx.globalAlpha = 1;
}
}
// Edges: batched per kind, endpoints must both be on screen
const vis = id => { const n = byId.get(id); return n && n.r * k > MIN_R * 2 && visible(n, k, tx, ty, vw, vh); };
const curve = (a, b) => {
const ax = a.x * k + tx, ay = a.y * k + ty, bx = b.x * k + tx, by = b.y * k + ty;
if ((ax - bx) ** 2 + (ay - by) ** 2 < 120) return;
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo((ax + bx) / 2 + (by - ay) * 0.12, (ay + by) / 2 + (ax - bx) * 0.12, bx, by);
};
if (!flowSet) {
for (const [kind, style] of Object.entries(EDGE_STYLE)) {
if (!kindOn[kind]) continue;
ctx.strokeStyle = style.color; ctx.lineWidth = 0.8; ctx.beginPath();
let drawn = 0;
for (const e of edges) {
if (e.kind !== kind || !vis(e.source) || !vis(e.target)) continue;
curve(byId.get(e.source), byId.get(e.target));
if (++drawn > 1200) break;
}
ctx.stroke();
}
}
// Focus edges (hover/selection) in accent, on top
const focus = hovered || selected;
if (focus && !flowSet) {
ctx.strokeStyle = "#cc785c"; ctx.lineWidth = 1.5; ctx.beginPath();
for (const e of edges) {
if (e.source !== focus && e.target !== focus) continue;
const a = byId.get(e.source), b = byId.get(e.target);
if (a && b) curve(a, b);
}
ctx.stroke();
}
// Active flow: numbered step path
if (flowSet) {
ctx.strokeStyle = "#cc785c"; ctx.lineWidth = 2;
const seq = activeFlow.steps.flatMap(s => s.nodes || []);
ctx.beginPath();
for (let i = 0; i + 1 < seq.length; i++) {
const a = byId.get(seq[i]), b = byId.get(seq[i + 1]);
if (a && b) curve(a, b);
}
ctx.stroke();
seq.forEach((id, i) => {
const n = byId.get(id); if (!n) return;
const sx = n.x * k + tx, sy = n.y * k + ty - n.r * k - 10;
ctx.beginPath(); ctx.arc(sx, sy, 9, 0, Math.PI * 2);
ctx.fillStyle = "#cc785c"; ctx.fill();
ctx.fillStyle = "#1e1e1e"; ctx.font = "700 11px system-ui, sans-serif";
ctx.fillText(String(i + 1), sx, sy + 0.5);
});
}
}
// ── Zoom / pan / hit-testing ────────────────────────────────────────────
const zoomB = d3.zoom().scaleExtent([0.05, 600]).clickDistance(4)
.on("zoom", ev => { transform = ev.transform; requestDraw(); })
.on("start", () => canvas.classList.add("dragging"))
.on("end", () => canvas.classList.remove("dragging"));
const sel = d3.select(canvas).call(zoomB).on("dblclick.zoom", null);
function hit(mx, my) {
const wx = (mx - transform.x) / transform.k, wy = (my - transform.y) / transform.k;
let best = null;
for (const n of nodes) {
if (n.r * transform.k < MIN_R) continue;
// Mirror the draw-time LOD rule so hidden nodes can't be clicked.
if (n.parent) {
const p = byId.get(n.parent);
if (p && p.r * transform.k < REVEAL_R) continue;
}
const dx = wx - n.x, dy = wy - n.y;
if (dx * dx + dy * dy <= n.r * n.r && (!best || n.r < best.r)) best = n;
}
return best;
}
function flyTo(n) {
const vw = innerWidth, vh = innerHeight, cur = transform;
const from = [(vw / 2 - cur.x) / cur.k, (vh / 2 - cur.y) / cur.k, Math.min(vw, vh) / cur.k];
const interp = d3.interpolateZoom(from, [n.x, n.y, n.r * 2.6]);
const dur = Math.max(400, interp.duration * 0.55), t0 = performance.now();
(function step() {
const t = Math.min(1, (performance.now() - t0) / dur);
const [cx, cy, w] = interp(d3.easeCubicInOut(t));
const k = Math.min(vw, vh) / w;
sel.call(zoomB.transform, d3.zoomIdentity.translate(vw / 2 - cx * k, vh / 2 - cy * k).scale(k));
if (t < 1) requestAnimationFrame(step);
})();
}
canvas.addEventListener("mousemove", e => {
const h = hit(e.offsetX, e.offsetY);
const id = h ? h.id : null;
if (id !== hovered) { hovered = id; canvas.style.cursor = h ? "pointer" : "grab"; requestDraw(); }
});
canvas.addEventListener("click", e => {
const h = hit(e.offsetX, e.offsetY);
selected = h ? h.id : null;
renderSidebar(); requestDraw();
});
canvas.addEventListener("dblclick", e => { const h = hit(e.offsetX, e.offsetY); if (h) flyTo(h); });
addEventListener("keydown", e => {
if (e.key !== "Escape") return;
// Escape inside the search box or flow select should only affect that
// control, not reset the whole viewport.
if (e.target === searchEl || (e.target && e.target.tagName === "SELECT")) return;
selected = null; setFlow(""); flyTo(byId.get(String(DATA.root.id))); renderSidebar();
});
addEventListener("resize", requestDraw);
// ── Sidebar ─────────────────────────────────────────────────────────────
const sidebar = document.getElementById("sidebar");
const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
function renderSidebar() {
if (activeFlow) return renderFlowSidebar();
if (!selected) { sidebar.classList.remove("open"); return; }
const n = byId.get(selected);
const neighbors = [];
for (const e of edges) {
if (e.source === n.id) neighbors.push({ id: e.target, rel: EDGE_STYLE[e.kind]?.label || e.kind, dir: "→" });
else if (e.target === n.id) neighbors.push({ id: e.source, rel: EDGE_STYLE[e.kind]?.label || e.kind, dir: "←" });
}
sidebar.innerHTML = `
<span class="closebtn" id="close">×</span>
<h2>${esc(n.name)}</h2>
<div class="meta">${esc(n.kind)}${n.language ? " · " + esc(n.language) : ""}${n.loc ? " · " + n.loc.toLocaleString() + " LOC" : ""}</div>
<div>${entrySet.has(n.id) ? '<span class="badge entry">entry point</span>' : ""}
<span class="badge">fan-in ${fanIn.get(n.id) || 0}</span>
<span class="badge">fan-out ${fanOut.get(n.id) || 0}</span></div>
${n.file ? `<h3>Source</h3><div class="meta">${esc(n.file)}</div>` : ""}
${neighbors.length ? `<h3>Connections (${neighbors.length})</h3>` +
neighbors.slice(0, 40).map(x => `<span class="link" data-id="${esc(x.id)}">${x.dir} ${esc(byId.get(x.id)?.name || x.id)} <span class="kind">${esc(x.rel)}</span></span>`).join("") +
(neighbors.length > 40 ? `<div class="meta">…and ${neighbors.length - 40} more</div>` : "") : ""}`;
sidebar.classList.add("open");
sidebar.querySelector("#close").onclick = () => { selected = null; renderSidebar(); requestDraw(); };
sidebar.querySelectorAll(".link").forEach(el => el.onclick = () => {
const t = byId.get(el.dataset.id);
if (t) { selected = t.id; flyTo(t); renderSidebar(); requestDraw(); }
});
}
function renderFlowSidebar() {
const f = activeFlow;
sidebar.innerHTML = `
<span class="closebtn" id="close">×</span>
<h2>${esc(f.name)}</h2>
<div class="meta">${f.persona ? "Persona: " + esc(f.persona) : ""}</div>
${f.description ? `<p style="font-size:13px">${esc(f.description)}</p>` : ""}
<h3>Steps</h3>
<ol>${f.steps.map(s => `<li>${esc(s.label)}${(s.nodes || []).length ?
`<div class="meta">${s.nodes.map(id => esc(byId.get(id)?.name || id)).join(" → ")}</div>` : ""}</li>`).join("")}</ol>`;
sidebar.classList.add("open");
sidebar.querySelector("#close").onclick = () => setFlow("");
}
// ── Search ──────────────────────────────────────────────────────────────
const searchEl = document.getElementById("search"), resultsEl = document.getElementById("results");
searchEl.addEventListener("input", () => {
const q = searchEl.value.trim().toLowerCase();
resultsEl.innerHTML = "";
if (q.length < 2) return;
nodes.filter(n => n.name.toLowerCase().includes(q)).slice(0, 12).forEach(n => {
const d = document.createElement("div");
d.innerHTML = `${esc(n.name)}<span class="kind">${esc(n.kind)}</span>`;
d.onclick = () => { selected = n.id; flyTo(n); renderSidebar(); requestDraw(); };
resultsEl.appendChild(d);
});
});
// ── Edge-kind toggles & legend ──────────────────────────────────────────
const togglesEl = document.getElementById("toggles");
for (const [kind, style] of Object.entries(EDGE_STYLE)) {
if (!edges.some(e => e.kind === kind)) continue;
const solid = style.color.replace(/[\d.]+\)$/, "0.9)");
const l = document.createElement("label");
l.innerHTML = `<input type="checkbox" checked><span class="swatch" style="background:${solid}"></span>${style.label}`;
l.querySelector("input").onchange = ev => { kindOn[kind] = ev.target.checked; requestDraw(); };
togglesEl.appendChild(l);
}
if (!togglesEl.children.length) togglesEl.style.display = "none";
// ── Flows ───────────────────────────────────────────────────────────────
const flowsPanel = document.getElementById("flows"), flowSel = document.getElementById("flowSel");
const flows = (DATA.flows || []).filter(Boolean).map(f => ({
...f,
steps: (Array.isArray(f.steps) ? f.steps : []).map(s => ({
label: String(s?.label ?? ""), nodes: (s?.nodes || []).map(String) })),
}));
if (flows.length) {
flowsPanel.style.display = "";
flows.forEach((f, i) => {
const o = document.createElement("option");
o.value = i; o.textContent = f.persona ? `${f.name} (${f.persona})` : f.name;
flowSel.appendChild(o);
});
flowSel.onchange = () => setFlow(flowSel.value);
}
function setFlow(v) {
flowSel.value = v;
activeFlow = v === "" ? null : flows[+v];
selected = null;
if (activeFlow) {
const ids = activeFlow.steps.flatMap(s => s.nodes || []);
const pts = ids.map(id => byId.get(id)).filter(Boolean);
if (pts.length) {
const minX = Math.min(...pts.map(n => n.x - n.r)), maxX = Math.max(...pts.map(n => n.x + n.r));
const minY = Math.min(...pts.map(n => n.y - n.r)), maxY = Math.max(...pts.map(n => n.y + n.r));
flyTo({ x: (minX + maxX) / 2, y: (minY + maxY) / 2, r: Math.max(maxX - minX, maxY - minY) / 2 || 100 });
}
}
renderSidebar(); requestDraw();
if (!activeFlow) sidebar.classList.remove("open");
}
// ── Observations ────────────────────────────────────────────────────────
if ((DATA.observations || []).length) {
document.getElementById("obs").style.display = "";
document.getElementById("obsList").innerHTML =
DATA.observations.map(o => `<li style="margin:4px 0">${esc(o)}</li>`).join("");
}
// ── Boot ────────────────────────────────────────────────────────────────
document.getElementById("title").textContent = DATA.system || DATA.root.name || "System topology";
document.getElementById("stats").textContent =
`${nodes.filter(n => !n.container).length} modules · ${edges.length} edges` +
(entrySet.size ? ` · ${entrySet.size} entry points` : "");
{
const vw = innerWidth, vh = innerHeight;
const k = Math.min(vw, vh) / (WORLD * 1.05);
sel.call(zoomB.transform, d3.zoomIdentity.translate(vw / 2 - WORLD / 2 * k, vh / 2 - WORLD / 2 * k).scale(k));
}
requestDraw();
</script>
</body>
</html>