codegraph-ir 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. cgir/__init__.py +6 -0
  2. cgir/analyses/__init__.py +7 -0
  3. cgir/analyses/call_graph.py +114 -0
  4. cgir/analyses/cfg.py +320 -0
  5. cgir/analyses/effects.py +95 -0
  6. cgir/analyses/entrypoints.py +55 -0
  7. cgir/analyses/param_flow.py +75 -0
  8. cgir/analyses/pdg.py +75 -0
  9. cgir/analyses/purity.py +37 -0
  10. cgir/analyses/reaching_defs.py +115 -0
  11. cgir/analyses/symbols.py +106 -0
  12. cgir/api/__init__.py +1 -0
  13. cgir/api/mcp_server.py +172 -0
  14. cgir/api/server.py +107 -0
  15. cgir/cli.py +688 -0
  16. cgir/config.py +22 -0
  17. cgir/export/__init__.py +5 -0
  18. cgir/export/graphml.py +52 -0
  19. cgir/export/html_viz.py +1087 -0
  20. cgir/export/json_export.py +37 -0
  21. cgir/export/mermaid.py +57 -0
  22. cgir/export/neo4j.py +11 -0
  23. cgir/hooks.py +189 -0
  24. cgir/ir/__init__.py +16 -0
  25. cgir/ir/component_spec.py +100 -0
  26. cgir/ir/edges.py +31 -0
  27. cgir/ir/graph.py +132 -0
  28. cgir/ir/nodes.py +38 -0
  29. cgir/languages/__init__.py +25 -0
  30. cgir/languages/base.py +221 -0
  31. cgir/languages/cache.py +73 -0
  32. cgir/languages/python.py +1145 -0
  33. cgir/languages/registry.py +24 -0
  34. cgir/languages/typescript.py +853 -0
  35. cgir/manifest.py +77 -0
  36. cgir/pipeline.py +54 -0
  37. cgir/py.typed +0 -0
  38. cgir/regenerate/__init__.py +6 -0
  39. cgir/regenerate/prompt_pack.py +16 -0
  40. cgir/regenerate/regenerator.py +108 -0
  41. cgir/report/__init__.py +5 -0
  42. cgir/report/diff.py +226 -0
  43. cgir/report/flow.py +84 -0
  44. cgir/report/impact.py +234 -0
  45. cgir/report/lint.py +84 -0
  46. cgir/report/pack.py +271 -0
  47. cgir/report/stats.py +121 -0
  48. cgir/slicing/__init__.py +5 -0
  49. cgir/slicing/slicer.py +174 -0
  50. cgir/sources/__init__.py +6 -0
  51. cgir/sources/base.py +14 -0
  52. cgir/sources/codeql_source.py +13 -0
  53. cgir/sources/joern_source.py +13 -0
  54. cgir/sources/tree_sitter_source.py +270 -0
  55. cgir/trace/__init__.py +5 -0
  56. cgir/trace/trace_map.py +83 -0
  57. cgir/verify.py +173 -0
  58. cgir/watch.py +171 -0
  59. codegraph_ir-0.1.0.dist-info/METADATA +143 -0
  60. codegraph_ir-0.1.0.dist-info/RECORD +63 -0
  61. codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
  62. codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
  63. codegraph_ir-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1087 @@
1
+ """Self-contained HTML visualization of the component graph.
2
+
3
+ Writes a single ``viz.html`` with the component data embedded as JSON and
4
+ a hand-rolled canvas force layout — no CDN, no network requests, matching
5
+ the local-first rule for the analysis layers. Open it with any browser.
6
+
7
+ Three views over the same data:
8
+
9
+ * **Components** — force layout, one node per component, file hulls.
10
+ * **Files** — one node per file, edges aggregated with call counts:
11
+ the architecture overview.
12
+ * **Layers** — columns by call depth (routes → services → repos → types),
13
+ left to right.
14
+
15
+ Interactions: drag nodes, wheel-zoom, drag-background to pan, hover for a
16
+ tooltip, click for a transitive trace + detail panel, search box to
17
+ highlight, legend to toggle component kinds, sliders for spacing / link
18
+ length / node size.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from pathlib import Path
25
+
26
+ from cgir.ir.component_spec import ComponentSpec
27
+
28
+
29
+ def write(
30
+ out_dir: Path,
31
+ specs: list[ComponentSpec],
32
+ arg_flows: dict[str, list[dict[str, object]]] | None = None,
33
+ ) -> Path:
34
+ """Write ``<out_dir>/viz.html`` and return its path.
35
+
36
+ ``arg_flows`` (from :mod:`cgir.analyses.param_flow`, keyed by spec id)
37
+ adds caller→callee argument edges typed by the parameter annotation.
38
+ """
39
+ out_dir.mkdir(parents=True, exist_ok=True)
40
+ data = _build_data(specs, arg_flows)
41
+ html = _TEMPLATE.replace("__CGIR_JSON__", json.dumps(data, sort_keys=True))
42
+ path = out_dir / "viz.html"
43
+ path.write_text(html)
44
+ return path
45
+
46
+
47
+ def _build_data(
48
+ specs: list[ComponentSpec],
49
+ arg_flows: dict[str, list[dict[str, object]]] | None = None,
50
+ ) -> dict[str, object]:
51
+ index_of = {spec.id: i for i, spec in enumerate(specs)}
52
+ nodes = []
53
+ for spec in specs:
54
+ file = spec.trace[0].rsplit(":", 1)[0] if spec.trace else ""
55
+ nodes.append(
56
+ {
57
+ "id": spec.id,
58
+ "kind": spec.kind.value,
59
+ "purity": spec.purity,
60
+ "effects": spec.effects,
61
+ "inputs": spec.inputs,
62
+ "calls": spec.calls,
63
+ "constructs": spec.constructs,
64
+ "outputs": spec.outputs,
65
+ "param_types": _param_types(spec.signature),
66
+ "entrypoint": spec.entrypoint,
67
+ "file": file,
68
+ "trace": spec.trace,
69
+ "signature": spec.signature,
70
+ }
71
+ )
72
+ # Synthetic nodes for constructed types — the data model becomes visible.
73
+ type_names = sorted({t for spec in specs for t in spec.constructs})
74
+ for type_name in type_names:
75
+ index_of[type_name] = len(nodes)
76
+ nodes.append(
77
+ {
78
+ "id": type_name,
79
+ "kind": "type",
80
+ "purity": None,
81
+ "effects": [],
82
+ "inputs": [],
83
+ "calls": [],
84
+ "constructs": [],
85
+ "outputs": [],
86
+ "param_types": [],
87
+ "entrypoint": None,
88
+ "file": "(types)",
89
+ "trace": [],
90
+ "signature": None,
91
+ }
92
+ )
93
+ edges = []
94
+ for spec in specs:
95
+ for callee in spec.calls:
96
+ target = index_of.get(callee)
97
+ if target is not None:
98
+ callee_outputs = specs[target].outputs if target < len(specs) else []
99
+ edges.append(
100
+ {
101
+ "s": index_of[spec.id],
102
+ "t": target,
103
+ "kind": "call",
104
+ "type": callee_outputs[0] if callee_outputs else None,
105
+ }
106
+ )
107
+ for type_name in spec.constructs:
108
+ edges.append(
109
+ {
110
+ "s": index_of[spec.id],
111
+ "t": index_of[type_name],
112
+ "kind": "construct",
113
+ "type": type_name.rsplit(".", 1)[-1],
114
+ }
115
+ )
116
+ if arg_flows:
117
+ spec_by_id = {spec.id: spec for spec in specs}
118
+ for caller_id, entries in arg_flows.items():
119
+ src = index_of.get(caller_id)
120
+ caller = spec_by_id.get(caller_id)
121
+ if src is None or caller is None:
122
+ continue
123
+ annotations = dict(_param_items(caller.signature))
124
+ for entry in entries:
125
+ dst = index_of.get(str(entry.get("callee")))
126
+ if dst is None:
127
+ continue
128
+ raw_params = entry.get("params")
129
+ params = [str(p) for p in raw_params] if isinstance(raw_params, list) else []
130
+ labels = [annotations.get(p) or p for p in params]
131
+ edges.append({"s": src, "t": dst, "kind": "arg", "type": ", ".join(labels)})
132
+ return {"nodes": nodes, "edges": edges}
133
+
134
+
135
+ def _param_items(signature: str | None) -> list[tuple[str, str | None]]:
136
+ """Best-effort ``(name, annotation)`` pairs from a rendered signature.
137
+
138
+ ``f(price: float, table: dict[str, int], x=1) -> float`` gives
139
+ ``[("price", "float"), ("table", "dict[str, int]"), ("x", None)]`` —
140
+ bracket-aware comma split, defaults stripped.
141
+ """
142
+ if not signature or "(" not in signature or ")" not in signature:
143
+ return []
144
+ start = signature.index("(") + 1
145
+ end = len(signature)
146
+ depth = 0
147
+ for i in range(start, len(signature)):
148
+ ch = signature[i]
149
+ if ch in "([{":
150
+ depth += 1
151
+ elif ch in ")]}":
152
+ if depth == 0 and ch == ")":
153
+ end = i
154
+ break
155
+ depth -= 1
156
+ inner = signature[start:end]
157
+ if not inner.strip():
158
+ return []
159
+ parts: list[str] = []
160
+ depth = 0
161
+ current = ""
162
+ for ch in inner:
163
+ if ch in "([{":
164
+ depth += 1
165
+ elif ch in ")]}":
166
+ depth -= 1
167
+ if ch == "," and depth == 0:
168
+ parts.append(current)
169
+ current = ""
170
+ else:
171
+ current += ch
172
+ parts.append(current)
173
+ items: list[tuple[str, str | None]] = []
174
+ for part in parts:
175
+ name, _, annotation = part.partition(":")
176
+ name = name.split("=", 1)[0].strip().lstrip("*")
177
+ annotation = annotation.split("=", 1)[0].strip()
178
+ items.append((name, annotation or None))
179
+ return items
180
+
181
+
182
+ def _param_types(signature: str | None) -> list[str]:
183
+ """Annotation list from :func:`_param_items`; unannotated marked ``?``."""
184
+ return [annotation or "?" for _, annotation in _param_items(signature)]
185
+
186
+
187
+ _TEMPLATE = """<!DOCTYPE html>
188
+ <html lang="en">
189
+ <head>
190
+ <meta charset="utf-8">
191
+ <title>CGIR component graph</title>
192
+ <style>
193
+ html, body { margin: 0; height: 100%; overflow: hidden;
194
+ font: 13px/1.4 -apple-system, "Segoe UI", Roboto, sans-serif;
195
+ background: #0f1420; color: #dbe2ef; }
196
+ #canvas { display: block; cursor: grab; }
197
+ #topbar { position: fixed; top: 0; left: 0; right: 0; display: flex;
198
+ gap: 14px; align-items: center; flex-wrap: wrap; padding: 10px 14px;
199
+ background: rgba(15,20,32,.92); border-bottom: 1px solid #232c44; }
200
+ #topbar h1 { font-size: 14px; margin: 0; font-weight: 600; color: #8ea6d8; }
201
+ #views { display: flex; border: 1px solid #2c3757; border-radius: 8px;
202
+ overflow: hidden; }
203
+ .vw { padding: 6px 14px; background: #171e30; color: #8ea6d8; border: none;
204
+ cursor: pointer; font-size: 13px; }
205
+ .vw + .vw { border-left: 1px solid #2c3757; }
206
+ .vw.active { background: #2c3757; color: #fff; }
207
+ .vw:hover:not(.active) { color: #dbe2ef; }
208
+ #search { flex: 0 0 220px; padding: 6px 10px; border-radius: 6px;
209
+ border: 1px solid #2c3757; background: #171e30; color: #dbe2ef; }
210
+ #fit { padding: 6px 12px; border-radius: 6px; border: 1px solid #2c3757;
211
+ background: #171e30; color: #8ea6d8; cursor: pointer; }
212
+ #fit:hover { color: #dbe2ef; border-color: #46578a; }
213
+ #sliders { display: flex; gap: 18px; align-items: center; }
214
+ #sliders label { display: flex; flex-direction: column; gap: 2px;
215
+ font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
216
+ color: #56618a; user-select: none; }
217
+ #sliders input[type="range"] { width: 180px; height: 22px;
218
+ accent-color: #63b3ed; cursor: pointer; }
219
+ #legend { display: flex; gap: 10px; flex-wrap: wrap; }
220
+ .lg { display: flex; gap: 5px; align-items: center; cursor: pointer;
221
+ opacity: 1; user-select: none; }
222
+ .lg.off { opacity: .3; }
223
+ .dot { width: 10px; height: 10px; border-radius: 50%; }
224
+ #detail { position: fixed; top: 96px; right: 0; bottom: 0; width: 320px;
225
+ background: rgba(18,24,40,.95); border-left: 1px solid #232c44;
226
+ padding: 16px; overflow-y: auto; display: none; }
227
+ #detail h2 { font-size: 13px; word-break: break-all; color: #9fd3a8;
228
+ margin: 0 0 8px; }
229
+ #detail dt { color: #8ea6d8; margin-top: 10px; font-size: 11px;
230
+ text-transform: uppercase; letter-spacing: .05em; }
231
+ #detail dd { margin: 2px 0 0; word-break: break-all; }
232
+ #detail .close { position: absolute; top: 8px; right: 12px; cursor: pointer;
233
+ color: #8ea6d8; }
234
+ #tooltip { position: fixed; pointer-events: none; background: #1c2540;
235
+ border: 1px solid #2c3757; border-radius: 6px; padding: 6px 10px;
236
+ display: none; max-width: 380px; word-break: break-all; z-index: 10; }
237
+ #hint { position: fixed; bottom: 10px; left: 14px; color: #56618a;
238
+ font-size: 11px; }
239
+ </style>
240
+ </head>
241
+ <body>
242
+ <div id="topbar">
243
+ <h1>CGIR</h1>
244
+ <div id="views">
245
+ <button class="vw active" data-view="components">Components</button>
246
+ <button class="vw" data-view="files">Files</button>
247
+ <button class="vw" data-view="layers">Layers</button>
248
+ <button class="vw" data-view="flow">Flow</button>
249
+ </div>
250
+ <input id="search" placeholder="search…" autocomplete="off">
251
+ <button id="fit" title="Zoom to fit (F)">⤢ fit</button>
252
+ <div id="sliders">
253
+ <label>spacing <input type="range" id="s-spacing" min="0.3" max="3" step="0.05" value="1"></label>
254
+ <label>link length <input type="range" id="s-links" min="0.3" max="3" step="0.05" value="1"></label>
255
+ <label>node size <input type="range" id="s-size" min="0.5" max="2.5" step="0.05" value="1"></label>
256
+ </div>
257
+ <div id="legend"></div>
258
+ </div>
259
+ <canvas id="canvas"></canvas>
260
+ <div id="tooltip"></div>
261
+ <div id="detail"><span class="close" id="close">✕</span><div id="detail-body"></div></div>
262
+ <div id="hint">drag nodes · wheel to zoom · drag background to pan · click a node to trace it</div>
263
+ <script>
264
+ const DATA = /*CGIR_DATA*/__CGIR_JSON__/*END_CGIR_DATA*/;
265
+
266
+ const KIND_COLORS = {
267
+ pure_function: "#68d391",
268
+ orchestrator: "#63b3ed",
269
+ state_transformer: "#f6ad55",
270
+ effect_adapter: "#fc8181",
271
+ unknown: "#a0aec0",
272
+ type: "#b794f4",
273
+ };
274
+
275
+ const canvas = document.getElementById("canvas");
276
+ const ctx = canvas.getContext("2d");
277
+ let W = 0, H = 0;
278
+ function resize() {
279
+ W = window.innerWidth; H = window.innerHeight;
280
+ canvas.width = W * devicePixelRatio; canvas.height = H * devicePixelRatio;
281
+ canvas.style.width = W + "px"; canvas.style.height = H + "px";
282
+ ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
283
+ }
284
+ window.addEventListener("resize", () => { resize(); kick(); });
285
+ resize();
286
+
287
+ // --- tunables (sliders) ------------------------------------------------------
288
+ let SP = 1; // spacing: cluster padding + repulsion
289
+ let LK = 1; // link length: spring rest distances
290
+ let SZ = 1; // node size
291
+ const rad = n => n.r * SZ;
292
+
293
+ // --- base graph model --------------------------------------------------------
294
+ const fileNames = [...new Set(DATA.nodes.map(n => n.file))];
295
+ const fileSlot = {};
296
+ fileNames.forEach((f, i) => { fileSlot[f] = i; });
297
+ const ringR = Math.max(380, fileNames.length * 52);
298
+ function seedPos(slotCount, slot, i) {
299
+ const slotAngle = (slot / Math.max(slotCount, 1)) * Math.PI * 2;
300
+ const jitterAngle = (i * 2.399963) % (Math.PI * 2); // golden-angle spread
301
+ const jitterR = 20 + (i % 5) * 14;
302
+ return {
303
+ x: W / 2 + Math.cos(slotAngle) * ringR + Math.cos(jitterAngle) * jitterR,
304
+ y: H / 2 + Math.sin(slotAngle) * ringR + Math.sin(jitterAngle) * jitterR,
305
+ };
306
+ }
307
+ const baseNodes = DATA.nodes.map((n, i) => {
308
+ const p = seedPos(fileNames.length, fileSlot[n.file], i);
309
+ return { ...n, x: p.x, y: p.y, vx: 0, vy: 0, deg: 0, fixed: false, layer: 0 };
310
+ });
311
+ const allEdges = DATA.edges.map(e => ({ s: e.s, t: e.t, kind: e.kind || "call", type: e.type, w: 1 }));
312
+ // PDG-derived arg edges only appear in the Flow view; the structural views
313
+ // use call/construct edges so pairs aren't double-drawn.
314
+ const argEdges = allEdges.filter(e => e.kind === "arg");
315
+ const baseEdges = allEdges.filter(e => e.kind !== "arg");
316
+ baseEdges.forEach(e => { baseNodes[e.s].deg++; baseNodes[e.t].deg++; });
317
+ baseNodes.forEach(n => { n.r = 5 + Math.min(11, Math.sqrt(n.deg) * 2.4); });
318
+
319
+ // layer = longest call-path depth from any root (bounded relaxation, cycle-safe)
320
+ {
321
+ for (let pass = 0; pass < 24; pass++) {
322
+ let changed = false;
323
+ baseEdges.forEach(e => {
324
+ if (baseNodes[e.t].layer < baseNodes[e.s].layer + 1) {
325
+ baseNodes[e.t].layer = baseNodes[e.s].layer + 1;
326
+ changed = true;
327
+ }
328
+ });
329
+ if (!changed) break;
330
+ }
331
+ }
332
+
333
+ // --- flow view: edges follow DATA, not calls ----------------------------------
334
+ // A callee returning a value sends data back up: edge callee -> caller,
335
+ // colored by the returned type. Void calls are "commands" (effects, thin
336
+ // gray). Constructs reverse too: the type feeds the function that builds it.
337
+ const flowEdges = [];
338
+ baseEdges.forEach(e => {
339
+ if (e.kind === "construct") {
340
+ flowEdges.push({ s: e.t, t: e.s, kind: "data", type: e.type, w: 1 });
341
+ return;
342
+ }
343
+ const callee = baseNodes[e.t];
344
+ const rt = (callee.outputs && callee.outputs[0]) || null;
345
+ if (rt && rt !== "None") {
346
+ flowEdges.push({ s: e.t, t: e.s, kind: "data", type: rt, w: 1 });
347
+ } else {
348
+ flowEdges.push({ s: e.s, t: e.t, kind: "command", type: null, w: 1 });
349
+ }
350
+ });
351
+ {
352
+ // Stage layout ignores arg edges (they run opposite the return edges and
353
+ // would cycle every caller/callee pair).
354
+ baseNodes.forEach(n => { n.flowLayer = 0; });
355
+ for (let pass = 0; pass < 24; pass++) {
356
+ let changed = false;
357
+ flowEdges.forEach(e => {
358
+ if (baseNodes[e.t].flowLayer < baseNodes[e.s].flowLayer + 1) {
359
+ baseNodes[e.t].flowLayer = baseNodes[e.s].flowLayer + 1;
360
+ changed = true;
361
+ }
362
+ });
363
+ if (!changed) break;
364
+ }
365
+ }
366
+ argEdges.forEach(e => flowEdges.push(e));
367
+
368
+ function typeColor(t) {
369
+ let h = 0;
370
+ for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) % 360;
371
+ return `hsl(${h}, 62%, 64%)`;
372
+ }
373
+
374
+ // --- files view: one node per file, aggregated weighted edges -----------------
375
+ function buildFilesView() {
376
+ const byFile = {};
377
+ baseNodes.forEach(n => { (byFile[n.file] = byFile[n.file] || []).push(n); });
378
+ const fnodes = [];
379
+ const idx = {};
380
+ Object.entries(byFile).forEach(([file, members]) => {
381
+ const kinds = {};
382
+ members.forEach(m => { kinds[m.kind] = (kinds[m.kind] || 0) + 1; });
383
+ const domKind = Object.entries(kinds).sort((a, b) => b[1] - a[1])[0][0];
384
+ const effects = [...new Set(members.flatMap(m => m.effects))].sort();
385
+ idx[file] = fnodes.length;
386
+ const p = seedPos(Object.keys(byFile).length, fnodes.length, fnodes.length);
387
+ fnodes.push({
388
+ id: file, kind: domKind, file, purity: null, effects,
389
+ inputs: [], calls: [], constructs: [], outputs: [], trace: [],
390
+ signature: null, members: members.map(m => m.id).sort(),
391
+ x: p.x, y: p.y, vx: 0, vy: 0, deg: 0, fixed: false, layer: 0,
392
+ });
393
+ });
394
+ const weight = {};
395
+ baseEdges.forEach(e => {
396
+ const fa = baseNodes[e.s].file, fb = baseNodes[e.t].file;
397
+ if (fa === fb) return;
398
+ const key = idx[fa] + ":" + idx[fb] + ":" + e.kind;
399
+ weight[key] = (weight[key] || 0) + 1;
400
+ });
401
+ const fedges = Object.entries(weight).map(([key, w]) => {
402
+ const [s, t, kind] = key.split(":");
403
+ return { s: +s, t: +t, kind, type: w > 1 ? w + " calls" : null, w };
404
+ });
405
+ fedges.forEach(e => { fnodes[e.s].deg += e.w; fnodes[e.t].deg += e.w; });
406
+ fnodes.forEach(n => {
407
+ n.r = 9 + Math.min(18, 3.2 * Math.sqrt((byFile[n.id] || []).length));
408
+ });
409
+ return { nodes: fnodes, edges: fedges };
410
+ }
411
+
412
+ // --- active view state ---------------------------------------------------------
413
+ let currentView = "components";
414
+ let nodes = baseNodes, edges = baseEdges;
415
+ let outAdj = [], inAdj = [];
416
+ function rebuildAdjacency() {
417
+ outAdj = nodes.map(() => []); inAdj = nodes.map(() => []);
418
+ edges.forEach((e, ei) => { outAdj[e.s].push({ n: e.t, ei }); inAdj[e.t].push({ n: e.s, ei }); });
419
+ }
420
+ rebuildAdjacency();
421
+
422
+ const LAYER_GAP = 260;
423
+ function isPinnedView() { return currentView === "layers" || currentView === "flow"; }
424
+ function pinnedLayer(n) { return currentView === "flow" ? n.flowLayer : n.layer; }
425
+ function layerX(n) { return 160 + pinnedLayer(n) * LAYER_GAP * LK; }
426
+
427
+ // Deterministic grid layout for pinned views: tight column slots, tall
428
+ // columns wrap sideways, and unconnected nodes park in a labeled block
429
+ // below instead of drifting to the fringes.
430
+ let isolatedTop = null;
431
+ let pinnedPitch = 44;
432
+ function layoutPinned() {
433
+ // flow boxes are ~34px tall; give them clear air between rows
434
+ const pitch = currentView === "flow" ? 54 : 44;
435
+ pinnedPitch = pitch;
436
+ const maxRows = 40;
437
+ const isolated = [], connected = [];
438
+ nodes.forEach((n, i) => {
439
+ const degHere = outAdj[i].length + inAdj[i].length;
440
+ (degHere === 0 ? isolated : connected).push(n);
441
+ });
442
+ const byLayer = {};
443
+ connected.forEach(n => {
444
+ (byLayer[pinnedLayer(n)] = byLayer[pinnedLayer(n)] || []).push(n);
445
+ });
446
+ const layerKeys = Object.keys(byLayer).map(Number).sort((x, y) => x - y);
447
+ layerKeys.forEach(l =>
448
+ byLayer[l].sort((a, b) => (a.file + a.id).localeCompare(b.file + b.id)));
449
+
450
+ // Barycenter sweeps: order each column by the mean row of its neighbors
451
+ // so edges run roughly horizontally instead of slashing across the grid.
452
+ const indexOfNode = new Map(nodes.map((n, i) => [n, i]));
453
+ const rowOf = new Map();
454
+ layerKeys.forEach(l => byLayer[l].forEach((n, i) => rowOf.set(n, i)));
455
+ const meanNeighborRow = n => {
456
+ const i = indexOfNode.get(n);
457
+ let sum = 0, count = 0;
458
+ outAdj[i].forEach(({ n: t }) => {
459
+ if (rowOf.has(nodes[t])) { sum += rowOf.get(nodes[t]); count++; }
460
+ });
461
+ inAdj[i].forEach(({ n: s }) => {
462
+ if (rowOf.has(nodes[s])) { sum += rowOf.get(nodes[s]); count++; }
463
+ });
464
+ return count ? sum / count : rowOf.get(n);
465
+ };
466
+ for (let sweep = 0; sweep < 4; sweep++) {
467
+ const keys = sweep % 2 === 0 ? layerKeys : [...layerKeys].reverse();
468
+ keys.forEach(l => {
469
+ byLayer[l].sort((a, b) =>
470
+ meanNeighborRow(a) - meanNeighborRow(b) ||
471
+ (a.file + a.id).localeCompare(b.file + b.id));
472
+ byLayer[l].forEach((n, i) => rowOf.set(n, i));
473
+ });
474
+ }
475
+
476
+ let maxBottom = 200;
477
+ layerKeys.forEach(l => {
478
+ byLayer[l].forEach((n, i) => {
479
+ const col = Math.floor(i / maxRows);
480
+ const row = i % maxRows;
481
+ n.x = layerX(n) + col * 230;
482
+ n.y = 200 + row * pitch;
483
+ n.vx = 0; n.vy = 0;
484
+ });
485
+ maxBottom = Math.max(maxBottom, 200 + Math.min(byLayer[l].length, maxRows) * pitch);
486
+ });
487
+ if (isolated.length) {
488
+ isolatedTop = maxBottom + 130;
489
+ const cols = Math.max(1, Math.ceil(Math.sqrt(isolated.length * 2)));
490
+ isolated.sort((a, b) => (a.file + a.id).localeCompare(b.file + b.id));
491
+ isolated.forEach((n, i) => {
492
+ n.x = 160 + (i % cols) * 250;
493
+ n.y = isolatedTop + Math.floor(i / cols) * pitch;
494
+ n.vx = 0; n.vy = 0;
495
+ });
496
+ } else {
497
+ isolatedTop = null;
498
+ }
499
+ }
500
+
501
+ function setView(name) {
502
+ currentView = name;
503
+ select(null);
504
+ hovered = null;
505
+ if (name === "files") {
506
+ const v = buildFilesView();
507
+ nodes = v.nodes; edges = v.edges;
508
+ } else if (name === "flow") {
509
+ nodes = baseNodes; edges = flowEdges;
510
+ } else {
511
+ nodes = baseNodes; edges = baseEdges;
512
+ }
513
+ rebuildAdjacency();
514
+ if (isPinnedView()) layoutPinned();
515
+ buildLegend();
516
+ userAdjustedView = false;
517
+ frameCount = 0;
518
+ alpha = 1;
519
+ document.querySelectorAll(".vw").forEach(b =>
520
+ b.classList.toggle("active", b.dataset.view === name));
521
+ }
522
+ document.querySelectorAll(".vw").forEach(b =>
523
+ b.addEventListener("click", () => setView(b.dataset.view)));
524
+
525
+ const kindVisible = {};
526
+ Object.keys(KIND_COLORS).forEach(k => kindVisible[k] = true);
527
+
528
+ let view = { x: 0, y: 0, scale: 1 };
529
+ let alpha = 1;
530
+ function kick() { alpha = Math.max(alpha, 0.35); }
531
+
532
+ // --- physics ----------------------------------------------------------------
533
+ const fileCenters = {};
534
+ function step() {
535
+ if (isPinnedView()) return; // pinned views are deterministic grids
536
+ if (alpha < 0.005) return;
537
+ alpha *= 0.985;
538
+ const vis = nodes.filter(n => kindVisible[n.kind]);
539
+ // repulsion — exact for small graphs, sampled for large ones
540
+ const rep = 2600 * SP;
541
+ if (vis.length > 1200) {
542
+ const K = 25, boost = 3;
543
+ for (let i = 0; i < vis.length; i++) {
544
+ const a = vis[i];
545
+ if (a.fixed) continue;
546
+ for (let k = 0; k < K; k++) {
547
+ const b = vis[(Math.random() * vis.length) | 0];
548
+ if (b === a) continue;
549
+ let dx = a.x - b.x, dy = a.y - b.y;
550
+ let d2 = dx * dx + dy * dy;
551
+ if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; }
552
+ if (d2 > 250000 * SP) continue;
553
+ const f = (rep * boost / d2) * alpha;
554
+ const d = Math.sqrt(d2);
555
+ a.vx += (dx / d) * f; a.vy += (dy / d) * f;
556
+ }
557
+ }
558
+ } else {
559
+ for (let i = 0; i < vis.length; i++) {
560
+ const a = vis[i];
561
+ for (let j = i + 1; j < vis.length; j++) {
562
+ const b = vis[j];
563
+ let dx = a.x - b.x, dy = a.y - b.y;
564
+ let d2 = dx * dx + dy * dy;
565
+ if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; }
566
+ if (d2 > 250000 * SP) continue;
567
+ const f = (rep / d2) * alpha;
568
+ const d = Math.sqrt(d2);
569
+ const fx = (dx / d) * f, fy = (dy / d) * f;
570
+ if (!a.fixed) { a.vx += fx; a.vy += fy; }
571
+ if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
572
+ }
573
+ }
574
+ }
575
+ // springs
576
+ edges.forEach(e => {
577
+ const a = nodes[e.s], b = nodes[e.t];
578
+ if (!kindVisible[a.kind] || !kindVisible[b.kind]) return;
579
+ const dx = b.x - a.x, dy = b.y - a.y;
580
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
581
+ const base = currentView === "files" ? 260
582
+ : (a.file === b.file ? 70 : 380);
583
+ const rest = base * LK;
584
+ const f = (d - rest) * 0.006 * alpha;
585
+ const fx = (dx / d) * f, fy = (dy / d) * f;
586
+ if (!a.fixed) { a.vx += fx; a.vy += fy; }
587
+ if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
588
+ });
589
+ // same-file cohesion + cluster separation (components view only)
590
+ Object.keys(fileCenters).forEach(k => delete fileCenters[k]);
591
+ vis.forEach(n => {
592
+ const c = fileCenters[n.file] = fileCenters[n.file] || { x: 0, y: 0, count: 0 };
593
+ c.x += n.x; c.y += n.y; c.count++;
594
+ });
595
+ if (currentView === "components") {
596
+ const centerList = Object.entries(fileCenters).filter(([, c]) => c.count > 0);
597
+ for (let i = 0; i < centerList.length; i++) {
598
+ for (let j = i + 1; j < centerList.length; j++) {
599
+ const [fa, a] = centerList[i], [fb, b] = centerList[j];
600
+ const ax = a.x / a.count, ay = a.y / a.count;
601
+ const bx = b.x / b.count, by = b.y / b.count;
602
+ let dx = ax - bx, dy = ay - by;
603
+ let d = Math.hypot(dx, dy);
604
+ if (d < 1) { dx = 1; dy = 0; d = 1; }
605
+ const ra = 34 + 13 * Math.sqrt(a.count), rb = 34 + 13 * Math.sqrt(b.count);
606
+ const minD = ra + rb + 150 * SP;
607
+ if (d < minD) {
608
+ const f = ((minD - d) / d) * 0.07 * alpha;
609
+ const fx = dx * f, fy = dy * f;
610
+ vis.forEach(n => {
611
+ if (n.fixed) return;
612
+ if (n.file === fa) { n.vx += fx; n.vy += fy; }
613
+ else if (n.file === fb) { n.vx -= fx; n.vy -= fy; }
614
+ });
615
+ }
616
+ }
617
+ }
618
+ }
619
+ vis.forEach(n => {
620
+ const c = fileCenters[n.file];
621
+ if (currentView === "components" && c.count > 1 && !n.fixed) {
622
+ n.vx += ((c.x / c.count) - n.x) * 0.025 * alpha;
623
+ n.vy += ((c.y / c.count) - n.y) * 0.025 * alpha;
624
+ }
625
+ if (!n.fixed) {
626
+ n.vx += (W / 2 - n.x) * (0.0002 / SP) * alpha;
627
+ n.vy += (H / 2 - n.y) * (0.0002 / SP) * alpha;
628
+ n.vx *= 0.86; n.vy *= 0.86;
629
+ n.x += n.vx; n.y += n.vy;
630
+ }
631
+ });
632
+ }
633
+
634
+ // --- fit view ----------------------------------------------------------------
635
+ let userAdjustedView = false;
636
+ let frameCount = 0;
637
+ function fitView() {
638
+ const vis = nodes.filter(n => kindVisible[n.kind]);
639
+ if (!vis.length) return;
640
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
641
+ vis.forEach(n => {
642
+ if (n.x < minX) minX = n.x;
643
+ if (n.x > maxX) maxX = n.x;
644
+ if (n.y < minY) minY = n.y;
645
+ if (n.y > maxY) maxY = n.y;
646
+ });
647
+ const pad = 90;
648
+ const sx = W / (maxX - minX + 2 * pad), sy = (H - 110) / (maxY - minY + 2 * pad);
649
+ view.scale = Math.min(1.4, Math.max(0.08, Math.min(sx, sy)));
650
+ view.x = W / 2 - ((minX + maxX) / 2) * view.scale;
651
+ view.y = (H + 96) / 2 - ((minY + maxY) / 2) * view.scale;
652
+ }
653
+
654
+ // --- transitive tracing --------------------------------------------------------
655
+ function traceFrom(start) {
656
+ const tracedNodes = new Set([start]), tracedEdges = new Set();
657
+ [outAdj, inAdj].forEach(adj => {
658
+ const frontier = [start], visited = new Set([start]);
659
+ while (frontier.length) {
660
+ const cur = frontier.pop();
661
+ adj[cur].forEach(({ n, ei }) => {
662
+ tracedEdges.add(ei); tracedNodes.add(n);
663
+ if (!visited.has(n)) { visited.add(n); frontier.push(n); }
664
+ });
665
+ }
666
+ });
667
+ return { nodes: tracedNodes, edges: tracedEdges };
668
+ }
669
+ let traced = null;
670
+
671
+ // --- rendering ---------------------------------------------------------------
672
+ let hovered = null, selected = null, query = "";
673
+ function matches(n) { return query && n.id.toLowerCase().includes(query); }
674
+
675
+ const short = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
676
+ // text with a dark halo so labels stay legible over edges
677
+ function haloText(text, x, y, color, sizePx) {
678
+ ctx.font = `${sizePx}px sans-serif`;
679
+ ctx.strokeStyle = "rgba(15,20,32,0.85)";
680
+ ctx.lineWidth = sizePx / 3.5;
681
+ ctx.lineJoin = "round";
682
+ ctx.strokeText(text, x, y);
683
+ ctx.fillStyle = color;
684
+ ctx.fillText(text, x, y);
685
+ }
686
+
687
+ function draw() {
688
+ ctx.clearRect(0, 0, W, H);
689
+ ctx.save();
690
+ ctx.translate(view.x, view.y);
691
+ ctx.scale(view.scale, view.scale);
692
+
693
+ // file cluster hulls (components view only)
694
+ if (currentView === "components") {
695
+ const groups = {};
696
+ nodes.forEach(n => {
697
+ if (!kindVisible[n.kind]) return;
698
+ (groups[n.file] = groups[n.file] || []).push(n);
699
+ });
700
+ Object.entries(groups).forEach(([file, members]) => {
701
+ if (members.length < 2) return;
702
+ let cx = 0, cy = 0;
703
+ members.forEach(m => { cx += m.x; cy += m.y; });
704
+ cx /= members.length; cy /= members.length;
705
+ let r = 0;
706
+ members.forEach(m => {
707
+ const d = Math.hypot(m.x - cx, m.y - cy) + rad(m);
708
+ if (d > r) r = d;
709
+ });
710
+ r += 20;
711
+ ctx.fillStyle = "rgba(90,110,170,0.08)";
712
+ ctx.strokeStyle = "rgba(90,110,170,0.28)";
713
+ ctx.lineWidth = 1 / view.scale;
714
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
715
+ if (view.scale > 0.45) {
716
+ ctx.textAlign = "center";
717
+ haloText(file, cx, cy - r - 6 / view.scale, "#8ea6d8", 12 / view.scale);
718
+ ctx.textAlign = "left";
719
+ }
720
+ });
721
+ }
722
+
723
+ // layer guides (pinned views)
724
+ if (isPinnedView()) {
725
+ const maxLayer = Math.max(...nodes.map(n => pinnedLayer(n)), 0);
726
+ const guideLabel = currentView === "flow" ? "stage " : "depth ";
727
+ ctx.textAlign = "center";
728
+ for (let l = 0; l <= maxLayer; l++) {
729
+ const x = 160 + l * LAYER_GAP * LK;
730
+ ctx.strokeStyle = "rgba(90,110,170,0.12)";
731
+ ctx.lineWidth = 1 / view.scale;
732
+ ctx.beginPath(); ctx.moveTo(x, -100000); ctx.lineTo(x, 100000); ctx.stroke();
733
+ const wy = (110 - view.y) / view.scale;
734
+ haloText(guideLabel + l, x, wy, "#7484b8", 12 / view.scale);
735
+ }
736
+ if (isolatedTop !== null) {
737
+ ctx.textAlign = "left";
738
+ haloText("unconnected", 160, isolatedTop - 24, "#7484b8", 12 / view.scale);
739
+ }
740
+ ctx.textAlign = "left";
741
+ }
742
+
743
+ // Selected node: highlight the full transitive trace. Hover: 1-hop.
744
+ const focus = selected !== null ? selected : hovered;
745
+ const neighbor = new Set();
746
+ if (focus !== null && traced === null) {
747
+ edges.forEach(e => {
748
+ if (e.s === focus) neighbor.add(e.t);
749
+ if (e.t === focus) neighbor.add(e.s);
750
+ });
751
+ }
752
+ const nodeHot = i => traced ? traced.nodes.has(i)
753
+ : (focus === null || i === focus || neighbor.has(i));
754
+ const edgeHot = (e, ei) => traced ? traced.edges.has(ei)
755
+ : (focus !== null && (e.s === focus || e.t === focus));
756
+
757
+ // Dense pinned views: idle edges recede; hover/click brings a path forward.
758
+ const pinned = isPinnedView();
759
+ const dense = pinned && edges.length > 250;
760
+ edges.forEach((e, ei) => {
761
+ const a = nodes[e.s], b = nodes[e.t];
762
+ if (!kindVisible[a.kind] || !kindVisible[b.kind]) return;
763
+ const hot = edgeHot(e, ei);
764
+ const hotFocus = hot && focus !== null;
765
+ const dimmed = focus !== null && !hot;
766
+ const baseW = 0.8 + (e.w ? Math.min(4, Math.sqrt(e.w) - 1) : 0);
767
+ let idleColor = "rgba(120,140,190,0.28)";
768
+ if (currentView === "flow") {
769
+ // data/arg edges carry their type's color; commands stay faint gray
770
+ idleColor = (e.kind === "data" || e.kind === "arg") && e.type
771
+ ? typeColor(e.type) : "rgba(120,140,190,0.18)";
772
+ }
773
+ ctx.strokeStyle = hotFocus ? "#e9c46a"
774
+ : dimmed ? "rgba(120,140,190,0.08)" : idleColor;
775
+ let edgeAlpha = 1;
776
+ if (!hotFocus && !dimmed) {
777
+ if (currentView === "flow") edgeAlpha = 0.6;
778
+ if (dense) edgeAlpha = 0.18;
779
+ }
780
+ ctx.globalAlpha = edgeAlpha;
781
+ ctx.lineWidth = (hotFocus ? baseW + 1 : baseW) / view.scale;
782
+ if (e.kind === "construct" || e.kind === "command") {
783
+ ctx.setLineDash([5 / view.scale, 4 / view.scale]);
784
+ } else if (e.kind === "arg") {
785
+ ctx.setLineDash([2 / view.scale, 3 / view.scale]);
786
+ }
787
+ const dx = b.x - a.x, dy = b.y - a.y, d = Math.sqrt(dx * dx + dy * dy) || 1;
788
+ const bOffset = (currentView === "flow" && b._bw) ? Math.min(b._bw, b._bh) + 3 : rad(b) + 4;
789
+ let tx, ty, ang, midX, midY;
790
+ if (pinned) {
791
+ // horizontal-tangent S-curve (dagre-style): edges leave and enter
792
+ // columns sideways instead of slashing across the grid
793
+ const dir = Math.sign(dx) || 1;
794
+ const bend = Math.max(50, Math.abs(dx) * 0.4);
795
+ const c1x = a.x + bend * dir, c2x = b.x - bend * dir;
796
+ tx = b.x - dir * bOffset; ty = b.y;
797
+ ctx.beginPath();
798
+ ctx.moveTo(a.x, a.y);
799
+ ctx.bezierCurveTo(c1x, a.y, c2x, b.y, tx, ty);
800
+ ctx.stroke();
801
+ ang = dir > 0 ? 0 : Math.PI;
802
+ midX = (a.x + 3 * c1x + 3 * c2x + b.x) / 8;
803
+ midY = (a.y + b.y) / 2;
804
+ } else {
805
+ tx = b.x - (dx / d) * bOffset; ty = b.y - (dy / d) * bOffset;
806
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
807
+ ang = Math.atan2(dy, dx);
808
+ midX = (a.x + b.x) / 2; midY = (a.y + b.y) / 2;
809
+ }
810
+ ctx.setLineDash([]);
811
+ // arrowhead
812
+ const s = 5 / Math.sqrt(view.scale);
813
+ ctx.fillStyle = ctx.strokeStyle;
814
+ ctx.beginPath();
815
+ ctx.moveTo(tx, ty);
816
+ ctx.lineTo(tx - s * Math.cos(ang - 0.4), ty - s * Math.sin(ang - 0.4));
817
+ ctx.lineTo(tx - s * Math.cos(ang + 0.4), ty - s * Math.sin(ang + 0.4));
818
+ ctx.fill();
819
+ // data-type / weight label: in dense views only when hot or zoomed in
820
+ const wantLabel = dense ? (hot || view.scale > 1.6)
821
+ : (view.scale > 1.3 || hot || currentView === "flow");
822
+ if (e.type && !dimmed && wantLabel) {
823
+ const color = currentView === "flow" && (e.kind === "data" || e.kind === "arg")
824
+ ? typeColor(e.type)
825
+ : e.kind === "construct" ? "#b794f4" : "#8ea6d8";
826
+ haloText(short(e.type, 24), midX + 4 / view.scale, midY - 4 / view.scale,
827
+ color, 10 / view.scale);
828
+ }
829
+ ctx.globalAlpha = 1;
830
+ });
831
+
832
+ nodes.forEach((n, i) => {
833
+ if (!kindVisible[n.kind]) return;
834
+ const dim = (query && !matches(n)) || (focus !== null && !nodeHot(i));
835
+ ctx.globalAlpha = dim ? 0.16 : 1;
836
+ const kindColor = KIND_COLORS[n.kind] || KIND_COLORS.unknown;
837
+ const r = rad(n);
838
+ if (currentView === "flow" && n.kind !== "type") {
839
+ // transformation box: name + return type, bordered by kind
840
+ const name = short(n.id.split(".").slice(-1)[0], 24);
841
+ const rt = n.outputs && n.outputs[0] ? short(n.outputs[0], 22) : null;
842
+ ctx.font = "12px sans-serif";
843
+ const tw = ctx.measureText(name).width;
844
+ const rw = rt ? ctx.measureText("-> " + rt).width : 0;
845
+ const bw = Math.max(tw, rw) / 2 + 10;
846
+ const bh = rt ? 18 : 12;
847
+ n._bw = bw; n._bh = bh;
848
+ ctx.fillStyle = "#1a2238";
849
+ ctx.strokeStyle = kindColor;
850
+ ctx.lineWidth = i === selected ? 2.5 : 1.3;
851
+ ctx.beginPath();
852
+ ctx.roundRect(n.x - bw, n.y - bh, bw * 2, bh * 2, 6);
853
+ ctx.fill(); ctx.stroke();
854
+ ctx.textAlign = "center";
855
+ ctx.font = "12px sans-serif";
856
+ ctx.fillStyle = dim ? "#56618a" : "#dbe2ef";
857
+ ctx.fillText(name, n.x, n.y + (rt ? -3 : 4));
858
+ if (rt) {
859
+ ctx.font = "10px sans-serif";
860
+ ctx.fillStyle = typeColor(n.outputs[0]);
861
+ ctx.fillText("-> " + rt, n.x, n.y + 11);
862
+ }
863
+ ctx.textAlign = "left";
864
+ ctx.globalAlpha = 1;
865
+ return;
866
+ }
867
+ n._bw = null; n._bh = null;
868
+ ctx.fillStyle = kindColor;
869
+ ctx.beginPath();
870
+ if (n.kind === "type") {
871
+ ctx.rect(n.x - r, n.y - r, r * 2, r * 2);
872
+ } else {
873
+ ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
874
+ }
875
+ ctx.fill();
876
+ if (n.entrypoint) {
877
+ // outer ring: reachable from the outside world (HTTP/CLI/task)
878
+ ctx.strokeStyle = "#e9c46a";
879
+ ctx.lineWidth = 1.4 / view.scale;
880
+ ctx.beginPath();
881
+ ctx.arc(n.x, n.y, r + 3.5 / view.scale, 0, Math.PI * 2);
882
+ ctx.stroke();
883
+ }
884
+ if (i === selected) {
885
+ ctx.strokeStyle = "#fff"; ctx.lineWidth = 2 / view.scale;
886
+ ctx.beginPath();
887
+ if (n.kind === "type") ctx.rect(n.x - r, n.y - r, r * 2, r * 2);
888
+ else ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
889
+ ctx.stroke();
890
+ }
891
+ // Label thinning: only draw when rows have on-screen room (pinned
892
+ // views), or per the density rules elsewhere. Hot nodes always label.
893
+ const isHot = i === hovered || i === selected || (traced && traced.nodes.has(i));
894
+ let wantLabel;
895
+ if (isPinnedView()) {
896
+ wantLabel = isHot || pinnedPitch * view.scale >= 14;
897
+ } else if (currentView === "files") {
898
+ wantLabel = view.scale > 0.3;
899
+ } else {
900
+ wantLabel = view.scale > 0.9 && (n.deg > 2 || view.scale > 1.6 || isHot);
901
+ }
902
+ if (wantLabel && !dim) {
903
+ const label = currentView === "files" ? short(n.id, 34)
904
+ : short(n.id.split(".").slice(-2).join("."), 28);
905
+ haloText(label, n.x + r + 4 / view.scale, n.y + 3.5 / view.scale,
906
+ "#c8d3ea", 11 / view.scale);
907
+ }
908
+ ctx.globalAlpha = 1;
909
+ });
910
+ ctx.restore();
911
+ }
912
+
913
+ function frame() {
914
+ step();
915
+ frameCount++;
916
+ if (!userAdjustedView && (frameCount === 5 || frameCount === 200)) fitView();
917
+ draw();
918
+ requestAnimationFrame(frame);
919
+ }
920
+ requestAnimationFrame(frame);
921
+
922
+ // --- interactions ------------------------------------------------------------
923
+ function toWorld(px, py) {
924
+ return { x: (px - view.x) / view.scale, y: (py - view.y) / view.scale };
925
+ }
926
+ function pick(px, py) {
927
+ const p = toWorld(px, py);
928
+ for (let i = nodes.length - 1; i >= 0; i--) {
929
+ const n = nodes[i];
930
+ if (!kindVisible[n.kind]) continue;
931
+ if (currentView === "flow" && n._bw) {
932
+ if (Math.abs(p.x - n.x) <= n._bw + 3 && Math.abs(p.y - n.y) <= n._bh + 3) return i;
933
+ continue;
934
+ }
935
+ const dx = p.x - n.x, dy = p.y - n.y;
936
+ const r = rad(n) + 3;
937
+ if (dx * dx + dy * dy <= r * r) return i;
938
+ }
939
+ return null;
940
+ }
941
+
942
+ let dragNode = null, panning = false, last = { x: 0, y: 0 }, moved = false;
943
+ canvas.addEventListener("mousedown", ev => {
944
+ moved = false;
945
+ const hit = pick(ev.clientX, ev.clientY);
946
+ if (hit !== null) { dragNode = hit; nodes[hit].fixed = true; }
947
+ else { panning = true; canvas.style.cursor = "grabbing"; }
948
+ last = { x: ev.clientX, y: ev.clientY };
949
+ });
950
+ window.addEventListener("mousemove", ev => {
951
+ const tooltip = document.getElementById("tooltip");
952
+ if (dragNode !== null) {
953
+ const p = toWorld(ev.clientX, ev.clientY);
954
+ nodes[dragNode].x = p.x; nodes[dragNode].y = p.y;
955
+ nodes[dragNode].vx = 0; nodes[dragNode].vy = 0;
956
+ moved = true; kick();
957
+ } else if (panning) {
958
+ view.x += ev.clientX - last.x; view.y += ev.clientY - last.y;
959
+ last = { x: ev.clientX, y: ev.clientY }; moved = true;
960
+ userAdjustedView = true;
961
+ } else {
962
+ hovered = pick(ev.clientX, ev.clientY);
963
+ if (hovered !== null) {
964
+ const n = nodes[hovered];
965
+ tooltip.style.display = "block";
966
+ tooltip.style.left = (ev.clientX + 14) + "px";
967
+ tooltip.style.top = (ev.clientY + 14) + "px";
968
+ tooltip.textContent = n.members
969
+ ? `${n.id} — ${n.members.length} components`
970
+ : (n.entrypoint ? `${n.entrypoint} · ` : "") +
971
+ `${n.id} — ${n.kind}` + (n.effects.length ? ` [${n.effects.join(", ")}]` : "");
972
+ canvas.style.cursor = "pointer";
973
+ } else {
974
+ tooltip.style.display = "none";
975
+ canvas.style.cursor = "grab";
976
+ }
977
+ }
978
+ });
979
+ window.addEventListener("mouseup", () => {
980
+ if (dragNode !== null && !moved) select(dragNode);
981
+ else if (panning && !moved) select(null);
982
+ if (dragNode !== null) nodes[dragNode].fixed = false;
983
+ dragNode = null; panning = false;
984
+ canvas.style.cursor = "grab";
985
+ });
986
+ canvas.addEventListener("wheel", ev => {
987
+ ev.preventDefault();
988
+ userAdjustedView = true;
989
+ const factor = Math.exp(-ev.deltaY * 0.0012);
990
+ const p = toWorld(ev.clientX, ev.clientY);
991
+ view.scale = Math.min(6, Math.max(0.08, view.scale * factor));
992
+ view.x = ev.clientX - p.x * view.scale;
993
+ view.y = ev.clientY - p.y * view.scale;
994
+ }, { passive: false });
995
+
996
+ // --- detail panel -------------------------------------------------------------
997
+ function select(i) {
998
+ selected = i;
999
+ traced = i === null ? null : traceFrom(i);
1000
+ const panel = document.getElementById("detail");
1001
+ if (i === null) { panel.style.display = "none"; return; }
1002
+ const n = nodes[i];
1003
+ const esc = s => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;");
1004
+ const list = xs => xs.length
1005
+ ? "<dd>" + xs.map(esc).join("</dd><dd>") + "</dd>" : "<dd>—</dd>";
1006
+ if (n.members) {
1007
+ const outFiles = edges.filter(e => e.s === i).map(e => `${nodes[e.t].id} (${e.w})`);
1008
+ const inFiles = edges.filter(e => e.t === i).map(e => `${nodes[e.s].id} (${e.w})`);
1009
+ document.getElementById("detail-body").innerHTML = `
1010
+ <h2>${esc(n.id)}</h2>
1011
+ <dl>
1012
+ <dt>calls into</dt>${list(outFiles)}
1013
+ <dt>called from</dt>${list(inFiles)}
1014
+ <dt>components (${n.members.length})</dt>${list(n.members)}
1015
+ </dl>`;
1016
+ } else {
1017
+ const callers = edges.filter(e => e.t === i && e.kind === "call").map(e => nodes[e.s].id);
1018
+ document.getElementById("detail-body").innerHTML = `
1019
+ <h2>${esc(n.id)}</h2>
1020
+ <dl>
1021
+ <dt>kind</dt><dd>${esc(n.kind)}</dd>
1022
+ <dt>entrypoint</dt><dd>${esc(n.entrypoint || "—")}</dd>
1023
+ <dt>purity</dt><dd>${n.purity == null ? "—" : n.purity}</dd>
1024
+ <dt>signature</dt><dd>${esc(n.signature || "—")}</dd>
1025
+ <dt>returns</dt>${list(n.outputs || [])}
1026
+ <dt>effects</dt>${list(n.effects)}
1027
+ <dt>inputs</dt>${list(n.inputs)}
1028
+ <dt>calls</dt>${list(n.calls)}
1029
+ <dt>constructs</dt>${list(n.constructs || [])}
1030
+ <dt>called by</dt>${list(callers)}
1031
+ <dt>trace</dt>${list(n.trace)}
1032
+ </dl>`;
1033
+ }
1034
+ panel.style.display = "block";
1035
+ }
1036
+ document.getElementById("close").addEventListener("click", () => select(null));
1037
+
1038
+ // --- search, fit, sliders, legend ----------------------------------------------
1039
+ document.getElementById("search").addEventListener("input", ev => {
1040
+ query = ev.target.value.trim().toLowerCase();
1041
+ });
1042
+ document.getElementById("fit").addEventListener("click", () => {
1043
+ userAdjustedView = false;
1044
+ fitView();
1045
+ });
1046
+ window.addEventListener("keydown", ev => {
1047
+ if (ev.key === "f" && document.activeElement !== document.getElementById("search")) {
1048
+ fitView();
1049
+ }
1050
+ });
1051
+ document.getElementById("s-spacing").addEventListener("input", ev => {
1052
+ SP = +ev.target.value; kick();
1053
+ if (!userAdjustedView) fitView();
1054
+ });
1055
+ document.getElementById("s-links").addEventListener("input", ev => {
1056
+ LK = +ev.target.value; kick();
1057
+ if (isPinnedView()) layoutPinned();
1058
+ if (!userAdjustedView) fitView();
1059
+ });
1060
+ document.getElementById("s-size").addEventListener("input", ev => {
1061
+ SZ = +ev.target.value;
1062
+ });
1063
+
1064
+ const legend = document.getElementById("legend");
1065
+ function buildLegend() {
1066
+ legend.innerHTML = "";
1067
+ const counts = {};
1068
+ nodes.forEach(n => { counts[n.kind] = (counts[n.kind] || 0) + 1; });
1069
+ Object.entries(KIND_COLORS).forEach(([kind, color]) => {
1070
+ if (!counts[kind]) return;
1071
+ const el = document.createElement("div");
1072
+ el.className = "lg" + (kindVisible[kind] ? "" : " off");
1073
+ el.innerHTML = `<span class="dot" style="background:${color}"></span>` +
1074
+ `${kind} (${counts[kind]})`;
1075
+ el.addEventListener("click", () => {
1076
+ kindVisible[kind] = !kindVisible[kind];
1077
+ el.classList.toggle("off", !kindVisible[kind]);
1078
+ kick();
1079
+ });
1080
+ legend.appendChild(el);
1081
+ });
1082
+ }
1083
+ buildLegend();
1084
+ </script>
1085
+ </body>
1086
+ </html>
1087
+ """