topology-viewer.html 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>System topology</title>
  7. <style>
  8. :root {
  9. --bg: #1e1e1e; --panel: #252526; --panel-2: #2d2d2d;
  10. --text: #d4d4d4; --muted: #8a8a8a; --accent: #cc785c;
  11. --border: #3a3a3a; --blue: #5ba0e0; --green: #6bb85a;
  12. }
  13. * { box-sizing: border-box; margin: 0; }
  14. html, body { height: 100%; overflow: hidden; background: var(--bg);
  15. color: var(--text); font: 14px/1.45 system-ui, sans-serif; }
  16. #map { position: absolute; inset: 0; cursor: grab; }
  17. #map.dragging { cursor: grabbing; }
  18. #hud { position: absolute; top: 12px; left: 12px; width: 280px;
  19. display: flex; flex-direction: column; gap: 8px; z-index: 2; }
  20. .panel { background: var(--panel); border: 1px solid var(--border);
  21. border-radius: 8px; padding: 10px 12px; }
  22. .panel h1 { font-size: 15px; color: var(--accent); margin-bottom: 2px; }
  23. .panel .sub { color: var(--muted); font-size: 12px; }
  24. #search { width: 100%; padding: 6px 8px; border-radius: 6px;
  25. border: 1px solid var(--border); background: var(--panel-2);
  26. color: var(--text); font: inherit; outline: none; }
  27. #search:focus { border-color: var(--accent); }
  28. #results { max-height: 180px; overflow-y: auto; margin-top: 4px; }
  29. #results div { padding: 4px 6px; border-radius: 4px; cursor: pointer;
  30. font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  31. #results div:hover { background: var(--panel-2); }
  32. #results .kind { color: var(--muted); font-size: 11px; margin-left: 6px; }
  33. .toggles label { display: flex; align-items: center; gap: 7px;
  34. font-size: 13px; padding: 2px 0; cursor: pointer; }
  35. .swatch { width: 14px; height: 3px; border-radius: 2px; display: inline-block; }
  36. #flows select { width: 100%; margin-top: 4px; padding: 5px;
  37. background: var(--panel-2); color: var(--text);
  38. border: 1px solid var(--border); border-radius: 6px; font: inherit; }
  39. #sidebar { position: absolute; top: 12px; right: 12px; bottom: 12px;
  40. width: 320px; overflow-y: auto; z-index: 2; display: none; }
  41. #sidebar.open { display: block; }
  42. #sidebar h2 { font-size: 16px; word-break: break-word; }
  43. #sidebar .meta { color: var(--muted); font-size: 12px; margin: 2px 0 10px; }
  44. #sidebar h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
  45. color: var(--muted); margin: 12px 0 4px; }
  46. #sidebar .link { color: var(--blue); cursor: pointer; display: block;
  47. padding: 2px 0; font-size: 13px; white-space: nowrap; overflow: hidden;
  48. text-overflow: ellipsis; }
  49. #sidebar .link:hover { color: var(--accent); }
  50. #sidebar ol { padding-left: 20px; } #sidebar ol li { margin: 6px 0; font-size: 13px; }
  51. #sidebar .closebtn { float: right; cursor: pointer; color: var(--muted);
  52. font-size: 18px; line-height: 1; } #sidebar .closebtn:hover { color: var(--text); }
  53. .badge { display: inline-block; padding: 1px 7px; border-radius: 9px;
  54. font-size: 11px; border: 1px solid var(--border); color: var(--muted); margin-right: 4px; }
  55. .badge.entry { border-color: var(--accent); color: var(--accent); }
  56. #hint { position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
  57. color: var(--muted); font-size: 12px; z-index: 2; background: var(--panel);
  58. border: 1px solid var(--border); border-radius: 6px; padding: 4px 12px; }
  59. #err { position: absolute; inset: 0; display: none; place-items: center; z-index: 9; }
  60. </style>
  61. </head>
  62. <body>
  63. <canvas id="map"></canvas>
  64. <div id="hud">
  65. <div class="panel">
  66. <h1 id="title">System topology</h1>
  67. <div class="sub" id="stats"></div>
  68. </div>
  69. <div class="panel">
  70. <input id="search" type="search" placeholder="Search modules, stores, jobs…" autocomplete="off">
  71. <div id="results"></div>
  72. </div>
  73. <div class="panel toggles" id="toggles"></div>
  74. <div class="panel" id="flows" style="display:none">
  75. <div class="sub">Business flow walkthrough</div>
  76. <select id="flowSel"><option value="">— none —</option></select>
  77. </div>
  78. <details class="panel" id="obs" style="display:none">
  79. <summary style="cursor:pointer;color:var(--muted);font-size:12px">Architect observations</summary>
  80. <ul id="obsList" style="padding-left:18px;margin-top:6px;font-size:12.5px"></ul>
  81. </details>
  82. </div>
  83. <aside id="sidebar" class="panel"></aside>
  84. <div id="hint">scroll to zoom · drag to pan · click a node · double-click to zoom in · Esc to reset</div>
  85. <div id="err" class="panel"><p>No topology data found in this file.<br>
  86. Re-run <code>/modernize-map</code> to regenerate it.</p></div>
  87. <script type="module">
  88. import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
  89. // Injected by /modernize-map: the contents of topology.json replace the
  90. // null after the marker comment. Schema documented in the command file.
  91. const DATA = /*__TOPOLOGY_DATA__*/ null;
  92. if (!DATA || !DATA.root) {
  93. document.getElementById("err").style.display = "grid";
  94. throw new Error("no data");
  95. }
  96. // ── Layout: circle-pack in a fixed world space ─────────────────────────
  97. // d3.pack gives every node (x, y, r) once; zooming is purely a canvas
  98. // transform, so the layout never recomputes and pan/zoom stays at 60fps.
  99. const WORLD = 8000;
  100. // Leaves without a meaningful LOC (data stores, screens, jobs) get a floor
  101. // based on the median module size so they stay visible next to code.
  102. const locs = [];
  103. (function walk(d) { if (d.children && d.children.length) d.children.forEach(walk);
  104. else if (d.loc > 1) locs.push(d.loc); })(DATA.root);
  105. locs.sort((a, b) => a - b);
  106. const FLOOR = Math.max(1, Math.round((locs[Math.floor(locs.length / 2)] || 100) * 0.4));
  107. const root = d3.hierarchy(DATA.root, d => d.children)
  108. .sum(d => (d.children && d.children.length ? 0 : Math.max(d.loc || 0, d.kind === "module" ? 1 : FLOOR)))
  109. .sort((a, b) => b.value - a.value);
  110. d3.pack().size([WORLD, WORLD]).padding(d => Math.max(3, 24 / (d.depth + 1)))(root);
  111. const nodes = [];
  112. const byId = new Map();
  113. root.each(d => {
  114. const n = { id: d.data.id, name: d.data.name, kind: d.data.kind || "module",
  115. language: d.data.language || null, file: d.data.file || null,
  116. loc: d.data.loc || 0, x: d.x, y: d.y, r: d.r, depth: d.depth,
  117. parent: d.parent ? d.parent.data.id : null,
  118. container: !!(d.children && d.children.length) };
  119. nodes.push(n); byId.set(n.id, n);
  120. });
  121. nodes.sort((a, b) => b.r - a.r); // painters: parents under children
  122. const entrySet = new Set(DATA.entryPoints || []);
  123. const edges = (DATA.edges || []).filter(e => byId.has(e.source) && byId.has(e.target));
  124. const fanIn = new Map(), fanOut = new Map();
  125. for (const e of edges) {
  126. fanOut.set(e.source, (fanOut.get(e.source) || 0) + 1);
  127. fanIn.set(e.target, (fanIn.get(e.target) || 0) + 1);
  128. }
  129. // ── Colors ──────────────────────────────────────────────────────────────
  130. const KIND_FILL = { system: "#1e1e1e", domain: null, module: "#4a6580",
  131. datastore: "#3d6a5a", job: "#7a5d8a", screen: "#8a6d4a" };
  132. const LANG_FILL = { cobol: "#5e7a9e", java: "#9a6848", c: "#6e6a58",
  133. "c++": "#6e6a58", python: "#5a7850", javascript: "#9a8348",
  134. typescript: "#4a6580", csharp: "#5f7a5f", rpg: "#7d6580", pl1: "#806858",
  135. natural: "#587a78", sql: "#3d6a5a" };
  136. const EDGE_STYLE = {
  137. call: { color: "rgba(201,182,144,0.30)", label: "calls" },
  138. dispatch: { color: "rgba(160,130,200,0.35)", label: "dynamic dispatch" },
  139. read: { color: "rgba(91,160,224,0.30)", label: "reads" },
  140. write: { color: "rgba(224,122,82,0.40)", label: "writes" },
  141. };
  142. function fillFor(n) {
  143. if (n.kind === "domain" || (n.container && n.kind !== "system"))
  144. return n.depth % 2 ? "#2d2d2d" : "#262626";
  145. if (n.kind !== "module" && KIND_FILL[n.kind]) return KIND_FILL[n.kind];
  146. return (n.language && LANG_FILL[n.language.toLowerCase()]) || "#4a6580";
  147. }
  148. // ── State ───────────────────────────────────────────────────────────────
  149. const canvas = document.getElementById("map");
  150. const ctx = canvas.getContext("2d");
  151. let transform = d3.zoomIdentity;
  152. let hovered = null, selected = null, activeFlow = null;
  153. const kindOn = Object.fromEntries(Object.keys(EDGE_STYLE).map(k => [k, true]));
  154. // ── Render ──────────────────────────────────────────────────────────────
  155. const MIN_R = 1.5, LABEL_R = 22, REVEAL_R = 55;
  156. let raf = null;
  157. const requestDraw = () => { if (!raf) raf = requestAnimationFrame(() => { raf = null; draw(); }); };
  158. function visible(n, k, tx, ty, vw, vh) {
  159. const sx = n.x * k + tx, sy = n.y * k + ty, sr = n.r * k;
  160. return sx + sr > -60 && sx - sr < vw + 60 && sy + sr > -60 && sy - sr < vh + 60;
  161. }
  162. function flowNodeSet() {
  163. if (!activeFlow) return null;
  164. const s = new Set();
  165. for (const step of activeFlow.steps) for (const id of step.nodes || []) s.add(id);
  166. return s;
  167. }
  168. function draw() {
  169. const dpr = window.devicePixelRatio || 1;
  170. const vw = innerWidth, vh = innerHeight;
  171. if (canvas.width !== vw * dpr) { canvas.width = vw * dpr; canvas.style.width = vw + "px"; }
  172. if (canvas.height !== vh * dpr) { canvas.height = vh * dpr; canvas.style.height = vh + "px"; }
  173. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  174. ctx.fillStyle = "#1e1e1e"; ctx.fillRect(0, 0, vw, vh);
  175. const { x: tx, y: ty, k } = transform;
  176. const flowSet = flowNodeSet();
  177. ctx.textAlign = "center"; ctx.textBaseline = "middle";
  178. // Nodes
  179. for (const n of nodes) {
  180. const sr = n.r * k;
  181. if (sr < MIN_R) continue;
  182. if (n.parent) {
  183. const p = byId.get(n.parent);
  184. if (p && p.r * k < REVEAL_R) continue; // LOD: reveal children gradually
  185. }
  186. if (!visible(n, k, tx, ty, vw, vh)) continue;
  187. const sx = n.x * k + tx, sy = n.y * k + ty;
  188. const inFlow = flowSet ? flowSet.has(n.id) : true;
  189. const isSel = selected === n.id, isHov = hovered === n.id;
  190. ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2);
  191. ctx.globalAlpha = (n.container ? 0.55 : 0.85) * (inFlow ? 1 : 0.18);
  192. ctx.fillStyle = isSel ? "rgba(204,120,92,0.25)" : fillFor(n);
  193. ctx.fill(); ctx.globalAlpha = 1;
  194. ctx.lineWidth = isSel ? 2.5 : isHov ? 1.8 : n.container ? 1 : 0.8;
  195. ctx.strokeStyle = isSel || isHov ? "#cc785c"
  196. : entrySet.has(n.id) ? "rgba(204,120,92,0.9)"
  197. : n.container ? "#3a3a3a" : "rgba(212,212,212,0.18)";
  198. if (entrySet.has(n.id) && !isSel && !isHov) ctx.lineWidth = 1.6;
  199. ctx.globalAlpha = inFlow ? 1 : 0.25; ctx.stroke(); ctx.globalAlpha = 1;
  200. if (sr > LABEL_R) {
  201. const fs = Math.min(17, Math.max(9, sr / 6));
  202. ctx.font = `${n.container ? "600" : "400"} ${fs}px system-ui, sans-serif`;
  203. ctx.fillStyle = n.container ? "#8a8a8a" : "#e8e8e8";
  204. ctx.globalAlpha = inFlow ? 1 : 0.3;
  205. const ly = n.container && sr > 55 ? sy - sr + fs + 5 : sy;
  206. const label = n.name.length > 28 ? n.name.slice(0, 26) + "…" : n.name;
  207. ctx.fillText(label, sx, ly);
  208. ctx.globalAlpha = 1;
  209. }
  210. }
  211. // Edges: batched per kind, endpoints must both be on screen
  212. const vis = id => { const n = byId.get(id); return n && n.r * k > MIN_R * 2 && visible(n, k, tx, ty, vw, vh); };
  213. const curve = (a, b) => {
  214. const ax = a.x * k + tx, ay = a.y * k + ty, bx = b.x * k + tx, by = b.y * k + ty;
  215. if ((ax - bx) ** 2 + (ay - by) ** 2 < 120) return;
  216. ctx.moveTo(ax, ay);
  217. ctx.quadraticCurveTo((ax + bx) / 2 + (by - ay) * 0.12, (ay + by) / 2 + (ax - bx) * 0.12, bx, by);
  218. };
  219. if (!flowSet) {
  220. for (const [kind, style] of Object.entries(EDGE_STYLE)) {
  221. if (!kindOn[kind]) continue;
  222. ctx.strokeStyle = style.color; ctx.lineWidth = 0.8; ctx.beginPath();
  223. let drawn = 0;
  224. for (const e of edges) {
  225. if (e.kind !== kind || !vis(e.source) || !vis(e.target)) continue;
  226. curve(byId.get(e.source), byId.get(e.target));
  227. if (++drawn > 1200) break;
  228. }
  229. ctx.stroke();
  230. }
  231. }
  232. // Focus edges (hover/selection) in accent, on top
  233. const focus = hovered || selected;
  234. if (focus && !flowSet) {
  235. ctx.strokeStyle = "#cc785c"; ctx.lineWidth = 1.5; ctx.beginPath();
  236. for (const e of edges) {
  237. if (e.source !== focus && e.target !== focus) continue;
  238. const a = byId.get(e.source), b = byId.get(e.target);
  239. if (a && b) curve(a, b);
  240. }
  241. ctx.stroke();
  242. }
  243. // Active flow: numbered step path
  244. if (flowSet) {
  245. ctx.strokeStyle = "#cc785c"; ctx.lineWidth = 2;
  246. const seq = activeFlow.steps.flatMap(s => s.nodes || []);
  247. ctx.beginPath();
  248. for (let i = 0; i + 1 < seq.length; i++) {
  249. const a = byId.get(seq[i]), b = byId.get(seq[i + 1]);
  250. if (a && b) curve(a, b);
  251. }
  252. ctx.stroke();
  253. seq.forEach((id, i) => {
  254. const n = byId.get(id); if (!n) return;
  255. const sx = n.x * k + tx, sy = n.y * k + ty - n.r * k - 10;
  256. ctx.beginPath(); ctx.arc(sx, sy, 9, 0, Math.PI * 2);
  257. ctx.fillStyle = "#cc785c"; ctx.fill();
  258. ctx.fillStyle = "#1e1e1e"; ctx.font = "700 11px system-ui, sans-serif";
  259. ctx.fillText(String(i + 1), sx, sy + 0.5);
  260. });
  261. }
  262. }
  263. // ── Zoom / pan / hit-testing ────────────────────────────────────────────
  264. const zoomB = d3.zoom().scaleExtent([0.05, 600])
  265. .on("zoom", ev => { transform = ev.transform; requestDraw(); })
  266. .on("start", () => canvas.classList.add("dragging"))
  267. .on("end", () => canvas.classList.remove("dragging"));
  268. const sel = d3.select(canvas).call(zoomB).on("dblclick.zoom", null);
  269. function hit(mx, my) {
  270. const wx = (mx - transform.x) / transform.k, wy = (my - transform.y) / transform.k;
  271. let best = null;
  272. for (const n of nodes) {
  273. if (n.r * transform.k < MIN_R) continue;
  274. const dx = wx - n.x, dy = wy - n.y;
  275. if (dx * dx + dy * dy <= n.r * n.r && (!best || n.r < best.r)) best = n;
  276. }
  277. return best;
  278. }
  279. function flyTo(n) {
  280. const vw = innerWidth, vh = innerHeight, cur = transform;
  281. const from = [(vw / 2 - cur.x) / cur.k, (vh / 2 - cur.y) / cur.k, Math.min(vw, vh) / cur.k];
  282. const interp = d3.interpolateZoom(from, [n.x, n.y, n.r * 2.6]);
  283. const dur = Math.max(400, interp.duration * 0.55), t0 = performance.now();
  284. (function step() {
  285. const t = Math.min(1, (performance.now() - t0) / dur);
  286. const [cx, cy, w] = interp(d3.easeCubicInOut(t));
  287. const k = Math.min(vw, vh) / w;
  288. sel.call(zoomB.transform, d3.zoomIdentity.translate(vw / 2 - cx * k, vh / 2 - cy * k).scale(k));
  289. if (t < 1) requestAnimationFrame(step);
  290. })();
  291. }
  292. canvas.addEventListener("mousemove", e => {
  293. const h = hit(e.offsetX, e.offsetY);
  294. const id = h ? h.id : null;
  295. if (id !== hovered) { hovered = id; canvas.style.cursor = h ? "pointer" : "grab"; requestDraw(); }
  296. });
  297. canvas.addEventListener("click", e => {
  298. const h = hit(e.offsetX, e.offsetY);
  299. selected = h ? h.id : null;
  300. renderSidebar(); requestDraw();
  301. });
  302. canvas.addEventListener("dblclick", e => { const h = hit(e.offsetX, e.offsetY); if (h) flyTo(h); });
  303. addEventListener("keydown", e => {
  304. if (e.key === "Escape") { selected = null; setFlow(""); flyTo(byId.get(DATA.root.id)); renderSidebar(); }
  305. });
  306. addEventListener("resize", requestDraw);
  307. // ── Sidebar ─────────────────────────────────────────────────────────────
  308. const sidebar = document.getElementById("sidebar");
  309. const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
  310. function renderSidebar() {
  311. if (activeFlow) return renderFlowSidebar();
  312. if (!selected) { sidebar.classList.remove("open"); return; }
  313. const n = byId.get(selected);
  314. const neighbors = [];
  315. for (const e of edges) {
  316. if (e.source === n.id) neighbors.push({ id: e.target, rel: EDGE_STYLE[e.kind]?.label || e.kind, dir: "→" });
  317. else if (e.target === n.id) neighbors.push({ id: e.source, rel: EDGE_STYLE[e.kind]?.label || e.kind, dir: "←" });
  318. }
  319. sidebar.innerHTML = `
  320. <span class="closebtn" id="close">×</span>
  321. <h2>${esc(n.name)}</h2>
  322. <div class="meta">${esc(n.kind)}${n.language ? " · " + esc(n.language) : ""}${n.loc ? " · " + n.loc.toLocaleString() + " LOC" : ""}</div>
  323. <div>${entrySet.has(n.id) ? '<span class="badge entry">entry point</span>' : ""}
  324. <span class="badge">fan-in ${fanIn.get(n.id) || 0}</span>
  325. <span class="badge">fan-out ${fanOut.get(n.id) || 0}</span></div>
  326. ${n.file ? `<h3>Source</h3><div class="meta">${esc(n.file)}</div>` : ""}
  327. ${neighbors.length ? `<h3>Connections (${neighbors.length})</h3>` +
  328. 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("") +
  329. (neighbors.length > 40 ? `<div class="meta">…and ${neighbors.length - 40} more</div>` : "") : ""}`;
  330. sidebar.classList.add("open");
  331. sidebar.querySelector("#close").onclick = () => { selected = null; renderSidebar(); requestDraw(); };
  332. sidebar.querySelectorAll(".link").forEach(el => el.onclick = () => {
  333. const t = byId.get(el.dataset.id);
  334. if (t) { selected = t.id; flyTo(t); renderSidebar(); requestDraw(); }
  335. });
  336. }
  337. function renderFlowSidebar() {
  338. const f = activeFlow;
  339. sidebar.innerHTML = `
  340. <span class="closebtn" id="close">×</span>
  341. <h2>${esc(f.name)}</h2>
  342. <div class="meta">${f.persona ? "Persona: " + esc(f.persona) : ""}</div>
  343. ${f.description ? `<p style="font-size:13px">${esc(f.description)}</p>` : ""}
  344. <h3>Steps</h3>
  345. <ol>${f.steps.map(s => `<li>${esc(s.label)}${(s.nodes || []).length ?
  346. `<div class="meta">${s.nodes.map(id => esc(byId.get(id)?.name || id)).join(" → ")}</div>` : ""}</li>`).join("")}</ol>`;
  347. sidebar.classList.add("open");
  348. sidebar.querySelector("#close").onclick = () => setFlow("");
  349. }
  350. // ── Search ──────────────────────────────────────────────────────────────
  351. const searchEl = document.getElementById("search"), resultsEl = document.getElementById("results");
  352. searchEl.addEventListener("input", () => {
  353. const q = searchEl.value.trim().toLowerCase();
  354. resultsEl.innerHTML = "";
  355. if (q.length < 2) return;
  356. nodes.filter(n => n.name.toLowerCase().includes(q)).slice(0, 12).forEach(n => {
  357. const d = document.createElement("div");
  358. d.innerHTML = `${esc(n.name)}<span class="kind">${esc(n.kind)}</span>`;
  359. d.onclick = () => { selected = n.id; flyTo(n); renderSidebar(); requestDraw(); };
  360. resultsEl.appendChild(d);
  361. });
  362. });
  363. // ── Edge-kind toggles & legend ──────────────────────────────────────────
  364. const togglesEl = document.getElementById("toggles");
  365. for (const [kind, style] of Object.entries(EDGE_STYLE)) {
  366. if (!edges.some(e => e.kind === kind)) continue;
  367. const solid = style.color.replace(/[\d.]+\)$/, "0.9)");
  368. const l = document.createElement("label");
  369. l.innerHTML = `<input type="checkbox" checked><span class="swatch" style="background:${solid}"></span>${style.label}`;
  370. l.querySelector("input").onchange = ev => { kindOn[kind] = ev.target.checked; requestDraw(); };
  371. togglesEl.appendChild(l);
  372. }
  373. if (!togglesEl.children.length) togglesEl.style.display = "none";
  374. // ── Flows ───────────────────────────────────────────────────────────────
  375. const flowsPanel = document.getElementById("flows"), flowSel = document.getElementById("flowSel");
  376. const flows = DATA.flows || [];
  377. if (flows.length) {
  378. flowsPanel.style.display = "";
  379. flows.forEach((f, i) => {
  380. const o = document.createElement("option");
  381. o.value = i; o.textContent = f.persona ? `${f.name} (${f.persona})` : f.name;
  382. flowSel.appendChild(o);
  383. });
  384. flowSel.onchange = () => setFlow(flowSel.value);
  385. }
  386. function setFlow(v) {
  387. flowSel.value = v;
  388. activeFlow = v === "" ? null : flows[+v];
  389. selected = null;
  390. if (activeFlow) {
  391. const ids = activeFlow.steps.flatMap(s => s.nodes || []);
  392. const pts = ids.map(id => byId.get(id)).filter(Boolean);
  393. if (pts.length) {
  394. const minX = Math.min(...pts.map(n => n.x - n.r)), maxX = Math.max(...pts.map(n => n.x + n.r));
  395. const minY = Math.min(...pts.map(n => n.y - n.r)), maxY = Math.max(...pts.map(n => n.y + n.r));
  396. flyTo({ x: (minX + maxX) / 2, y: (minY + maxY) / 2, r: Math.max(maxX - minX, maxY - minY) / 2 || 100 });
  397. }
  398. }
  399. renderSidebar(); requestDraw();
  400. if (!activeFlow) sidebar.classList.remove("open");
  401. }
  402. // ── Observations ────────────────────────────────────────────────────────
  403. if ((DATA.observations || []).length) {
  404. document.getElementById("obs").style.display = "";
  405. document.getElementById("obsList").innerHTML =
  406. DATA.observations.map(o => `<li style="margin:4px 0">${esc(o)}</li>`).join("");
  407. }
  408. // ── Boot ────────────────────────────────────────────────────────────────
  409. document.getElementById("title").textContent = DATA.system || DATA.root.name || "System topology";
  410. document.getElementById("stats").textContent =
  411. `${nodes.filter(n => !n.container).length} modules · ${edges.length} edges` +
  412. (entrySet.size ? ` · ${entrySet.size} entry points` : "");
  413. {
  414. const vw = innerWidth, vh = innerHeight;
  415. const k = Math.min(vw, vh) / (WORLD * 1.05);
  416. sel.call(zoomB.transform, d3.zoomIdentity.translate(vw / 2 - WORLD / 2 * k, vh / 2 - WORLD / 2 * k).scale(k));
  417. }
  418. requestDraw();
  419. </script>
  420. </body>
  421. </html>