@descryy/viz 0.1.0

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.
package/public/app.js ADDED
@@ -0,0 +1,686 @@
1
+ // descry viz — vanilla, no framework, no bundler, no CDN, no network call this
2
+ // page does not itself originate against its own local server. Canvas 2D
3
+ // rendering, chosen over SVG for legibility at the realistic working set
4
+ // (50-200 visible nodes) with simple hand-rolled hit-testing.
5
+
6
+ const NODE_TYPES = [
7
+ "FILE", "FUNCTION", "CLASS", "MODULE", "API_ENDPOINT", "API_ROUTE",
8
+ "DATABASE_TABLE", "DATABASE_COLUMN", "MODEL", "DTO", "COMPONENT", "PAGE",
9
+ "TEST_CASE", "INCIDENT", "FIX_PATTERN",
10
+ ];
11
+
12
+ const EDGE_TYPES = [
13
+ "CALLS", "IMPORTS", "READS", "WRITES", "RETURNS", "IMPLEMENTS", "INHERITS",
14
+ "USES_API", "SERVES_API", "USES_TYPE", "PROPAGATES_TO", "TESTS", "FIXED_BY",
15
+ "CHANGES_WITH", "INCIDENT_CORRELATED",
16
+ ];
17
+
18
+ const API_JOIN_TYPES = new Set(["USES_API", "SERVES_API"]);
19
+
20
+ const NODE_COLORS = {
21
+ FILE: "#8890a4", FUNCTION: "#5da9ff", CLASS: "#8f7ee6", MODULE: "#5fc2c9",
22
+ API_ENDPOINT: "#ffb454", API_ROUTE: "#ff9f5a", DATABASE_TABLE: "#e67e9e",
23
+ DATABASE_COLUMN: "#e67e9e", MODEL: "#56d364", DTO: "#7ee6a8",
24
+ COMPONENT: "#5da9ff", PAGE: "#5da9ff", TEST_CASE: "#c9c9c9",
25
+ INCIDENT: "#ff6b6b", FIX_PATTERN: "#ff6b6b",
26
+ };
27
+
28
+ const state = {
29
+ nodes: new Map(), // id -> node (as returned by the API, includes reliability)
30
+ edges: [], // {from, to, type, resolution, confidence, producedBy, observedByRun, attrs}
31
+ seeds: [],
32
+ positions: new Map(), // id -> {x, y}
33
+ transform: { x: 0, y: 0, scale: 1 },
34
+ selected: null, // {kind: 'node'|'edge', id or edgeKey}
35
+ highlightMode: null, // 'upstream' | 'downstream' | null
36
+ filters: {
37
+ nodeTypes: new Set(NODE_TYPES),
38
+ edgeTypes: new Set(EDGE_TYPES),
39
+ languages: new Set(), // populated from the loaded subgraph; empty set = show all
40
+ },
41
+ depth: 1,
42
+ direction: "both",
43
+ drag: null,
44
+ hover: null,
45
+ };
46
+
47
+ const els = {};
48
+ for (const id of [
49
+ "search-input", "search-results", "stamp", "node-type-filters",
50
+ "edge-type-filters", "language-filters", "export-btn", "notes",
51
+ "graph-canvas", "empty-state", "hover-tooltip", "inspector-empty",
52
+ "inspector-content", "depth-range", "depth-value", "direction-select",
53
+ ]) {
54
+ els[toCamel(id)] = document.getElementById(id);
55
+ }
56
+ function toCamel(kebab) {
57
+ return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
58
+ }
59
+
60
+ const ctx = els.graphCanvas.getContext("2d");
61
+
62
+ async function fetchJson(path) {
63
+ const res = await fetch(path);
64
+ const body = await res.json();
65
+ if (!res.ok) throw new Error(body.error ?? `${path} failed (${res.status})`);
66
+ return body;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Search
71
+ // ---------------------------------------------------------------------------
72
+
73
+ let searchDebounce = null;
74
+ els.searchInput.addEventListener("input", () => {
75
+ clearTimeout(searchDebounce);
76
+ const q = els.searchInput.value;
77
+ if (q.trim() === "") {
78
+ els.searchResults.hidden = true;
79
+ return;
80
+ }
81
+ searchDebounce = setTimeout(() => runSearch(q), 150);
82
+ });
83
+
84
+ document.addEventListener("click", (e) => {
85
+ if (!els.searchResults.contains(e.target) && e.target !== els.searchInput) {
86
+ els.searchResults.hidden = true;
87
+ }
88
+ });
89
+
90
+ async function runSearch(q) {
91
+ let result;
92
+ try {
93
+ result = await fetchJson(`/api/search?q=${encodeURIComponent(q)}`);
94
+ } catch (err) {
95
+ els.searchResults.innerHTML = `<div class="search-hit">${escapeHtml(err.message)}</div>`;
96
+ els.searchResults.hidden = false;
97
+ return;
98
+ }
99
+ if (result.nodes.length === 0) {
100
+ els.searchResults.innerHTML = `<div class="search-hit">No match for "${escapeHtml(q)}".</div>`;
101
+ els.searchResults.hidden = false;
102
+ return;
103
+ }
104
+ els.searchResults.innerHTML = "";
105
+ for (const node of result.nodes) {
106
+ const row = document.createElement("div");
107
+ row.className = "search-hit";
108
+ row.innerHTML = `
109
+ <div>
110
+ <div class="name">${escapeHtml(node.name)}</div>
111
+ ${node.file ? `<span class="file">${escapeHtml(node.file)}</span>` : ""}
112
+ </div>
113
+ <div class="meta">${node.type}${node.language ? " · " + node.language : ""}</div>
114
+ `;
115
+ row.addEventListener("click", () => {
116
+ els.searchResults.hidden = true;
117
+ els.searchInput.value = node.name;
118
+ loadSubgraph([node.id]);
119
+ });
120
+ els.searchResults.appendChild(row);
121
+ }
122
+ els.searchResults.hidden = false;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Filters
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function renderTypeChips(container, allTypes, activeSet, onToggle) {
130
+ container.innerHTML = "";
131
+ for (const t of allTypes) {
132
+ const chip = document.createElement("span");
133
+ chip.className = "chip" + (activeSet.has(t) ? " active" : "");
134
+ chip.textContent = t;
135
+ chip.addEventListener("click", () => {
136
+ if (activeSet.has(t)) activeSet.delete(t);
137
+ else activeSet.add(t);
138
+ chip.classList.toggle("active");
139
+ onToggle();
140
+ });
141
+ container.appendChild(chip);
142
+ }
143
+ }
144
+
145
+ renderTypeChips(els.nodeTypeFilters, NODE_TYPES, state.filters.nodeTypes, applyFiltersAndRender);
146
+ renderTypeChips(els.edgeTypeFilters, EDGE_TYPES, state.filters.edgeTypes, () => {
147
+ if (state.seeds.length > 0) loadSubgraph(state.seeds);
148
+ });
149
+
150
+ els.depthRange.addEventListener("input", () => {
151
+ state.depth = Number(els.depthRange.value);
152
+ els.depthValue.textContent = String(state.depth);
153
+ });
154
+ els.depthRange.addEventListener("change", () => {
155
+ if (state.seeds.length > 0) loadSubgraph(state.seeds);
156
+ });
157
+ els.directionSelect.addEventListener("change", () => {
158
+ state.direction = els.directionSelect.value;
159
+ if (state.seeds.length > 0) loadSubgraph(state.seeds);
160
+ });
161
+
162
+ function applyFiltersAndRender() {
163
+ render();
164
+ }
165
+
166
+ function visibleNodeIds() {
167
+ const out = new Set();
168
+ for (const [id, node] of state.nodes) {
169
+ if (!state.filters.nodeTypes.has(node.type)) continue;
170
+ if (state.filters.languages.size > 0 && node.language && !state.filters.languages.has(node.language)) continue;
171
+ out.add(id);
172
+ }
173
+ // Seeds are always shown, even if a filter would otherwise hide them —
174
+ // hiding the thing that was searched for is a worse failure than a filter
175
+ // that looks slightly inconsistent.
176
+ for (const s of state.seeds) out.add(s);
177
+ return out;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Loading a subgraph
182
+ // ---------------------------------------------------------------------------
183
+
184
+ async function loadSubgraph(seedIds) {
185
+ const edgeTypes = [...state.filters.edgeTypes].join(",");
186
+ const url =
187
+ `/api/subgraph?ids=${seedIds.map(encodeURIComponent).join(",")}` +
188
+ `&depth=${state.depth}&direction=${state.direction}` +
189
+ (edgeTypes ? `&edgeTypes=${encodeURIComponent(edgeTypes)}` : "");
190
+
191
+ let result;
192
+ try {
193
+ result = await fetchJson(url);
194
+ } catch (err) {
195
+ els.notes.innerHTML = `<p>${escapeHtml(err.message)}</p>`;
196
+ return;
197
+ }
198
+
199
+ state.nodes = new Map(result.nodes.map((n) => [n.id, n]));
200
+ state.edges = result.edges;
201
+ state.seeds = seedIds;
202
+ state.selected = null;
203
+
204
+ const languages = new Set(result.nodes.map((n) => n.language).filter(Boolean));
205
+ state.filters.languages = new Set(); // reset to "show all" on every fresh load
206
+ renderTypeChips(els.languageFilters, [...languages].sort(), state.filters.languages, applyFiltersAndRender);
207
+ if (languages.size === 0) {
208
+ els.languageFilters.innerHTML = '<span class="chip-note">No language on any node in this view (e.g. API_ENDPOINT nodes only).</span>';
209
+ }
210
+
211
+ els.notes.innerHTML = result.notes.map((n) => `<p>${escapeHtml(n)}</p>`).join("");
212
+ els.stamp.textContent = result.stamp.commitSha
213
+ ? `commit ${result.stamp.commitSha.slice(0, 8)} · resolution floor R${result.stamp.resolutionFloor}` +
214
+ (result.truncated ? " · TRUNCATED" : "")
215
+ : "empty graph";
216
+
217
+ computeLayout();
218
+ fitToView();
219
+ els.emptyState.style.display = state.nodes.size === 0 ? "flex" : "none";
220
+ els.exportBtn.disabled = state.nodes.size === 0;
221
+ selectNode(seedIds[0]);
222
+ render();
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Layout — depth-ordered columns, the "legible chain" shape rather than a
227
+ // force-directed cloud. Depth is recomputed here (a plain BFS over the
228
+ // already-small, already-fetched node/edge set — a UI layout concern, not a
229
+ // second traversal engine) because the API returns nodes and edges, not a
230
+ // per-node depth; recomputing costs nothing at this size.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function computeLayout() {
234
+ const adjacency = new Map();
235
+ for (const id of state.nodes.keys()) adjacency.set(id, []);
236
+ for (const e of state.edges) {
237
+ adjacency.get(e.from)?.push(e.to);
238
+ adjacency.get(e.to)?.push(e.from);
239
+ }
240
+
241
+ const depth = new Map();
242
+ const queue = [];
243
+ for (const s of state.seeds) {
244
+ if (!state.nodes.has(s)) continue;
245
+ depth.set(s, 0);
246
+ queue.push(s);
247
+ }
248
+ for (let head = 0; head < queue.length; head += 1) {
249
+ const cur = queue[head];
250
+ for (const next of adjacency.get(cur) ?? []) {
251
+ if (depth.has(next)) continue;
252
+ depth.set(next, depth.get(cur) + 1);
253
+ queue.push(next);
254
+ }
255
+ }
256
+ // Anything unreached from a seed (should not happen given the API only
257
+ // returns the seed's own reachable set, but a defensive default keeps
258
+ // layout from throwing on an edge case) sits in its own trailing column.
259
+ for (const id of state.nodes.keys()) if (!depth.has(id)) depth.set(id, 0);
260
+
261
+ const byDepth = new Map();
262
+ for (const [id, d] of depth) {
263
+ if (!byDepth.has(d)) byDepth.set(d, []);
264
+ byDepth.get(d).push(id);
265
+ }
266
+
267
+ const COL_SPACING = 220;
268
+ const ROW_SPACING = 70;
269
+ state.positions = new Map();
270
+ const depths = [...byDepth.keys()].sort((a, b) => a - b);
271
+ for (const d of depths) {
272
+ const ids = byDepth.get(d).sort();
273
+ const totalHeight = (ids.length - 1) * ROW_SPACING;
274
+ ids.forEach((id, i) => {
275
+ state.positions.set(id, {
276
+ x: d * COL_SPACING,
277
+ y: i * ROW_SPACING - totalHeight / 2,
278
+ });
279
+ });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Frame the whole loaded subgraph inside the canvas's own visible box (the
285
+ * middle grid column, between the filter sidebar and the inspector — never
286
+ * the full window). Without this, a legible depth-1 view stays framed
287
+ * correctly by luck alone, and a wider one runs past the canvas's right
288
+ * edge with no way back short of the user finding the right scroll/zoom by
289
+ * hand — exactly the "pushed its own neighbours off-screen" failure a
290
+ * search result must not produce.
291
+ */
292
+ function fitToView() {
293
+ if (state.positions.size === 0) return;
294
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
295
+ for (const pos of state.positions.values()) {
296
+ minX = Math.min(minX, pos.x);
297
+ maxX = Math.max(maxX, pos.x);
298
+ minY = Math.min(minY, pos.y);
299
+ maxY = Math.max(maxY, pos.y);
300
+ }
301
+ const rect = els.graphCanvas.getBoundingClientRect();
302
+ const labelPad = 60; // room for the node radius, its label below it, and breathing room
303
+ const contentW = Math.max(1, maxX - minX) + NODE_RADIUS * 2 + labelPad * 2;
304
+ const contentH = Math.max(1, maxY - minY) + NODE_RADIUS * 2 + labelPad * 2;
305
+ const scale = Math.min(rect.width / contentW, rect.height / contentH);
306
+ state.transform.scale = Math.max(0.15, Math.min(1.5, scale));
307
+ state.transform.x = -(minX + maxX) / 2;
308
+ state.transform.y = -(minY + maxY) / 2;
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Rendering
313
+ // ---------------------------------------------------------------------------
314
+
315
+ function resizeCanvas() {
316
+ const rect = els.graphCanvas.parentElement.getBoundingClientRect();
317
+ const dpr = window.devicePixelRatio || 1;
318
+ els.graphCanvas.width = rect.width * dpr;
319
+ els.graphCanvas.height = rect.height * dpr;
320
+ els.graphCanvas.style.width = `${rect.width}px`;
321
+ els.graphCanvas.style.height = `${rect.height}px`;
322
+ render();
323
+ }
324
+ window.addEventListener("resize", resizeCanvas);
325
+
326
+ function worldToScreen(x, y) {
327
+ const rect = els.graphCanvas.getBoundingClientRect();
328
+ return {
329
+ x: rect.width / 2 + (x + state.transform.x) * state.transform.scale,
330
+ y: rect.height / 2 + (y + state.transform.y) * state.transform.scale,
331
+ };
332
+ }
333
+ function screenToWorld(sx, sy) {
334
+ const rect = els.graphCanvas.getBoundingClientRect();
335
+ return {
336
+ x: (sx - rect.width / 2) / state.transform.scale - state.transform.x,
337
+ y: (sy - rect.height / 2) / state.transform.scale - state.transform.y,
338
+ };
339
+ }
340
+
341
+ const NODE_RADIUS = 18;
342
+
343
+ function highlightSet() {
344
+ if (state.selected?.kind !== "node" || state.highlightMode === null) return null;
345
+ const startId = state.selected.id;
346
+ const dir = state.highlightMode; // 'upstream' follows incoming edges, 'downstream' follows outgoing
347
+ const out = new Set([startId]);
348
+ const queue = [startId];
349
+ while (queue.length > 0) {
350
+ const cur = queue.shift();
351
+ for (const e of state.edges) {
352
+ if (dir === "downstream" && e.from === cur && !out.has(e.to)) { out.add(e.to); queue.push(e.to); }
353
+ if (dir === "upstream" && e.to === cur && !out.has(e.from)) { out.add(e.from); queue.push(e.from); }
354
+ }
355
+ }
356
+ return out;
357
+ }
358
+
359
+ function render() {
360
+ const dpr = window.devicePixelRatio || 1;
361
+ ctx.save();
362
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
363
+ ctx.clearRect(0, 0, els.graphCanvas.clientWidth, els.graphCanvas.clientHeight);
364
+
365
+ const shown = visibleNodeIds();
366
+ const hl = highlightSet();
367
+
368
+ // Edges first, under the nodes.
369
+ for (const e of state.edges) {
370
+ if (!shown.has(e.from) || !shown.has(e.to)) continue;
371
+ if (!state.filters.edgeTypes.has(e.type)) continue;
372
+ const from = state.positions.get(e.from);
373
+ const to = state.positions.get(e.to);
374
+ if (!from || !to) continue;
375
+ const p1 = worldToScreen(from.x, from.y);
376
+ const p2 = worldToScreen(to.x, to.y);
377
+
378
+ const dimmed = hl !== null && !(hl.has(e.from) && hl.has(e.to));
379
+ const isSelected = state.selected?.kind === "edge" && state.selected.id === edgeKey(e);
380
+ const isJoin = API_JOIN_TYPES.has(e.type);
381
+
382
+ ctx.beginPath();
383
+ ctx.moveTo(p1.x, p1.y);
384
+ ctx.lineTo(p2.x, p2.y);
385
+ ctx.lineWidth = isSelected ? 3 : isJoin ? 2.2 : 1.4;
386
+ ctx.strokeStyle = isSelected
387
+ ? "#ffffff"
388
+ : dimmed
389
+ ? "rgba(120,128,148,0.15)"
390
+ : isJoin
391
+ ? "#ffb454"
392
+ : "rgba(93,169,255,0.55)";
393
+ // Reserved visual channel for the runtime layer: resolution 4 means this
394
+ // edge was actually observed firing, not just statically inferred. Dashed
395
+ // = static-only; solid = observed. "Failed here" has no data source yet
396
+ // (no runtime evidence field exists) and is deliberately not drawn —
397
+ // inventing a look for a fact the graph does not carry would be a lie.
398
+ ctx.setLineDash(e.resolution === 4 ? [] : [5, 4]);
399
+ ctx.stroke();
400
+ ctx.setLineDash([]);
401
+
402
+ // Arrowhead.
403
+ const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
404
+ const ah = 7;
405
+ const ex = p2.x - Math.cos(angle) * (NODE_RADIUS + 2);
406
+ const ey = p2.y - Math.sin(angle) * (NODE_RADIUS + 2);
407
+ ctx.beginPath();
408
+ ctx.moveTo(ex, ey);
409
+ ctx.lineTo(ex - ah * Math.cos(angle - 0.4), ey - ah * Math.sin(angle - 0.4));
410
+ ctx.lineTo(ex - ah * Math.cos(angle + 0.4), ey - ah * Math.sin(angle + 0.4));
411
+ ctx.closePath();
412
+ ctx.fillStyle = ctx.strokeStyle;
413
+ ctx.fill();
414
+ }
415
+
416
+ // Nodes.
417
+ for (const [id, node] of state.nodes) {
418
+ if (!shown.has(id)) continue;
419
+ const pos = state.positions.get(id);
420
+ if (!pos) continue;
421
+ const p = worldToScreen(pos.x, pos.y);
422
+ const dimmed = hl !== null && !hl.has(id);
423
+ const isSelected = state.selected?.kind === "node" && state.selected.id === id;
424
+ const isSeed = state.seeds.includes(id);
425
+ const isJoinNode = node.type === "API_ENDPOINT";
426
+
427
+ ctx.beginPath();
428
+ ctx.arc(p.x, p.y, NODE_RADIUS, 0, Math.PI * 2);
429
+ ctx.fillStyle = dimmed ? "rgba(70,75,90,0.4)" : (NODE_COLORS[node.type] ?? "#888");
430
+ ctx.globalAlpha = dimmed ? 0.4 : 1;
431
+ ctx.fill();
432
+ if (isJoinNode) {
433
+ ctx.lineWidth = 2.5;
434
+ ctx.strokeStyle = "#ffb454";
435
+ ctx.stroke();
436
+ }
437
+ if (isSelected || isSeed) {
438
+ ctx.lineWidth = isSelected ? 3 : 2;
439
+ ctx.strokeStyle = isSelected ? "#ffffff" : "#5da9ff";
440
+ ctx.stroke();
441
+ }
442
+ ctx.globalAlpha = 1;
443
+
444
+ ctx.fillStyle = dimmed ? "rgba(180,186,200,0.35)" : "#e6e8ee";
445
+ ctx.font = "11px -apple-system, sans-serif";
446
+ ctx.textAlign = "center";
447
+ const label = node.name.length > 22 ? node.name.slice(0, 21) + "…" : node.name;
448
+ ctx.fillText(label, p.x, p.y + NODE_RADIUS + 13);
449
+ }
450
+
451
+ ctx.restore();
452
+ }
453
+
454
+ function edgeKey(e) { return `${e.from}${e.to}${e.type}`; }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Interaction: pan, zoom, click, hover
458
+ // ---------------------------------------------------------------------------
459
+
460
+ els.graphCanvas.addEventListener("wheel", (e) => {
461
+ e.preventDefault();
462
+ const rect = els.graphCanvas.getBoundingClientRect();
463
+ const before = screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
464
+ const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
465
+ state.transform.scale = Math.max(0.15, Math.min(4, state.transform.scale * factor));
466
+ const after = screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
467
+ state.transform.x += after.x - before.x;
468
+ state.transform.y += after.y - before.y;
469
+ render();
470
+ }, { passive: false });
471
+
472
+ els.graphCanvas.addEventListener("mousedown", (e) => {
473
+ state.drag = { startX: e.clientX, startY: e.clientY, origX: state.transform.x, origY: state.transform.y, moved: false };
474
+ els.graphCanvas.classList.add("dragging");
475
+ });
476
+ window.addEventListener("mousemove", (e) => {
477
+ if (state.drag) {
478
+ const dx = (e.clientX - state.drag.startX) / state.transform.scale;
479
+ const dy = (e.clientY - state.drag.startY) / state.transform.scale;
480
+ if (Math.abs(dx) + Math.abs(dy) > 2) state.drag.moved = true;
481
+ state.transform.x = state.drag.origX + dx;
482
+ state.transform.y = state.drag.origY + dy;
483
+ render();
484
+ return;
485
+ }
486
+ handleHover(e);
487
+ });
488
+ window.addEventListener("mouseup", (e) => {
489
+ const wasDrag = state.drag?.moved;
490
+ state.drag = null;
491
+ els.graphCanvas.classList.remove("dragging");
492
+ if (!wasDrag) handleClick(e);
493
+ });
494
+
495
+ function pointAt(e) {
496
+ const rect = els.graphCanvas.getBoundingClientRect();
497
+ return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
498
+ }
499
+
500
+ function nodeAt(sx, sy) {
501
+ const shown = visibleNodeIds();
502
+ for (const [id, _node] of state.nodes) {
503
+ if (!shown.has(id)) continue;
504
+ const pos = state.positions.get(id);
505
+ if (!pos) continue;
506
+ const p = worldToScreen(pos.x, pos.y);
507
+ if (Math.hypot(p.x - sx, p.y - sy) <= NODE_RADIUS + 3) return id;
508
+ }
509
+ return null;
510
+ }
511
+
512
+ function edgeAt(sx, sy) {
513
+ const shown = visibleNodeIds();
514
+ for (const e of state.edges) {
515
+ if (!shown.has(e.from) || !shown.has(e.to)) continue;
516
+ const from = state.positions.get(e.from);
517
+ const to = state.positions.get(e.to);
518
+ if (!from || !to) continue;
519
+ const p1 = worldToScreen(from.x, from.y);
520
+ const p2 = worldToScreen(to.x, to.y);
521
+ const dist = distanceToSegment(sx, sy, p1.x, p1.y, p2.x, p2.y);
522
+ if (dist <= 5) return e;
523
+ }
524
+ return null;
525
+ }
526
+
527
+ function distanceToSegment(px, py, x1, y1, x2, y2) {
528
+ const dx = x2 - x1, dy = y2 - y1;
529
+ const lenSq = dx * dx + dy * dy;
530
+ let t = lenSq === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / lenSq;
531
+ t = Math.max(0, Math.min(1, t));
532
+ const cx = x1 + t * dx, cy = y1 + t * dy;
533
+ return Math.hypot(px - cx, py - cy);
534
+ }
535
+
536
+ function handleClick(e) {
537
+ const { sx, sy } = pointAt(e);
538
+ const hitNode = nodeAt(sx, sy);
539
+ if (hitNode) return selectNode(hitNode);
540
+ const hitEdge = edgeAt(sx, sy);
541
+ if (hitEdge) return selectEdge(hitEdge);
542
+ state.selected = null;
543
+ state.highlightMode = null;
544
+ renderInspectorEmpty();
545
+ render();
546
+ }
547
+
548
+ function handleHover(e) {
549
+ const { sx, sy } = pointAt(e);
550
+ const hitNode = nodeAt(sx, sy);
551
+ if (hitNode) {
552
+ const node = state.nodes.get(hitNode);
553
+ els.hoverTooltip.textContent = `${node.type} · ${node.name}`;
554
+ els.hoverTooltip.style.left = `${sx + 14}px`;
555
+ els.hoverTooltip.style.top = `${sy + 10}px`;
556
+ els.hoverTooltip.hidden = false;
557
+ return;
558
+ }
559
+ els.hoverTooltip.hidden = true;
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Inspector — node/edge detail, "why is this connected"
564
+ // ---------------------------------------------------------------------------
565
+
566
+ function renderInspectorEmpty() {
567
+ els.inspectorEmpty.hidden = false;
568
+ els.inspectorContent.hidden = true;
569
+ }
570
+
571
+ function selectNode(id) {
572
+ const node = state.nodes.get(id);
573
+ if (!node) return;
574
+ state.selected = { kind: "node", id };
575
+ els.inspectorEmpty.hidden = true;
576
+ els.inspectorContent.hidden = false;
577
+
578
+ const outgoing = state.edges.filter((e) => e.from === id);
579
+ const incoming = state.edges.filter((e) => e.to === id);
580
+
581
+ els.inspectorContent.innerHTML = `
582
+ <h2>${escapeHtml(node.name)}</h2>
583
+ <span class="kind-badge">${node.type}</span>
584
+ ${fieldRow("File", node.file ?? "—")}
585
+ ${fieldRow("Range", node.range ? `L${node.range.startLine}–${node.range.endLine}` : "—")}
586
+ ${fieldRow("Language", node.language ?? "—")}
587
+ ${fieldRow("Resolution", `R${node.resolution}`)}
588
+ ${fieldRow("Reliability", `<span class="reliability-${node.reliability}">${node.reliability}</span>`, true)}
589
+ ${fieldRow("Produced by", node.producedBy)}
590
+ ${fieldRow("Node id", `<code style="font-size:10px">${escapeHtml(node.id)}</code>`, true)}
591
+ <div class="section-title">attrs</div>
592
+ <pre>${escapeHtml(JSON.stringify(node.attrs ?? {}, null, 2))}</pre>
593
+ <div class="section-title">Dependencies (${outgoing.length + incoming.length} edge(s) in this view)</div>
594
+ <div>
595
+ <button class="link-btn" id="hl-downstream">Highlight downstream (${outgoing.length})</button><br/>
596
+ <button class="link-btn" id="hl-upstream">Highlight upstream (${incoming.length})</button><br/>
597
+ <button class="link-btn" id="hl-clear">Clear highlight</button>
598
+ </div>
599
+ `;
600
+ document.getElementById("hl-downstream").addEventListener("click", () => { state.highlightMode = "downstream"; render(); });
601
+ document.getElementById("hl-upstream").addEventListener("click", () => { state.highlightMode = "upstream"; render(); });
602
+ document.getElementById("hl-clear").addEventListener("click", () => { state.highlightMode = null; render(); });
603
+
604
+ render();
605
+ }
606
+
607
+ function selectEdge(edge) {
608
+ state.selected = { kind: "edge", id: edgeKey(edge) };
609
+ els.inspectorEmpty.hidden = true;
610
+ els.inspectorContent.hidden = false;
611
+
612
+ const fromNode = state.nodes.get(edge.from);
613
+ const toNode = state.nodes.get(edge.to);
614
+ const isJoin = API_JOIN_TYPES.has(edge.type);
615
+
616
+ const attrsEntries = Object.entries(edge.attrs ?? {});
617
+ const whyKey = attrsEntries.find(([k]) => /^(why|reason|rule)$/i.test(k));
618
+
619
+ els.inspectorContent.innerHTML = `
620
+ <h2>${edge.type}</h2>
621
+ <span class="kind-badge">${isJoin ? "cross-language join edge" : "edge"}</span>
622
+ ${fieldRow("From", fromNode ? fromNode.name : edge.from)}
623
+ ${fieldRow("To", toNode ? toNode.name : edge.to)}
624
+ ${fieldRow("Resolution", `R${edge.resolution}`)}
625
+ ${fieldRow("Confidence", edge.confidence.toFixed(2))}
626
+ ${fieldRow("Produced by", edge.producedBy)}
627
+ ${fieldRow("Observed by run", edge.observedByRun ? "yes — " + edge.observedByRun : (edge.resolution === 4 ? "yes" : "no — static inference only"))}
628
+ <div class="section-title">Why is this connected?</div>
629
+ <div class="why-connected">
630
+ ${whyKey
631
+ ? escapeHtml(String(whyKey[1]))
632
+ : attrsEntries.length > 0
633
+ ? "No explicit reason field on this edge — full evidence below."
634
+ : "This edge carries no attrs beyond the core provenance fields above (resolution, confidence, producedBy). That is the entire evidence trail the graph stores for it."}
635
+ </div>
636
+ ${attrsEntries.length > 0 ? `<div class="section-title">Full attrs (evidence &amp; provenance)</div><pre>${escapeHtml(JSON.stringify(edge.attrs, null, 2))}</pre>` : ""}
637
+ `;
638
+ render();
639
+ }
640
+
641
+ function fieldRow(k, v, rawHtml) {
642
+ return `<div class="field-row"><span class="k">${k}</span><span class="v">${rawHtml ? v : escapeHtml(String(v))}</span></div>`;
643
+ }
644
+
645
+ function escapeHtml(s) {
646
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // Export
651
+ // ---------------------------------------------------------------------------
652
+
653
+ els.exportBtn.addEventListener("click", () => {
654
+ const payload = {
655
+ seeds: state.seeds,
656
+ depth: state.depth,
657
+ direction: state.direction,
658
+ nodes: [...state.nodes.values()],
659
+ edges: state.edges,
660
+ exportedFrom: "descry viz",
661
+ };
662
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
663
+ const url = URL.createObjectURL(blob);
664
+ const a = document.createElement("a");
665
+ a.href = url;
666
+ a.download = `descry-viz-${(state.seeds[0] ?? "graph").replace(/[^a-zA-Z0-9]/g, "_").slice(0, 40)}.json`;
667
+ a.click();
668
+ URL.revokeObjectURL(url);
669
+ });
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Boot
673
+ // ---------------------------------------------------------------------------
674
+
675
+ async function boot() {
676
+ try {
677
+ const info = await fetchJson("/api/graph-info");
678
+ els.stamp.textContent = info.stamp.commitSha
679
+ ? `commit ${info.stamp.commitSha.slice(0, 8)}`
680
+ : "graph has no commit recorded";
681
+ } catch (err) {
682
+ els.stamp.textContent = `graph unavailable: ${err.message}`;
683
+ }
684
+ resizeCanvas();
685
+ }
686
+ boot();