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.
- cgir/__init__.py +6 -0
- cgir/analyses/__init__.py +7 -0
- cgir/analyses/call_graph.py +114 -0
- cgir/analyses/cfg.py +320 -0
- cgir/analyses/effects.py +95 -0
- cgir/analyses/entrypoints.py +55 -0
- cgir/analyses/param_flow.py +75 -0
- cgir/analyses/pdg.py +75 -0
- cgir/analyses/purity.py +37 -0
- cgir/analyses/reaching_defs.py +115 -0
- cgir/analyses/symbols.py +106 -0
- cgir/api/__init__.py +1 -0
- cgir/api/mcp_server.py +172 -0
- cgir/api/server.py +107 -0
- cgir/cli.py +688 -0
- cgir/config.py +22 -0
- cgir/export/__init__.py +5 -0
- cgir/export/graphml.py +52 -0
- cgir/export/html_viz.py +1087 -0
- cgir/export/json_export.py +37 -0
- cgir/export/mermaid.py +57 -0
- cgir/export/neo4j.py +11 -0
- cgir/hooks.py +189 -0
- cgir/ir/__init__.py +16 -0
- cgir/ir/component_spec.py +100 -0
- cgir/ir/edges.py +31 -0
- cgir/ir/graph.py +132 -0
- cgir/ir/nodes.py +38 -0
- cgir/languages/__init__.py +25 -0
- cgir/languages/base.py +221 -0
- cgir/languages/cache.py +73 -0
- cgir/languages/python.py +1145 -0
- cgir/languages/registry.py +24 -0
- cgir/languages/typescript.py +853 -0
- cgir/manifest.py +77 -0
- cgir/pipeline.py +54 -0
- cgir/py.typed +0 -0
- cgir/regenerate/__init__.py +6 -0
- cgir/regenerate/prompt_pack.py +16 -0
- cgir/regenerate/regenerator.py +108 -0
- cgir/report/__init__.py +5 -0
- cgir/report/diff.py +226 -0
- cgir/report/flow.py +84 -0
- cgir/report/impact.py +234 -0
- cgir/report/lint.py +84 -0
- cgir/report/pack.py +271 -0
- cgir/report/stats.py +121 -0
- cgir/slicing/__init__.py +5 -0
- cgir/slicing/slicer.py +174 -0
- cgir/sources/__init__.py +6 -0
- cgir/sources/base.py +14 -0
- cgir/sources/codeql_source.py +13 -0
- cgir/sources/joern_source.py +13 -0
- cgir/sources/tree_sitter_source.py +270 -0
- cgir/trace/__init__.py +5 -0
- cgir/trace/trace_map.py +83 -0
- cgir/verify.py +173 -0
- cgir/watch.py +171 -0
- codegraph_ir-0.1.0.dist-info/METADATA +143 -0
- codegraph_ir-0.1.0.dist-info/RECORD +63 -0
- codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
- codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
- codegraph_ir-0.1.0.dist-info/licenses/LICENSE +21 -0
cgir/export/html_viz.py
ADDED
|
@@ -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, "&").replace(/</g, "<");
|
|
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
|
+
"""
|