@clear-capabilities/agentic-security-scanner 0.147.0 → 0.147.5
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/CHANGELOG.md +127 -0
- package/dist/1122.index.js +79 -2
- package/dist/3180.index.js +73 -1
- package/dist/5051.index.js +77 -6
- package/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +2 -2
- package/src/server/static-assets.js +11 -6
- package/src/shared/frontend-root.js +52 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { worstVerdict, protectionVisual } from '../lib/protection-visual.js';
|
|
2
|
+
import { el, clear } from '../lib/dom.js';
|
|
3
|
+
import { flowPathNodeIds } from '../lib/flow-path.js';
|
|
4
|
+
|
|
5
|
+
export const ZONE_ORDER = Object.freeze(['Public Internet', 'Application Layer', 'Service Layer', 'Data Layer', 'External Zone']);
|
|
6
|
+
|
|
7
|
+
export function zoneForNode(node) {
|
|
8
|
+
switch (node.kind) {
|
|
9
|
+
case 'source':
|
|
10
|
+
return 'Public Internet';
|
|
11
|
+
case 'api':
|
|
12
|
+
return 'Application Layer';
|
|
13
|
+
case 'process':
|
|
14
|
+
case 'transform':
|
|
15
|
+
return 'Service Layer';
|
|
16
|
+
case 'store':
|
|
17
|
+
case 'log':
|
|
18
|
+
case 'queue':
|
|
19
|
+
case 'sink':
|
|
20
|
+
return 'Data Layer';
|
|
21
|
+
case 'external':
|
|
22
|
+
case 'unresolved':
|
|
23
|
+
return 'External Zone';
|
|
24
|
+
default:
|
|
25
|
+
// boundary, or any future kind not yet mapped: a safe internal default
|
|
26
|
+
// rather than silently dropping the node from every zone.
|
|
27
|
+
return 'Service Layer';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveSelection(graph, selectedId) {
|
|
32
|
+
const empty = { active: false, nodeIds: new Set(), edgeIds: new Set(), flow: null };
|
|
33
|
+
if (!selectedId) return empty;
|
|
34
|
+
|
|
35
|
+
const flow = graph.flows.find((f) => f.id === selectedId);
|
|
36
|
+
if (flow) {
|
|
37
|
+
const edgeIds = new Set(flow.edgeIds);
|
|
38
|
+
const nodeIds = new Set();
|
|
39
|
+
for (const edgeId of flow.edgeIds) {
|
|
40
|
+
const edge = graph.edges.find((e) => e.id === edgeId);
|
|
41
|
+
if (edge) {
|
|
42
|
+
nodeIds.add(edge.from);
|
|
43
|
+
nodeIds.add(edge.to);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
nodeIds.add(flow.source);
|
|
47
|
+
nodeIds.add(flow.sink);
|
|
48
|
+
return { active: true, nodeIds, edgeIds, flow };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const node = graph.nodes.find((n) => n.id === selectedId);
|
|
52
|
+
if (node) {
|
|
53
|
+
const edgeIds = new Set(graph.edges.filter((e) => e.from === selectedId || e.to === selectedId).map((e) => e.id));
|
|
54
|
+
return { active: true, nodeIds: new Set([selectedId]), edgeIds, flow: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const edge = graph.edges.find((e) => e.id === selectedId);
|
|
58
|
+
if (edge) {
|
|
59
|
+
return { active: true, nodeIds: new Set([edge.from, edge.to]), edgeIds: new Set([selectedId]), flow: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return empty;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function edgeVerdict(edge) {
|
|
66
|
+
return worstVerdict([edge.protection.transit.verdict, edge.protection.atRest.verdict, edge.protection.handling.verdict]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function computeFlowSummary(graph, flow) {
|
|
70
|
+
const edges = flow.edgeIds.map((id) => graph.edges.find((e) => e.id === id)).filter(Boolean);
|
|
71
|
+
const dataElement = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
72
|
+
const sourceNode = graph.nodes.find((n) => n.id === flow.source);
|
|
73
|
+
const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
|
|
74
|
+
|
|
75
|
+
const pathNodeIds = flowPathNodeIds(graph, flow);
|
|
76
|
+
const externalRecipients = graph.nodes
|
|
77
|
+
.filter((n) => pathNodeIds.has(n.id) && n.externality?.value === 'external')
|
|
78
|
+
.map((n) => n.label);
|
|
79
|
+
// 'unknown' externality is NOT safe to fold into "no external recipients" —
|
|
80
|
+
// it means the scanner could not resolve the destination (e.g. a dynamic
|
|
81
|
+
// URL expression), which is a distinct risk from a confirmed-internal
|
|
82
|
+
// recipient. Tracked separately so callers can't mistake "we don't know"
|
|
83
|
+
// for "we checked and it's fine" (I4, final whole-branch review).
|
|
84
|
+
const unknownRecipients = graph.nodes
|
|
85
|
+
.filter((n) => pathNodeIds.has(n.id) && n.externality?.value === 'unknown')
|
|
86
|
+
.map((n) => n.label);
|
|
87
|
+
|
|
88
|
+
let protectedCount = 0;
|
|
89
|
+
let unprotectedCount = 0;
|
|
90
|
+
let unknownCount = 0;
|
|
91
|
+
for (const e of edges) {
|
|
92
|
+
const v = edgeVerdict(e);
|
|
93
|
+
if (v === 'protected') protectedCount += 1;
|
|
94
|
+
else if (v === 'unprotected' || v === 'mixed') unprotectedCount += 1;
|
|
95
|
+
else unknownCount += 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
flowId: flow.id,
|
|
100
|
+
dataElementName: dataElement?.name ?? 'unknown field',
|
|
101
|
+
dataClasses: dataElement?.dataClasses ?? [],
|
|
102
|
+
sourceLabel: sourceNode?.label ?? 'unknown source',
|
|
103
|
+
destinationLabel: sinkNode?.label ?? 'unknown destination',
|
|
104
|
+
protectedCount,
|
|
105
|
+
unprotectedCount,
|
|
106
|
+
unknownCount,
|
|
107
|
+
externalRecipients,
|
|
108
|
+
unknownRecipients,
|
|
109
|
+
transitVerdict: worstVerdict(edges.map((e) => e.protection.transit.verdict)),
|
|
110
|
+
atRestVerdict: worstVerdict(edges.map((e) => e.protection.atRest.verdict)),
|
|
111
|
+
handlingVerdict: worstVerdict(edges.map((e) => e.protection.handling.verdict)),
|
|
112
|
+
protectionSummary: flow.protectionSummary,
|
|
113
|
+
policyVerdict: flow.policyVerdict,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} graph
|
|
119
|
+
* @param {object} state
|
|
120
|
+
* @param {{nodeIds: Set<string>, edgeIds: Set<string>} | null} [focusSelection] -
|
|
121
|
+
* Milestone 3, sub-project M3-UX-Query, Task 4. An optional pre-computed
|
|
122
|
+
* selection override — the SAME `{nodeIds, edgeIds}` shape every
|
|
123
|
+
* `lib/focus-controls.js` function already returns. When present (non-
|
|
124
|
+
* null), it is used AS the `selection` variable directly, bypassing
|
|
125
|
+
* `resolveSelection(graph, state.selectedId)` entirely for this render —
|
|
126
|
+
* a focus control's own multi-node result has no single canonical
|
|
127
|
+
* `selectedId` to look up. When omitted/null (the default, and what every
|
|
128
|
+
* pre-existing caller/test already passes), behavior is unchanged:
|
|
129
|
+
* `resolveSelection` runs exactly as it always has.
|
|
130
|
+
*/
|
|
131
|
+
export function computeArchitectureViewModel(graph, state, focusSelection = null) {
|
|
132
|
+
const selection = focusSelection
|
|
133
|
+
? { active: true, nodeIds: focusSelection.nodeIds, edgeIds: focusSelection.edgeIds, flow: null }
|
|
134
|
+
: resolveSelection(graph, state.selectedId);
|
|
135
|
+
|
|
136
|
+
const zones = ZONE_ORDER.map((name) => ({
|
|
137
|
+
name,
|
|
138
|
+
nodeIds: graph.nodes.filter((n) => zoneForNode(n) === name).map((n) => n.id),
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
const nodes = graph.nodes.map((n) => ({
|
|
142
|
+
id: n.id,
|
|
143
|
+
label: n.label,
|
|
144
|
+
kind: n.kind,
|
|
145
|
+
subtype: n.subtype,
|
|
146
|
+
zone: zoneForNode(n),
|
|
147
|
+
selected: selection.nodeIds.has(n.id),
|
|
148
|
+
dimmed: selection.active && !selection.nodeIds.has(n.id),
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const edges = graph.edges.map((e) => ({
|
|
152
|
+
id: e.id,
|
|
153
|
+
from: e.from,
|
|
154
|
+
to: e.to,
|
|
155
|
+
verdict: edgeVerdict(e),
|
|
156
|
+
selected: selection.edgeIds.has(e.id),
|
|
157
|
+
dimmed: selection.active && !selection.edgeIds.has(e.id),
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
const flowSummary = selection.flow ? computeFlowSummary(graph, selection.flow) : null;
|
|
161
|
+
|
|
162
|
+
return { zones, nodes, edges, flowSummary };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Renders computeFlowSummary()'s output into the shell's context rail
|
|
166
|
+
// (frontend/src/shell.js's getContextRailEl()). This is a plain HTML panel,
|
|
167
|
+
// not part of the SVG canvas, so it's built via el() (lib/dom.js) — NOT
|
|
168
|
+
// svgEl() — matching Privacy View / Trace View's convention of using el()
|
|
169
|
+
// for anything outside the <svg> tree.
|
|
170
|
+
export function renderFlowSummary(flowSummary, contextRailEl) {
|
|
171
|
+
clear(contextRailEl);
|
|
172
|
+
if (!flowSummary) return;
|
|
173
|
+
|
|
174
|
+
const dims = [
|
|
175
|
+
['Transit', flowSummary.transitVerdict],
|
|
176
|
+
['At rest', flowSummary.atRestVerdict],
|
|
177
|
+
['Handling', flowSummary.handlingVerdict],
|
|
178
|
+
].map(([label, verdict]) => {
|
|
179
|
+
const v = protectionVisual(verdict);
|
|
180
|
+
return el('div', {}, `${v.glyph} ${label}: ${v.label}`);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const recipientsLine = (label, names) => (names.length > 0 ? el('div', {}, `${label}: ${names.join(', ')}`) : null);
|
|
184
|
+
|
|
185
|
+
contextRailEl.appendChild(
|
|
186
|
+
el('div', { class: 'flow-summary' }, [
|
|
187
|
+
el('h4', {}, flowSummary.dataElementName),
|
|
188
|
+
el('div', {}, flowSummary.dataClasses.join(', ')),
|
|
189
|
+
el('div', {}, `${flowSummary.sourceLabel} → ${flowSummary.destinationLabel}`),
|
|
190
|
+
el('div', {}, `${flowSummary.protectedCount} protected · ${flowSummary.unprotectedCount} unprotected · ${flowSummary.unknownCount} unknown`),
|
|
191
|
+
recipientsLine('External recipients', flowSummary.externalRecipients),
|
|
192
|
+
recipientsLine('Unknown-externality recipients', flowSummary.unknownRecipients),
|
|
193
|
+
...dims,
|
|
194
|
+
].filter(Boolean)),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Per-zone level-of-detail clustering (PRD §21: "no more than 2,000
|
|
200
|
+
* visible elements after level-of-detail clustering"). A currently-
|
|
201
|
+
* SELECTED node always stays individually visible, bypassing `budget`
|
|
202
|
+
* entirely — clustering must never hide the thing the user is looking
|
|
203
|
+
* at. `budget` is the max number of individually-visible node SLOTS for
|
|
204
|
+
* a zone, INCLUDING the cluster glyph's own slot when clustering is
|
|
205
|
+
* needed (so `budget=4` with 10 nodes shows 3 real nodes + 1 cluster
|
|
206
|
+
* glyph, never 4 real nodes + a cluster that would then be a 5th
|
|
207
|
+
* element). Node order (for which unselected nodes stay visible) is
|
|
208
|
+
* graph order — a defensible, simple tie-break, not sorted by anything
|
|
209
|
+
* PRD-significant.
|
|
210
|
+
*
|
|
211
|
+
* @param {Array<{name: string, nodeIds: string[]}>} zones
|
|
212
|
+
* @param {Array<{id: string, kind: string, zone: string, selected: boolean}>} nodes
|
|
213
|
+
* @param {number} budget
|
|
214
|
+
*/
|
|
215
|
+
export function computeClusteredLayout(zones, nodes, budget) {
|
|
216
|
+
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
|
217
|
+
|
|
218
|
+
return zones.map((zone) => {
|
|
219
|
+
const selectedIds = zone.nodeIds.filter((id) => nodesById.get(id)?.selected);
|
|
220
|
+
const unselectedIds = zone.nodeIds.filter((id) => !nodesById.get(id)?.selected);
|
|
221
|
+
|
|
222
|
+
if (zone.nodeIds.length <= budget) {
|
|
223
|
+
return { name: zone.name, visibleNodeIds: [...zone.nodeIds], cluster: null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Selected nodes never count against the budget or get clustered.
|
|
227
|
+
// The remaining budget (after reserving 1 slot for the cluster
|
|
228
|
+
// glyph itself) goes to unselected nodes in graph order.
|
|
229
|
+
const slotsForUnselected = Math.max(0, budget - 1);
|
|
230
|
+
const visibleUnselected = unselectedIds.slice(0, slotsForUnselected);
|
|
231
|
+
const clusteredIds = unselectedIds.slice(slotsForUnselected);
|
|
232
|
+
|
|
233
|
+
if (clusteredIds.length === 0) {
|
|
234
|
+
// Selected nodes alone pushed us over budget, or the unselected
|
|
235
|
+
// set fit exactly — no real overflow to cluster.
|
|
236
|
+
return { name: zone.name, visibleNodeIds: [...selectedIds, ...visibleUnselected], cluster: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const kindSummary = [...new Set(clusteredIds.map((id) => nodesById.get(id)?.kind).filter(Boolean))].sort().join(', ');
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
name: zone.name,
|
|
243
|
+
visibleNodeIds: [...selectedIds, ...visibleUnselected],
|
|
244
|
+
cluster: {
|
|
245
|
+
id: `cluster:${zone.name}`,
|
|
246
|
+
count: clusteredIds.length,
|
|
247
|
+
kindSummary,
|
|
248
|
+
memberIds: clusteredIds,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Redirects an edge's endpoint to its zone's cluster glyph when that
|
|
256
|
+
* endpoint's node was clustered away (computeClusteredLayout), then
|
|
257
|
+
* groups edges sharing the same real (from, to) VISIBLE-endpoint pair
|
|
258
|
+
* into one aggregate, reusing worstVerdict — the SAME aggregation
|
|
259
|
+
* primitive edgeVerdict() already uses per-edge, applied here per-group.
|
|
260
|
+
* An edge whose both endpoints resolve to the SAME cluster (entirely
|
|
261
|
+
* "inside" one collapsed group) is dropped — it adds no information a
|
|
262
|
+
* single cluster glyph doesn't already summarize.
|
|
263
|
+
*
|
|
264
|
+
* @param {Array<{id,from,to,verdict,selected,dimmed}>} edges
|
|
265
|
+
* @param {ReturnType<typeof computeClusteredLayout>} clusteredZones
|
|
266
|
+
*/
|
|
267
|
+
export function aggregateEdgesForClusters(edges, clusteredZones) {
|
|
268
|
+
const visibleIdFor = new Map();
|
|
269
|
+
for (const zone of clusteredZones) {
|
|
270
|
+
for (const id of zone.visibleNodeIds) visibleIdFor.set(id, id);
|
|
271
|
+
if (zone.cluster) {
|
|
272
|
+
for (const memberId of zone.cluster.memberIds) visibleIdFor.set(memberId, zone.cluster.id);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const groups = new Map(); // key: `${visibleFrom}->${visibleTo}` -> edges[]
|
|
277
|
+
for (const edge of edges) {
|
|
278
|
+
const visibleFrom = visibleIdFor.get(edge.from) ?? edge.from;
|
|
279
|
+
const visibleTo = visibleIdFor.get(edge.to) ?? edge.to;
|
|
280
|
+
if (visibleFrom === visibleTo) continue; // dropped: collapsed self-loop
|
|
281
|
+
const key = `${visibleFrom}->${visibleTo}`;
|
|
282
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
283
|
+
groups.get(key).push(edge);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return [...groups.entries()].map(([key, group]) => {
|
|
287
|
+
const [from, to] = key.split('->');
|
|
288
|
+
if (group.length === 1) return { ...group[0], from, to, constituentCount: 1 };
|
|
289
|
+
return {
|
|
290
|
+
id: `agg:${key}`, // deterministic — same (from,to) pair always yields the same id
|
|
291
|
+
from,
|
|
292
|
+
to,
|
|
293
|
+
verdict: worstVerdict(group.map((e) => e.verdict)),
|
|
294
|
+
selected: group.some((e) => e.selected),
|
|
295
|
+
dimmed: group.every((e) => e.dimmed),
|
|
296
|
+
constituentCount: group.length,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Default viewport on first mount: show the entire content bounds
|
|
303
|
+
* unchanged (decision 5: reset only on a fresh view-mount, not every
|
|
304
|
+
* rerender — the caller owns when this gets called again).
|
|
305
|
+
*
|
|
306
|
+
* @param {{x: number, y: number, width: number, height: number}} contentBounds
|
|
307
|
+
*/
|
|
308
|
+
export function computeFitAllViewport(contentBounds) {
|
|
309
|
+
return { ...contentBounds };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Pure zoom reducer — no DOM access. `svgX`/`svgY` are the cursor
|
|
314
|
+
* position already converted to SVG-coordinate space by the caller
|
|
315
|
+
* (real browser code, using getScreenCTM()/getBoundingClientRect() —
|
|
316
|
+
* see Task 3). Zoom factor is a fixed, disclosed constant per wheel
|
|
317
|
+
* "tick" rather than proportional to raw deltaY magnitude (real trackpad/
|
|
318
|
+
* mouse-wheel deltaY values vary wildly across devices/browsers — a
|
|
319
|
+
* fixed-step zoom avoids over- or under-reacting to a single event).
|
|
320
|
+
*
|
|
321
|
+
* The centered-on-cursor property is algebraic, not approximate: `newX`
|
|
322
|
+
* is derived by solving `(svgX - newX) / newWidth === fracX` for `newX`,
|
|
323
|
+
* so the cursor's fractional position within the viewport is IDENTICAL
|
|
324
|
+
* before and after the zoom, for any resulting `newWidth`/`newHeight`
|
|
325
|
+
* (including after clamping) — not just for the unclamped case.
|
|
326
|
+
*/
|
|
327
|
+
const ZOOM_STEP = 0.1; // 10% per wheel tick
|
|
328
|
+
|
|
329
|
+
export function applyWheelZoom(viewport, { deltaY, svgX, svgY }, bounds) {
|
|
330
|
+
const factor = deltaY < 0 ? 1 - ZOOM_STEP : 1 + ZOOM_STEP;
|
|
331
|
+
const newWidth = Math.min(bounds.maxWidth, Math.max(bounds.minWidth, viewport.width * factor));
|
|
332
|
+
const newHeight = Math.min(bounds.maxWidth, Math.max(bounds.minWidth, viewport.height * factor)); // aspect-locked to width's own clamp, since this view's aspect ratio is fixed by zone-column layout
|
|
333
|
+
// Keep (svgX, svgY) fixed under the cursor: the point's own relative
|
|
334
|
+
// position within the viewport (0..1 fraction) must be identical
|
|
335
|
+
// before and after.
|
|
336
|
+
const fracX = (svgX - viewport.x) / viewport.width;
|
|
337
|
+
const fracY = (svgY - viewport.y) / viewport.height;
|
|
338
|
+
return {
|
|
339
|
+
x: svgX - fracX * newWidth,
|
|
340
|
+
y: svgY - fracY * newHeight,
|
|
341
|
+
width: newWidth,
|
|
342
|
+
height: newHeight,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Pans by an SVG-space delta, clamped so the viewport cannot be dragged
|
|
348
|
+
* entirely off `contentBounds` — a sliver of content always remains
|
|
349
|
+
* visible at the boundary, rather than the viewport being allowed to
|
|
350
|
+
* drift into empty space with nothing on screen.
|
|
351
|
+
*/
|
|
352
|
+
export function applyDragPan(viewport, { dxSvg, dySvg }, contentBounds) {
|
|
353
|
+
const minX = contentBounds.x - viewport.width; // allow dragging until only a sliver of content remains visible, never fully past it
|
|
354
|
+
const maxX = contentBounds.x + contentBounds.width;
|
|
355
|
+
const minY = contentBounds.y - viewport.height;
|
|
356
|
+
const maxY = contentBounds.y + contentBounds.height;
|
|
357
|
+
return {
|
|
358
|
+
x: Math.min(maxX, Math.max(minX, viewport.x + dxSvg)),
|
|
359
|
+
y: Math.min(maxY, Math.max(minY, viewport.y + dySvg)),
|
|
360
|
+
width: viewport.width,
|
|
361
|
+
height: viewport.height,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Viewport-culling predicate for level-of-detail rendering at scale:
|
|
367
|
+
* which node ids fall within `viewportRect` expanded by `margin` on all
|
|
368
|
+
* sides (the margin avoids visible pop-in as a node crosses the exact
|
|
369
|
+
* edge). `nodePositions` is the SAME `Map<id, {x, y}>` shape
|
|
370
|
+
* `renderArchitectureView` already builds locally — reuse it there,
|
|
371
|
+
* don't rebuild it.
|
|
372
|
+
*/
|
|
373
|
+
export function visibleNodeIds(nodePositions, viewportRect, margin) {
|
|
374
|
+
const minX = viewportRect.x - margin;
|
|
375
|
+
const maxX = viewportRect.x + viewportRect.width + margin;
|
|
376
|
+
const minY = viewportRect.y - margin;
|
|
377
|
+
const maxY = viewportRect.y + viewportRect.height + margin;
|
|
378
|
+
const result = new Set();
|
|
379
|
+
for (const [id, pos] of nodePositions) {
|
|
380
|
+
if (pos.x >= minX && pos.x <= maxX && pos.y >= minY && pos.y <= maxY) result.add(id);
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
386
|
+
const ZONE_WIDTH = 220;
|
|
387
|
+
const ZONE_PADDING = 12;
|
|
388
|
+
const NODE_HEIGHT = 44;
|
|
389
|
+
const NODE_GAP = 16;
|
|
390
|
+
const NODE_WIDTH = ZONE_WIDTH - ZONE_PADDING * 2;
|
|
391
|
+
|
|
392
|
+
// PRD §21: "no more than 2,000 visible elements after level-of-detail
|
|
393
|
+
// clustering." Each node is 3 SVG elements (rect+2 text, see renderNode);
|
|
394
|
+
// each rendered edge is ~2 (path+text, see renderEdge); 5 zones contribute
|
|
395
|
+
// 2 chrome elements each (bg rect + label text) = 10; a cluster glyph
|
|
396
|
+
// itself costs the same 3 elements as a real node. Budget conservatively:
|
|
397
|
+
// reserve 20% of the 2,000 target for edges/chrome, split the rest evenly
|
|
398
|
+
// across 5 zones.
|
|
399
|
+
const VISIBLE_ELEMENT_BUDGET = 2000;
|
|
400
|
+
const ZONE_CHROME_ELEMENTS = ZONE_ORDER.length * 2;
|
|
401
|
+
const EDGE_ELEMENT_RESERVE_FRACTION = 0.2;
|
|
402
|
+
const NODE_ELEMENTS_PER_NODE = 3;
|
|
403
|
+
function computeZoneNodeBudget() {
|
|
404
|
+
const budgetForNodes = (VISIBLE_ELEMENT_BUDGET - ZONE_CHROME_ELEMENTS) * (1 - EDGE_ELEMENT_RESERVE_FRACTION);
|
|
405
|
+
return Math.max(3, Math.floor(budgetForNodes / ZONE_ORDER.length / NODE_ELEMENTS_PER_NODE));
|
|
406
|
+
}
|
|
407
|
+
// Sanity-checked (this session, real numbers): with the constants above this
|
|
408
|
+
// evaluates to 106 — well under the ~1,000-per-zone average a 5,000-node
|
|
409
|
+
// graph split across 5 zones would produce (so clustering actually engages
|
|
410
|
+
// at PRD reference scale), and far above the <10 floor that would clutter
|
|
411
|
+
// every modest fixture with a cluster glyph.
|
|
412
|
+
|
|
413
|
+
// Module-local, NOT persisted to lib/state.js's URL hash — real UI state,
|
|
414
|
+
// not meaningfully shareable (scoping doc decision 5).
|
|
415
|
+
//
|
|
416
|
+
// Simplification of the brief's own Step 3.5 (documented in the task-3
|
|
417
|
+
// context, not just here): `renderArchitectureView`'s caller (app.js) has
|
|
418
|
+
// no "this is a fresh view mount, not a same-view rerender" signal today,
|
|
419
|
+
// and adding one is out of this task's file list (architecture-view.js
|
|
420
|
+
// only). Instead: currentViewport starts null and is set to a fit-all
|
|
421
|
+
// viewport ONLY the very first time this module ever renders. It is never
|
|
422
|
+
// auto-reset again afterward — only via the user's own "0" key, a cluster
|
|
423
|
+
// expansion (see expandedZones' onClick below — re-fitting there is a
|
|
424
|
+
// deliberate, real necessity, not a copy-paste of the mount rule: without
|
|
425
|
+
// it, newly-revealed nodes from an expanded cluster could land outside the
|
|
426
|
+
// still-small pre-expansion viewport and be viewport-culled right back out
|
|
427
|
+
// of the DOM, silently undoing the click), or a page reload. This means
|
|
428
|
+
// pan/zoom position is preserved across ordinary view switches away from
|
|
429
|
+
// and back to Architecture View — simpler, fully local to this file, and
|
|
430
|
+
// does not violate AC-16 (which requires selection/filters/header/coverage
|
|
431
|
+
// state to survive a view switch, and says nothing about pan/zoom).
|
|
432
|
+
let currentViewport = null;
|
|
433
|
+
// Module-local set of zone names whose per-zone budget is lifted to
|
|
434
|
+
// Infinity by a user click on that zone's cluster glyph (see
|
|
435
|
+
// computeEffectiveClusteredLayout below). Kept as a Set, not folded into
|
|
436
|
+
// currentViewport, so it survives independently of pan/zoom resets.
|
|
437
|
+
const expandedZones = new Set();
|
|
438
|
+
// Module-local, keyed the same way `currentViewport` is: drag state must
|
|
439
|
+
// survive a rerender (renderArchitectureView tears down and rebuilds the
|
|
440
|
+
// entire <svg> tree on every pan/zoom-driven rerender, including the ones
|
|
441
|
+
// fired mid-drag from `mousemove`), or a real mouse-drag gesture would
|
|
442
|
+
// silently stop after its first `mousemove` event — the old <svg> (and any
|
|
443
|
+
// per-render-closure-local drag state) is gone, and no second `mousedown`
|
|
444
|
+
// ever fires to restart it.
|
|
445
|
+
let dragState = null;
|
|
446
|
+
|
|
447
|
+
const CULL_MARGIN = 100; // SVG units; see visibleNodeIds' own margin param
|
|
448
|
+
const KEYBOARD_PAN_STEP = 40; // SVG units per arrow-key press
|
|
449
|
+
const MIN_VIEWPORT_WIDTH = 150; // SVG units; deepest zoom-in via wheel/keyboard
|
|
450
|
+
|
|
451
|
+
// computeClusteredLayout (Task 1) intentionally takes one `budget` number
|
|
452
|
+
// applied uniformly to every zone — already tested against a plain number,
|
|
453
|
+
// and Task 3 must not change that signature. To let ONE zone's budget be
|
|
454
|
+
// lifted (cluster-glyph click, "show me everything in this zone"), split
|
|
455
|
+
// the zones into "expanded" (budget=Infinity) and "everyone else" (the
|
|
456
|
+
// real zoneNodeBudget), call computeClusteredLayout once per group, and
|
|
457
|
+
// merge back in the original zone order.
|
|
458
|
+
function computeEffectiveClusteredLayout(zones, nodes, budget, expandedZoneNames) {
|
|
459
|
+
const expanded = zones.filter((z) => expandedZoneNames.has(z.name));
|
|
460
|
+
const collapsed = zones.filter((z) => !expandedZoneNames.has(z.name));
|
|
461
|
+
const expandedResult = expanded.length > 0 ? computeClusteredLayout(expanded, nodes, Infinity) : [];
|
|
462
|
+
const collapsedResult = collapsed.length > 0 ? computeClusteredLayout(collapsed, nodes, budget) : [];
|
|
463
|
+
const byName = new Map([...expandedResult, ...collapsedResult].map((z) => [z.name, z]));
|
|
464
|
+
return zones.map((z) => byName.get(z.name));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Real screen-to-SVG-space coordinate conversion, via the standard
|
|
468
|
+
// getScreenCTM()/createSVGPoint() DOM APIs — NOT available on test/dom-
|
|
469
|
+
// shim.js's FakeElement (it has no CSSOM/layout engine to compute a CTM
|
|
470
|
+
// against), so every call site guards with `supportsPointerConversion`
|
|
471
|
+
// first and skips wiring entirely when it's false. Real interaction is
|
|
472
|
+
// only provable in a real browser regardless (Step 6).
|
|
473
|
+
function screenToSvgPoint(svgElement, clientX, clientY) {
|
|
474
|
+
const pt = svgElement.createSVGPoint();
|
|
475
|
+
pt.x = clientX;
|
|
476
|
+
pt.y = clientY;
|
|
477
|
+
return pt.matrixTransform(svgElement.getScreenCTM().inverse());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function zoomBoundsFor(contentBounds) {
|
|
481
|
+
return { minWidth: MIN_VIEWPORT_WIDTH, maxWidth: Math.max(contentBounds.width, MIN_VIEWPORT_WIDTH) };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Builds an element in the SVG namespace (createElementNS), unlike `el()`
|
|
485
|
+
// (lib/dom.js) which always calls createElement and produces an HTML-
|
|
486
|
+
// namespaced element — a foreign element inside an <svg> tree that neither
|
|
487
|
+
// paints nor paints its children (C1, final whole-branch review). Event
|
|
488
|
+
// handlers are wired the same way `el()` does (addEventListener, not
|
|
489
|
+
// setAttribute); everything else — including `class` — goes through plain
|
|
490
|
+
// setAttribute, which is correct for SVG elements. Do NOT set `.className`
|
|
491
|
+
// here: it's a read-only SVGAnimatedString on SVG elements, and assigning to
|
|
492
|
+
// it is a silent no-op that would drop every CSS class.
|
|
493
|
+
export function svgEl(tag, attrs = {}) {
|
|
494
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
495
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
496
|
+
if (value === undefined || value === null || value === false) continue;
|
|
497
|
+
if (key.startsWith('on') && typeof value === 'function') {
|
|
498
|
+
node.addEventListener(key.slice(2).toLowerCase(), value);
|
|
499
|
+
} else {
|
|
500
|
+
node.setAttribute(key, String(value));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return node;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {ReturnType<typeof computeArchitectureViewModel>} viewModel
|
|
508
|
+
* @param {HTMLElement} canvasEl
|
|
509
|
+
* @param {(id: string) => void} onSelect
|
|
510
|
+
*/
|
|
511
|
+
export function renderArchitectureView(viewModel, canvasEl, onSelect) {
|
|
512
|
+
// Real, disclosed finding from this session's own manual browser smoke
|
|
513
|
+
// check (Step 6): every pan/zoom-driven rerender tears down and rebuilds
|
|
514
|
+
// the ENTIRE <svg> from scratch. A real browser does NOT transfer focus
|
|
515
|
+
// to a freshly-inserted replacement element, so without this, a single
|
|
516
|
+
// keyboard-driven zoom/pan keystroke would work but a SECOND rapid one
|
|
517
|
+
// (e.g. holding "-") would silently do nothing — the old, focused <svg>
|
|
518
|
+
// is already gone, and nothing ever refocuses the new one. Recorded here
|
|
519
|
+
// (not just observed and left alone) because this is exactly the class
|
|
520
|
+
// of bug a manual-only check without direct focus inspection would miss
|
|
521
|
+
// (see app.js's own comment on the analogous Task-5/Task-7 lesson).
|
|
522
|
+
const previouslyFocusedSvg = typeof document !== 'undefined' && document.activeElement === canvasEl.firstChild ? canvasEl.firstChild : null;
|
|
523
|
+
clear(canvasEl);
|
|
524
|
+
|
|
525
|
+
function rerender() {
|
|
526
|
+
renderArchitectureView(viewModel, canvasEl, onSelect);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const zoneNodeBudget = computeZoneNodeBudget();
|
|
530
|
+
const clusteredZones = computeEffectiveClusteredLayout(viewModel.zones, viewModel.nodes, zoneNodeBudget, expandedZones);
|
|
531
|
+
const nodesById = new Map(viewModel.nodes.map((n) => [n.id, n]));
|
|
532
|
+
|
|
533
|
+
const zoneCount = clusteredZones.length;
|
|
534
|
+
const maxRowsInAZone = Math.max(1, ...clusteredZones.map((z) => z.visibleNodeIds.length + (z.cluster ? 1 : 0)));
|
|
535
|
+
const height = Math.max(480, maxRowsInAZone * (NODE_HEIGHT + NODE_GAP) + 80);
|
|
536
|
+
const width = zoneCount * ZONE_WIDTH;
|
|
537
|
+
const contentBounds = { x: 0, y: 0, width, height };
|
|
538
|
+
|
|
539
|
+
if (currentViewport === null) {
|
|
540
|
+
currentViewport = computeFitAllViewport(contentBounds);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const svg = svgEl('svg', {
|
|
544
|
+
class: 'arch-view',
|
|
545
|
+
viewBox: `${currentViewport.x} ${currentViewport.y} ${currentViewport.width} ${currentViewport.height}`,
|
|
546
|
+
role: 'img',
|
|
547
|
+
tabindex: '0',
|
|
548
|
+
'aria-label': 'Architecture view: trust zones, nodes, and data-flow edges. Arrow keys pan, plus/minus zoom, 0 resets.',
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Pass 1: lay out every INDIVIDUALLY-VISIBLE node (post-clustering) and
|
|
552
|
+
// each zone's own cluster glyph, recording positions for all of them —
|
|
553
|
+
// viewport culling (pass 2 below) needs every position up front, since
|
|
554
|
+
// an edge's endpoint may resolve to either a node or a cluster glyph.
|
|
555
|
+
const nodePositions = new Map();
|
|
556
|
+
const pendingNodes = []; // {node, x, y}
|
|
557
|
+
const pendingClusters = []; // {zone, x, y}
|
|
558
|
+
clusteredZones.forEach((zone, zoneIndex) => {
|
|
559
|
+
const zoneX = zoneIndex * ZONE_WIDTH;
|
|
560
|
+
svg.appendChild(svgEl('rect', { class: 'arch-zone-bg', x: zoneX, y: 0, width: ZONE_WIDTH, height, rx: 4 }));
|
|
561
|
+
const zoneLabel = svgEl('text', { class: 'arch-zone-label', x: zoneX + ZONE_PADDING, y: 24 });
|
|
562
|
+
zoneLabel.textContent = zone.name;
|
|
563
|
+
svg.appendChild(zoneLabel);
|
|
564
|
+
|
|
565
|
+
let row = 0;
|
|
566
|
+
for (const nodeId of zone.visibleNodeIds) {
|
|
567
|
+
const node = nodesById.get(nodeId);
|
|
568
|
+
const y = 48 + row * (NODE_HEIGHT + NODE_GAP);
|
|
569
|
+
const x = zoneX + ZONE_PADDING;
|
|
570
|
+
nodePositions.set(nodeId, { x: x + NODE_WIDTH / 2, y: y + NODE_HEIGHT / 2 });
|
|
571
|
+
pendingNodes.push({ node, x, y });
|
|
572
|
+
row += 1;
|
|
573
|
+
}
|
|
574
|
+
if (zone.cluster) {
|
|
575
|
+
const y = 48 + row * (NODE_HEIGHT + NODE_GAP);
|
|
576
|
+
const x = zoneX + ZONE_PADDING;
|
|
577
|
+
nodePositions.set(zone.cluster.id, { x: x + NODE_WIDTH / 2, y: y + NODE_HEIGHT / 2 });
|
|
578
|
+
pendingClusters.push({ zone, x, y });
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Pass 2: viewport culling. A currently-selected node always renders
|
|
583
|
+
// regardless of the viewport, same "never hide the thing the user is
|
|
584
|
+
// looking at" principle clustering already applies to the budget.
|
|
585
|
+
const visible = visibleNodeIds(nodePositions, currentViewport, CULL_MARGIN);
|
|
586
|
+
for (const { node, x, y } of pendingNodes) {
|
|
587
|
+
if (!visible.has(node.id) && !node.selected) continue;
|
|
588
|
+
svg.appendChild(renderNode(node, x, y, onSelect));
|
|
589
|
+
}
|
|
590
|
+
for (const { zone, x, y } of pendingClusters) {
|
|
591
|
+
if (!visible.has(zone.cluster.id)) continue;
|
|
592
|
+
svg.appendChild(renderClusterGlyph(zone.cluster, x, y, () => {
|
|
593
|
+
expandedZones.add(zone.name);
|
|
594
|
+
// A just-expanded cluster's newly-individual nodes can land outside
|
|
595
|
+
// the current (pre-expansion) viewport; re-fit so the user actually
|
|
596
|
+
// sees what they just asked to see, rather than having it culled
|
|
597
|
+
// straight back out of the DOM. See currentViewport's own comment.
|
|
598
|
+
currentViewport = null;
|
|
599
|
+
rerender();
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Edges: only edges actually touched by clustering (an endpoint that got
|
|
604
|
+
// folded into a cluster glyph) go through aggregateEdgesForClusters.
|
|
605
|
+
// Real, disclosed finding this session: aggregateEdgesForClusters groups
|
|
606
|
+
// PURELY by post-redirect (from, to) pair — with NO clustering involved
|
|
607
|
+
// at all, feeding it every edge unconditionally would ALSO merge two
|
|
608
|
+
// genuinely distinct real edges that happen to share the same (from, to)
|
|
609
|
+
// node pair (the flagship fixture has exactly this: masked_log's and
|
|
610
|
+
// raw_log's own log-write edges both run process->log with different
|
|
611
|
+
// verdicts), silently collapsing them to one worst-verdict edge and
|
|
612
|
+
// regressing AC-17's "raw vs masked render distinct verdicts" golden
|
|
613
|
+
// test. Splitting the input here (not touching aggregateEdgesForClusters
|
|
614
|
+
// itself, which Task 1 already shipped and tested) keeps every
|
|
615
|
+
// untouched edge exactly as before, and only reroutes+aggregates the
|
|
616
|
+
// ones clustering actually affected.
|
|
617
|
+
const clusteredMemberIds = new Set();
|
|
618
|
+
for (const zone of clusteredZones) {
|
|
619
|
+
if (zone.cluster) for (const memberId of zone.cluster.memberIds) clusteredMemberIds.add(memberId);
|
|
620
|
+
}
|
|
621
|
+
const edgesTouchedByClustering = viewModel.edges.filter((e) => clusteredMemberIds.has(e.from) || clusteredMemberIds.has(e.to));
|
|
622
|
+
const edgesUntouchedByClustering = viewModel.edges.filter((e) => !clusteredMemberIds.has(e.from) && !clusteredMemberIds.has(e.to));
|
|
623
|
+
const aggregatedEdges = edgesTouchedByClustering.length > 0 ? aggregateEdgesForClusters(edgesTouchedByClustering, clusteredZones) : [];
|
|
624
|
+
const allRenderableEdges = [...edgesUntouchedByClustering, ...aggregatedEdges];
|
|
625
|
+
|
|
626
|
+
// Culled the same way as nodes — an edge with either endpoint visible
|
|
627
|
+
// (or itself selected) still renders; dimmed edges are drawn first so a
|
|
628
|
+
// highlighted edge always renders on top.
|
|
629
|
+
const sortedEdges = [...allRenderableEdges].sort((a, b) => Number(a.selected) - Number(b.selected));
|
|
630
|
+
for (const edge of sortedEdges) {
|
|
631
|
+
const from = nodePositions.get(edge.from);
|
|
632
|
+
const to = nodePositions.get(edge.to);
|
|
633
|
+
if (!from || !to) continue; // an edge whose endpoint isn't rendered (shouldn't happen) is safely skipped, not a crash
|
|
634
|
+
if (!edge.selected && !visible.has(edge.from) && !visible.has(edge.to)) continue;
|
|
635
|
+
svg.appendChild(renderEdge(edge, from, to, onSelect));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const supportsPointerConversion = typeof svg.getScreenCTM === 'function' && typeof svg.createSVGPoint === 'function';
|
|
639
|
+
if (supportsPointerConversion) {
|
|
640
|
+
svg.addEventListener('wheel', (evt) => {
|
|
641
|
+
evt.preventDefault();
|
|
642
|
+
const { x: svgX, y: svgY } = screenToSvgPoint(svg, evt.clientX, evt.clientY);
|
|
643
|
+
currentViewport = applyWheelZoom(currentViewport, { deltaY: evt.deltaY, svgX, svgY }, zoomBoundsFor(contentBounds));
|
|
644
|
+
rerender();
|
|
645
|
+
});
|
|
646
|
+
svg.addEventListener('mousedown', (evt) => {
|
|
647
|
+
const p = screenToSvgPoint(svg, evt.clientX, evt.clientY);
|
|
648
|
+
dragState = { lastX: p.x, lastY: p.y };
|
|
649
|
+
});
|
|
650
|
+
svg.addEventListener('mousemove', (evt) => {
|
|
651
|
+
if (!dragState) return;
|
|
652
|
+
const p = screenToSvgPoint(svg, evt.clientX, evt.clientY);
|
|
653
|
+
// Content under the cursor should stay under the cursor: shift the
|
|
654
|
+
// viewport by the NEGATIVE of the cursor's own SVG-space delta.
|
|
655
|
+
const dxSvg = dragState.lastX - p.x;
|
|
656
|
+
const dySvg = dragState.lastY - p.y;
|
|
657
|
+
dragState = { lastX: p.x, lastY: p.y };
|
|
658
|
+
currentViewport = applyDragPan(currentViewport, { dxSvg, dySvg }, contentBounds);
|
|
659
|
+
rerender();
|
|
660
|
+
});
|
|
661
|
+
svg.addEventListener('mouseup', () => { dragState = null; });
|
|
662
|
+
svg.addEventListener('mouseleave', () => { dragState = null; });
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
svg.addEventListener('keydown', (evt) => {
|
|
666
|
+
switch (evt.key) {
|
|
667
|
+
case 'ArrowUp':
|
|
668
|
+
evt.preventDefault();
|
|
669
|
+
currentViewport = applyDragPan(currentViewport, { dxSvg: 0, dySvg: -KEYBOARD_PAN_STEP }, contentBounds);
|
|
670
|
+
break;
|
|
671
|
+
case 'ArrowDown':
|
|
672
|
+
evt.preventDefault();
|
|
673
|
+
currentViewport = applyDragPan(currentViewport, { dxSvg: 0, dySvg: KEYBOARD_PAN_STEP }, contentBounds);
|
|
674
|
+
break;
|
|
675
|
+
case 'ArrowLeft':
|
|
676
|
+
evt.preventDefault();
|
|
677
|
+
currentViewport = applyDragPan(currentViewport, { dxSvg: -KEYBOARD_PAN_STEP, dySvg: 0 }, contentBounds);
|
|
678
|
+
break;
|
|
679
|
+
case 'ArrowRight':
|
|
680
|
+
evt.preventDefault();
|
|
681
|
+
currentViewport = applyDragPan(currentViewport, { dxSvg: KEYBOARD_PAN_STEP, dySvg: 0 }, contentBounds);
|
|
682
|
+
break;
|
|
683
|
+
case '+':
|
|
684
|
+
case '=':
|
|
685
|
+
evt.preventDefault();
|
|
686
|
+
currentViewport = applyWheelZoom(
|
|
687
|
+
currentViewport,
|
|
688
|
+
{ deltaY: -1, svgX: currentViewport.x + currentViewport.width / 2, svgY: currentViewport.y + currentViewport.height / 2 },
|
|
689
|
+
zoomBoundsFor(contentBounds),
|
|
690
|
+
);
|
|
691
|
+
break;
|
|
692
|
+
case '-':
|
|
693
|
+
evt.preventDefault();
|
|
694
|
+
currentViewport = applyWheelZoom(
|
|
695
|
+
currentViewport,
|
|
696
|
+
{ deltaY: 1, svgX: currentViewport.x + currentViewport.width / 2, svgY: currentViewport.y + currentViewport.height / 2 },
|
|
697
|
+
zoomBoundsFor(contentBounds),
|
|
698
|
+
);
|
|
699
|
+
break;
|
|
700
|
+
case '0':
|
|
701
|
+
evt.preventDefault();
|
|
702
|
+
currentViewport = computeFitAllViewport(contentBounds);
|
|
703
|
+
break;
|
|
704
|
+
default:
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
rerender();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
canvasEl.appendChild(svg);
|
|
711
|
+
if (previouslyFocusedSvg && typeof svg.focus === 'function') svg.focus();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function renderNode(node, x, y, onSelect) {
|
|
715
|
+
const group = svgEl('g', {
|
|
716
|
+
class: 'arch-node',
|
|
717
|
+
'data-selected': String(node.selected),
|
|
718
|
+
'data-dimmed': String(node.dimmed),
|
|
719
|
+
tabindex: '0',
|
|
720
|
+
role: 'button',
|
|
721
|
+
'aria-label': `${node.label}, ${node.kind}${node.selected ? ', selected' : ''}`,
|
|
722
|
+
onClick: () => onSelect(node.id),
|
|
723
|
+
onKeydown: (evt) => {
|
|
724
|
+
if (evt.key === 'Enter' || evt.key === ' ') {
|
|
725
|
+
evt.preventDefault();
|
|
726
|
+
onSelect(node.id);
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
group.appendChild(svgEl('rect', { class: 'arch-node-box', x, y, width: NODE_WIDTH, height: NODE_HEIGHT }));
|
|
731
|
+
const glyph = svgEl('text', { class: 'arch-node-glyph', x: x + 8, y: y + 16 });
|
|
732
|
+
glyph.textContent = node.kind.slice(0, 3).toUpperCase();
|
|
733
|
+
group.appendChild(glyph);
|
|
734
|
+
const label = svgEl('text', { class: 'arch-node-label', x: x + 8, y: y + 34 });
|
|
735
|
+
label.textContent = node.label;
|
|
736
|
+
group.appendChild(label);
|
|
737
|
+
return group;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function renderEdge(edge, from, to, onSelect) {
|
|
741
|
+
const visual = protectionVisual(edge.verdict);
|
|
742
|
+
const midX = (from.x + to.x) / 2;
|
|
743
|
+
const midY = (from.y + to.y) / 2;
|
|
744
|
+
const path = svgEl('path', {
|
|
745
|
+
d: `M ${from.x} ${from.y} L ${to.x} ${to.y}`,
|
|
746
|
+
style: `stroke: var(${visual.colorVar})`,
|
|
747
|
+
});
|
|
748
|
+
const group = svgEl('g', {
|
|
749
|
+
class: 'arch-edge',
|
|
750
|
+
'data-selected': String(edge.selected),
|
|
751
|
+
'data-dimmed': String(edge.dimmed),
|
|
752
|
+
tabindex: '0',
|
|
753
|
+
role: 'button',
|
|
754
|
+
'aria-label': `Edge, protection ${visual.label}${edge.selected ? ', selected' : ''}`,
|
|
755
|
+
onClick: () => onSelect(edge.id),
|
|
756
|
+
onKeydown: (evt) => {
|
|
757
|
+
if (evt.key === 'Enter' || evt.key === ' ') {
|
|
758
|
+
evt.preventDefault();
|
|
759
|
+
onSelect(edge.id);
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
path.classList.add(`arch-edge-linestyle-${visual.lineStyle === 'solid' ? 'solid' : visual.lineStyle}`);
|
|
764
|
+
group.appendChild(path);
|
|
765
|
+
const glyph = svgEl('text', { class: 'arch-edge-glyph', x: midX, y: midY - 4, fill: `var(${visual.colorVar})` });
|
|
766
|
+
glyph.textContent = visual.glyph;
|
|
767
|
+
group.appendChild(glyph);
|
|
768
|
+
return group;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// The cluster glyph mirrors renderNode()'s own structure/pattern (a
|
|
772
|
+
// clickable <g> with a box + two text children) so it reads as "one more
|
|
773
|
+
// node-shaped thing" rather than a visually distinct control. `onExpand`
|
|
774
|
+
// is renderArchitectureView's own closure — adds this zone to
|
|
775
|
+
// `expandedZones` and re-renders.
|
|
776
|
+
function renderClusterGlyph(cluster, x, y, onExpand) {
|
|
777
|
+
const group = svgEl('g', {
|
|
778
|
+
class: 'arch-node-cluster',
|
|
779
|
+
tabindex: '0',
|
|
780
|
+
role: 'button',
|
|
781
|
+
'aria-label': `${cluster.count} more ${cluster.kindSummary || 'nodes'} folded into this cluster. Activate to expand.`,
|
|
782
|
+
onClick: onExpand,
|
|
783
|
+
onKeydown: (evt) => {
|
|
784
|
+
if (evt.key === 'Enter' || evt.key === ' ') {
|
|
785
|
+
evt.preventDefault();
|
|
786
|
+
onExpand();
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
group.appendChild(svgEl('rect', { class: 'arch-node-cluster-box', x, y, width: NODE_WIDTH, height: NODE_HEIGHT }));
|
|
791
|
+
const count = svgEl('text', { class: 'arch-node-cluster-count', x: x + 8, y: y + 20 });
|
|
792
|
+
count.textContent = `+${cluster.count}`;
|
|
793
|
+
group.appendChild(count);
|
|
794
|
+
const kind = svgEl('text', { class: 'arch-node-cluster-kind', x: x + 8, y: y + 36 });
|
|
795
|
+
kind.textContent = cluster.kindSummary;
|
|
796
|
+
group.appendChild(kind);
|
|
797
|
+
return group;
|
|
798
|
+
}
|