@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/README.md +40 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/reliability.d.ts +10 -0
- package/dist/reliability.d.ts.map +1 -0
- package/dist/reliability.js +15 -0
- package/dist/reliability.js.map +1 -0
- package/dist/search.d.ts +17 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +61 -0
- package/dist/search.js.map +1 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +153 -0
- package/dist/server.js.map +1 -0
- package/dist/subgraph.d.ts +40 -0
- package/dist/subgraph.d.ts.map +1 -0
- package/dist/subgraph.js +0 -0
- package/dist/subgraph.js.map +1 -0
- package/package.json +31 -0
- package/public/app.js +686 -0
- package/public/index.html +86 -0
- package/public/style.css +266 -0
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 & 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|