opencons 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/LICENSE +21 -0
- package/README.md +382 -0
- package/opencons.d.ts +55 -0
- package/package.json +73 -0
- package/scripts/vendor-d3.js +22 -0
- package/src/core/context.js +44 -0
- package/src/core/index.js +198 -0
- package/src/core/tracer.js +252 -0
- package/src/drivers/db-language.js +207 -0
- package/src/drivers/detect.js +62 -0
- package/src/drivers/drizzle.js +87 -0
- package/src/drivers/index.js +43 -0
- package/src/drivers/mongoose.js +89 -0
- package/src/drivers/mysql2.js +116 -0
- package/src/drivers/pg.js +130 -0
- package/src/drivers/prisma.js +109 -0
- package/src/drivers/record.js +158 -0
- package/src/index.js +28 -0
- package/src/integrations/nest-lifecycle.js +357 -0
- package/src/integrations/nest.js +89 -0
- package/src/interceptors/express.js +270 -0
- package/src/interceptors/require-hook.js +109 -0
- package/src/lib/config.js +139 -0
- package/src/lib/errors.js +54 -0
- package/src/lib/http-response.js +37 -0
- package/src/lib/logger.js +69 -0
- package/src/lib/serialize.js +22 -0
- package/src/server/static.js +165 -0
- package/src/server/ws.js +62 -0
- package/src/store/source-cache.js +120 -0
- package/src/store/trace-store.js +117 -0
- package/src/transform/ast.js +255 -0
- package/src/transform/natural-language.js +146 -0
- package/src/transform/probe.js +161 -0
- package/src/transform/register.js +44 -0
- package/src/utils/label.js +26 -0
- package/src/utils/observable.js +103 -0
- package/widget/app.js +356 -0
- package/widget/db-language.js +90 -0
- package/widget/graph.js +1167 -0
- package/widget/index.html +132 -0
- package/widget/styles.css +773 -0
- package/widget/timeline.js +57 -0
- package/widget/vendor/d3.min.js +2 -0
package/widget/graph.js
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const NODE_COLORS = {
|
|
4
|
+
request: '#6b7280',
|
|
5
|
+
response: '#6b7280',
|
|
6
|
+
middleware: '#ff7a45',
|
|
7
|
+
controller: '#a78bfa',
|
|
8
|
+
branch: '#f59e0b',
|
|
9
|
+
loop: '#ff4d00',
|
|
10
|
+
db: '#3b82f6',
|
|
11
|
+
error: '#ef4444',
|
|
12
|
+
ghost: '#4b5563',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const PATH_PALETTE = ['#ff4d00', '#a78bfa', '#60a5fa', '#f472b6', '#fb923c', '#34d399', '#fbbf24'];
|
|
16
|
+
const SURFACE_FILL = '#222222';
|
|
17
|
+
const SURFACE_STROKE = '#3a3a3a';
|
|
18
|
+
|
|
19
|
+
const NODE_W = 148;
|
|
20
|
+
const NODE_H = 42;
|
|
21
|
+
const DB_HUB_W = 228;
|
|
22
|
+
const DB_HUB_H = 84;
|
|
23
|
+
const DB_QUERY_W = 176;
|
|
24
|
+
const DB_QUERY_H = 52;
|
|
25
|
+
const DB_QUERY_GAP = 64;
|
|
26
|
+
const DB_LANE_OFFSET = 58;
|
|
27
|
+
const DB_HUB_GAP = 96;
|
|
28
|
+
const DECISION_W = 260;
|
|
29
|
+
const DECISION_H = 118;
|
|
30
|
+
const GHOST_W = 148;
|
|
31
|
+
const GHOST_H = 34;
|
|
32
|
+
const GAP_STD = 110;
|
|
33
|
+
const GAP_DECISION = 180;
|
|
34
|
+
const BRANCH_OFFSET = 92;
|
|
35
|
+
const MIN_EDGE_GAP = 48;
|
|
36
|
+
const OUTCOME_H = 19;
|
|
37
|
+
const CANVAS_PAD = 96;
|
|
38
|
+
|
|
39
|
+
/** @type {d3.ZoomBehavior | null} */
|
|
40
|
+
let activeZoom = null;
|
|
41
|
+
|
|
42
|
+
/** @type {d3.Selection | null} */
|
|
43
|
+
let activeSvg = null;
|
|
44
|
+
|
|
45
|
+
/** @type {d3.Selection | null} */
|
|
46
|
+
let activeRoot = null;
|
|
47
|
+
|
|
48
|
+
window.OpenconsGraph = {
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} trace
|
|
51
|
+
* @param {(node: object) => void} onNodeSelect
|
|
52
|
+
*/
|
|
53
|
+
render(trace, onNodeSelect) {
|
|
54
|
+
const svg = d3.select('#graph-svg');
|
|
55
|
+
svg.selectAll('*').remove();
|
|
56
|
+
|
|
57
|
+
const container = document.getElementById('graph-view');
|
|
58
|
+
const width = container.clientWidth;
|
|
59
|
+
const height = container.clientHeight;
|
|
60
|
+
|
|
61
|
+
const displayNodes = prepareDisplayNodes(trace.nodes);
|
|
62
|
+
const spine = buildExecutionPath(displayNodes, trace.edges);
|
|
63
|
+
const layout = layoutGraph(displayNodes, trace.edges, spine, width, height);
|
|
64
|
+
|
|
65
|
+
svg.attr('viewBox', [0, 0, layout.width, layout.height]);
|
|
66
|
+
|
|
67
|
+
const tooltip = document.getElementById('node-tooltip');
|
|
68
|
+
const g = svg.append('g').attr('class', 'graph-root');
|
|
69
|
+
|
|
70
|
+
const zoom = d3
|
|
71
|
+
.zoom()
|
|
72
|
+
.scaleExtent([0.02, 16])
|
|
73
|
+
.filter((event) => {
|
|
74
|
+
if (event.type === 'wheel') return true;
|
|
75
|
+
if (event.type.startsWith('touch')) return !event.target.closest?.('.graph-node');
|
|
76
|
+
if (event.button && event.button !== 0) return false;
|
|
77
|
+
if (event.type === 'mousedown') return !event.target.closest?.('.graph-node');
|
|
78
|
+
return true;
|
|
79
|
+
})
|
|
80
|
+
.on('zoom', (event) => g.attr('transform', event.transform));
|
|
81
|
+
|
|
82
|
+
svg.call(zoom);
|
|
83
|
+
activeZoom = zoom;
|
|
84
|
+
activeSvg = svg;
|
|
85
|
+
activeRoot = g;
|
|
86
|
+
|
|
87
|
+
const defs = svg.append('defs');
|
|
88
|
+
|
|
89
|
+
const dotPattern = defs
|
|
90
|
+
.append('pattern')
|
|
91
|
+
.attr('id', 'dot-grid')
|
|
92
|
+
.attr('width', 22)
|
|
93
|
+
.attr('height', 22)
|
|
94
|
+
.attr('patternUnits', 'userSpaceOnUse');
|
|
95
|
+
dotPattern.append('circle').attr('cx', 1).attr('cy', 1).attr('r', 1).attr('fill', '#2a3042');
|
|
96
|
+
|
|
97
|
+
const shadow = defs.append('filter').attr('id', 'node-shadow').attr('x', '-20%').attr('y', '-20%').attr('width', '140%').attr('height', '140%');
|
|
98
|
+
shadow.append('feDropShadow').attr('dx', 0).attr('dy', 2).attr('stdDeviation', 3).attr('flood-color', '#000').attr('flood-opacity', 0.35);
|
|
99
|
+
|
|
100
|
+
g.insert('rect', ':first-child')
|
|
101
|
+
.attr('class', 'graph-bg')
|
|
102
|
+
.attr('x', 0)
|
|
103
|
+
.attr('y', 0)
|
|
104
|
+
.attr('width', layout.width)
|
|
105
|
+
.attr('height', layout.height)
|
|
106
|
+
.attr('fill', 'url(#dot-grid)');
|
|
107
|
+
|
|
108
|
+
g.selectAll('.graph-link')
|
|
109
|
+
.data(layout.links)
|
|
110
|
+
.join('path')
|
|
111
|
+
.attr('class', (d) =>
|
|
112
|
+
['graph-link', d.active ? 'active' : 'inactive', d.kind || ''].filter(Boolean).join(' ')
|
|
113
|
+
)
|
|
114
|
+
.attr('stroke', (d) => linkColor(d))
|
|
115
|
+
.attr('d', (d) => mindMapLink(d.source, d.target, d.kind));
|
|
116
|
+
|
|
117
|
+
const drag = d3
|
|
118
|
+
.drag()
|
|
119
|
+
.clickDistance(5)
|
|
120
|
+
.on('start', function onDragStart(event) {
|
|
121
|
+
event.sourceEvent.stopPropagation();
|
|
122
|
+
d3.select(this).raise().classed('is-dragging', true);
|
|
123
|
+
})
|
|
124
|
+
.on('drag', function onDrag(event, d) {
|
|
125
|
+
const transform = d3.zoomTransform(svg.node());
|
|
126
|
+
d.x += event.dx / transform.k;
|
|
127
|
+
d.y += event.dy / transform.k;
|
|
128
|
+
d3.select(this).attr('transform', `translate(${d.x},${d.y})`);
|
|
129
|
+
refreshGraphLinks(g);
|
|
130
|
+
})
|
|
131
|
+
.on('end', function onDragEnd(event, d) {
|
|
132
|
+
d3.select(this).classed('is-dragging', false);
|
|
133
|
+
if (event.sourceEvent.defaultPrevented) return;
|
|
134
|
+
const dx = event.x - event.subject.x;
|
|
135
|
+
const dy = event.y - event.subject.y;
|
|
136
|
+
if (Math.hypot(dx, dy) < 4 && !d.isGhost) {
|
|
137
|
+
onNodeSelect(d);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const nodeGroups = g
|
|
142
|
+
.selectAll('.graph-node')
|
|
143
|
+
.data(layout.nodes)
|
|
144
|
+
.join('g')
|
|
145
|
+
.attr('class', (d) =>
|
|
146
|
+
[
|
|
147
|
+
'graph-node',
|
|
148
|
+
`type-${d.type}`,
|
|
149
|
+
d.isDecision ? 'is-decision' : '',
|
|
150
|
+
d.isGhost ? 'is-ghost' : '',
|
|
151
|
+
d.onSpine === false ? 'is-stub' : '',
|
|
152
|
+
d.isDbHub ? 'is-db-hub' : '',
|
|
153
|
+
d.isDbQuery ? 'is-db-query' : '',
|
|
154
|
+
]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(' ')
|
|
157
|
+
)
|
|
158
|
+
.attr('transform', (d) => `translate(${d.x},${d.y})`)
|
|
159
|
+
.call(drag)
|
|
160
|
+
.on('mouseenter', (event, d) => showTooltip(event, d, tooltip))
|
|
161
|
+
.on('mousemove', (event) => moveTooltip(event, tooltip))
|
|
162
|
+
.on('mouseleave', () => hideTooltip(tooltip));
|
|
163
|
+
|
|
164
|
+
nodeGroups.each(function drawNode(d) {
|
|
165
|
+
const group = d3.select(this);
|
|
166
|
+
|
|
167
|
+
if (d.isGhost) {
|
|
168
|
+
renderGhostNode(group, d);
|
|
169
|
+
} else if (d.isDecision) {
|
|
170
|
+
renderDecisionNode(group, d);
|
|
171
|
+
} else if (d.isDbHub) {
|
|
172
|
+
renderDbHub(group, d);
|
|
173
|
+
} else if (d.type === 'db' || d.isDbQuery) {
|
|
174
|
+
renderDbQueryNode(group, d);
|
|
175
|
+
} else {
|
|
176
|
+
renderStandardNode(group, d);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
requestAnimationFrame(() => {
|
|
181
|
+
fitGraphToView(svg, zoom, g, width, height);
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
zoomIn() {
|
|
186
|
+
if (!activeSvg || !activeZoom) return;
|
|
187
|
+
activeSvg.transition().duration(180).call(activeZoom.scaleBy, 1.35);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
zoomOut() {
|
|
191
|
+
if (!activeSvg || !activeZoom) return;
|
|
192
|
+
activeSvg.transition().duration(180).call(activeZoom.scaleBy, 0.74);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
resetView() {
|
|
196
|
+
if (!activeSvg || !activeZoom || !activeRoot) return;
|
|
197
|
+
const container = document.getElementById('graph-view');
|
|
198
|
+
fitGraphToView(activeSvg, activeZoom, activeRoot, container.clientWidth, container.clientHeight);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {object[]} nodes
|
|
204
|
+
*/
|
|
205
|
+
function prepareDisplayNodes(nodes) {
|
|
206
|
+
return nodes
|
|
207
|
+
.filter((node) => {
|
|
208
|
+
if (node.source?.kind !== 'else') return true;
|
|
209
|
+
return !node.outcomes;
|
|
210
|
+
})
|
|
211
|
+
.map((node) => finalizeBranchOutcomes(node));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {object} node
|
|
216
|
+
*/
|
|
217
|
+
function finalizeBranchOutcomes(node) {
|
|
218
|
+
if (!node.outcomes?.length) return node;
|
|
219
|
+
|
|
220
|
+
if (node.value === false && node.has_else && node.taken_outcome !== 'else') {
|
|
221
|
+
return {
|
|
222
|
+
...node,
|
|
223
|
+
outcomes: node.outcomes.map((outcome) =>
|
|
224
|
+
outcome.key === 'else'
|
|
225
|
+
? { ...outcome, label: 'Else block — skipped', taken: false }
|
|
226
|
+
: outcome
|
|
227
|
+
),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return node;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {object[]} nodes
|
|
236
|
+
* @param {object[]} edges
|
|
237
|
+
*/
|
|
238
|
+
function buildExecutionPath(nodes, edges) {
|
|
239
|
+
const byId = new Map(nodes.map((n) => [n.id, { ...n }]));
|
|
240
|
+
const adjacency = new Map();
|
|
241
|
+
|
|
242
|
+
for (const edge of edges) {
|
|
243
|
+
if (edge.parallel) continue;
|
|
244
|
+
if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
|
|
245
|
+
adjacency.get(edge.from).push(edge.to);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const start =
|
|
249
|
+
nodes.find((n) => n.type === 'request') ||
|
|
250
|
+
nodes.find((n) => !edges.some((e) => e.to === n.id));
|
|
251
|
+
|
|
252
|
+
if (!start) return nodes.map((n) => ({ ...n }));
|
|
253
|
+
|
|
254
|
+
const path = [];
|
|
255
|
+
const visited = new Set();
|
|
256
|
+
let current = start.id;
|
|
257
|
+
|
|
258
|
+
while (current && !visited.has(current)) {
|
|
259
|
+
visited.add(current);
|
|
260
|
+
const node = byId.get(current);
|
|
261
|
+
if (node) path.push(node);
|
|
262
|
+
const nextIds = adjacency.get(current) || [];
|
|
263
|
+
current = nextIds[0];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return path;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {object[]} nodes
|
|
271
|
+
* @param {object[]} edges
|
|
272
|
+
* @param {object[]} spine
|
|
273
|
+
* @param {number} viewportWidth
|
|
274
|
+
* @param {number} viewportHeight
|
|
275
|
+
*/
|
|
276
|
+
function layoutGraph(nodes, edges, spine, viewportWidth, viewportHeight) {
|
|
277
|
+
const base = layoutHorizontalTree(spine, viewportWidth, viewportHeight);
|
|
278
|
+
layoutDbHub(nodes, edges, base);
|
|
279
|
+
|
|
280
|
+
const maxY = Math.max(...base.nodes.map((node) => node.y + node.nodeHeight / 2), 0);
|
|
281
|
+
const minY = Math.min(...base.nodes.map((node) => node.y - node.nodeHeight / 2), 0);
|
|
282
|
+
base.height = Math.max(base.height, maxY - minY + CANVAS_PAD * 2);
|
|
283
|
+
|
|
284
|
+
return base;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Route every DB query through one global Database hub:
|
|
289
|
+
* handler → query card → hub → query card → handler
|
|
290
|
+
* @param {object[]} nodes
|
|
291
|
+
* @param {object[]} edges
|
|
292
|
+
* @param {{ nodes: object[], links: object[], height: number }} base
|
|
293
|
+
*/
|
|
294
|
+
function layoutDbHub(nodes, edges, base) {
|
|
295
|
+
const dbNodes = nodes.filter((node) => node.type === 'db');
|
|
296
|
+
if (!dbNodes.length) return;
|
|
297
|
+
|
|
298
|
+
const byId = new Map(base.nodes.map((node) => [node.id, node]));
|
|
299
|
+
const parallelEdges = edges.filter((edge) => edge.parallel);
|
|
300
|
+
const spineNodes = base.nodes.filter((node) => node.onSpine);
|
|
301
|
+
const minX = Math.min(...spineNodes.map((node) => node.x), CANVAS_PAD);
|
|
302
|
+
const maxX = Math.max(...spineNodes.map((node) => node.x), CANVAS_PAD + 200);
|
|
303
|
+
const spineY = spineNodes[0]?.y || base.height / 2;
|
|
304
|
+
const hubX = (minX + maxX) / 2;
|
|
305
|
+
|
|
306
|
+
const drivers = [...new Set(dbNodes.map((node) => node.driver).filter(Boolean))];
|
|
307
|
+
const parentQueryCount = new Map();
|
|
308
|
+
const layoutQueries = [];
|
|
309
|
+
|
|
310
|
+
for (const dbNode of dbNodes) {
|
|
311
|
+
const edge = parallelEdges.find((entry) => entry.to === dbNode.id);
|
|
312
|
+
const parent = edge?.from ? byId.get(edge.from) : null;
|
|
313
|
+
if (!parent) continue;
|
|
314
|
+
|
|
315
|
+
const laneIndex = parentQueryCount.get(parent.id) || 0;
|
|
316
|
+
parentQueryCount.set(parent.id, laneIndex + 1);
|
|
317
|
+
|
|
318
|
+
layoutQueries.push({
|
|
319
|
+
dbNode,
|
|
320
|
+
parent,
|
|
321
|
+
laneIndex,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!layoutQueries.length) return;
|
|
326
|
+
|
|
327
|
+
let maxQueryBottom = spineY;
|
|
328
|
+
|
|
329
|
+
for (const entry of layoutQueries) {
|
|
330
|
+
const { dbNode, parent, laneIndex } = entry;
|
|
331
|
+
const queryY = parent.y + parent.nodeHeight / 2 + DB_LANE_OFFSET + laneIndex * DB_QUERY_GAP;
|
|
332
|
+
maxQueryBottom = Math.max(maxQueryBottom, queryY + DB_QUERY_H / 2);
|
|
333
|
+
|
|
334
|
+
const layoutQuery = {
|
|
335
|
+
...dbNode,
|
|
336
|
+
x: parent.x,
|
|
337
|
+
y: queryY,
|
|
338
|
+
lane: laneIndex,
|
|
339
|
+
depth: parent.depth + 0.2,
|
|
340
|
+
onSpine: false,
|
|
341
|
+
isDbQuery: true,
|
|
342
|
+
isDbHub: false,
|
|
343
|
+
isDecision: false,
|
|
344
|
+
isGhost: false,
|
|
345
|
+
parentNodeId: parent.id,
|
|
346
|
+
parentLabel: parent.label,
|
|
347
|
+
nodeWidth: DB_QUERY_W,
|
|
348
|
+
nodeHeight: DB_QUERY_H,
|
|
349
|
+
pathColor: '#3b82f6',
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
base.nodes.push(layoutQuery);
|
|
353
|
+
entry.layoutQuery = layoutQuery;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const hubY = maxQueryBottom + DB_HUB_GAP;
|
|
357
|
+
const hubNode = {
|
|
358
|
+
id: '__db_hub__',
|
|
359
|
+
type: 'db-hub',
|
|
360
|
+
label: 'Database',
|
|
361
|
+
isDbHub: true,
|
|
362
|
+
isDbQuery: false,
|
|
363
|
+
queryCount: layoutQueries.length,
|
|
364
|
+
drivers,
|
|
365
|
+
dbQueries: layoutQueries.map((entry) => entry.dbNode),
|
|
366
|
+
x: hubX,
|
|
367
|
+
y: hubY,
|
|
368
|
+
lane: 0,
|
|
369
|
+
depth: 999,
|
|
370
|
+
onSpine: false,
|
|
371
|
+
isDecision: false,
|
|
372
|
+
isGhost: false,
|
|
373
|
+
nodeWidth: DB_HUB_W,
|
|
374
|
+
nodeHeight: DB_HUB_H,
|
|
375
|
+
pathColor: '#2563eb',
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
base.nodes.push(hubNode);
|
|
379
|
+
|
|
380
|
+
for (const entry of layoutQueries) {
|
|
381
|
+
const { parent, layoutQuery } = entry;
|
|
382
|
+
|
|
383
|
+
base.links.push({ source: parent, target: layoutQuery, active: true, kind: 'db-out' });
|
|
384
|
+
base.links.push({ source: layoutQuery, target: hubNode, active: true, kind: 'db-to-hub' });
|
|
385
|
+
base.links.push({ source: hubNode, target: layoutQuery, active: true, kind: 'db-from-hub' });
|
|
386
|
+
base.links.push({ source: layoutQuery, target: parent, active: true, kind: 'db-return' });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Horizontal decision-tree layout: flat spine left→right, branches fork locally.
|
|
392
|
+
* @param {object[]} spine
|
|
393
|
+
* @param {number} viewportWidth
|
|
394
|
+
* @param {number} viewportHeight
|
|
395
|
+
*/
|
|
396
|
+
function layoutHorizontalTree(spine, viewportWidth, viewportHeight) {
|
|
397
|
+
const spineNodes = [];
|
|
398
|
+
const links = [];
|
|
399
|
+
let xCursor = CANVAS_PAD;
|
|
400
|
+
|
|
401
|
+
const graphHeight = Math.max(
|
|
402
|
+
viewportHeight,
|
|
403
|
+
BRANCH_OFFSET * 2 + DECISION_H + GHOST_H + CANVAS_PAD * 2
|
|
404
|
+
);
|
|
405
|
+
const spineY = graphHeight / 2;
|
|
406
|
+
|
|
407
|
+
spine.forEach((node, depth) => {
|
|
408
|
+
const isDecision = Boolean(node.outcomes?.length);
|
|
409
|
+
const dims = nodeDimensions(node, isDecision);
|
|
410
|
+
const prev = spineNodes[spineNodes.length - 1];
|
|
411
|
+
const gap = prev ? edgeGap(prev.isDecision, isDecision) : 0;
|
|
412
|
+
|
|
413
|
+
xCursor += gap;
|
|
414
|
+
const x = xCursor + dims.nodeWidth / 2;
|
|
415
|
+
|
|
416
|
+
const layoutNode = {
|
|
417
|
+
...node,
|
|
418
|
+
x,
|
|
419
|
+
y: spineY,
|
|
420
|
+
lane: 0,
|
|
421
|
+
depth,
|
|
422
|
+
onSpine: true,
|
|
423
|
+
isDecision,
|
|
424
|
+
isGhost: false,
|
|
425
|
+
pathColor: PATH_PALETTE[depth % PATH_PALETTE.length],
|
|
426
|
+
...dims,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
spineNodes.push(layoutNode);
|
|
430
|
+
xCursor += dims.nodeWidth / 2;
|
|
431
|
+
|
|
432
|
+
if (prev) {
|
|
433
|
+
links.push({
|
|
434
|
+
source: prev,
|
|
435
|
+
target: layoutNode,
|
|
436
|
+
active: true,
|
|
437
|
+
kind: 'spine',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
resolveSpineCollisions(spineNodes);
|
|
443
|
+
|
|
444
|
+
const layoutNodes = [...spineNodes];
|
|
445
|
+
|
|
446
|
+
for (const layoutNode of spineNodes) {
|
|
447
|
+
if (!layoutNode.isDecision) continue;
|
|
448
|
+
|
|
449
|
+
for (const outcome of layoutNode.outcomes) {
|
|
450
|
+
if (outcome.taken) continue;
|
|
451
|
+
|
|
452
|
+
const above = outcome.key === 'then';
|
|
453
|
+
const branchY = above
|
|
454
|
+
? spineY - layoutNode.nodeHeight / 2 - BRANCH_OFFSET - GHOST_H / 2
|
|
455
|
+
: spineY + layoutNode.nodeHeight / 2 + BRANCH_OFFSET + GHOST_H / 2;
|
|
456
|
+
|
|
457
|
+
const ghost = {
|
|
458
|
+
id: `${layoutNode.id}__ghost__${outcome.key}`,
|
|
459
|
+
type: 'ghost',
|
|
460
|
+
label: outcome.label,
|
|
461
|
+
summary: 'Not taken',
|
|
462
|
+
x: layoutNode.x,
|
|
463
|
+
y: branchY,
|
|
464
|
+
lane: 0,
|
|
465
|
+
depth: layoutNode.depth + 0.35,
|
|
466
|
+
onSpine: false,
|
|
467
|
+
isGhost: true,
|
|
468
|
+
isDecision: false,
|
|
469
|
+
nodeWidth: GHOST_W,
|
|
470
|
+
nodeHeight: GHOST_H,
|
|
471
|
+
outcomeKey: outcome.key,
|
|
472
|
+
branchAbove: above,
|
|
473
|
+
pathColor: layoutNode.pathColor,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
layoutNodes.push(ghost);
|
|
477
|
+
links.push({
|
|
478
|
+
source: layoutNode,
|
|
479
|
+
target: ghost,
|
|
480
|
+
active: false,
|
|
481
|
+
kind: 'branch',
|
|
482
|
+
branchKey: outcome.key,
|
|
483
|
+
branchAbove: above,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const last = spineNodes[spineNodes.length - 1];
|
|
489
|
+
const graphWidth = last
|
|
490
|
+
? Math.max(viewportWidth, last.x + last.nodeWidth / 2 + CANVAS_PAD)
|
|
491
|
+
: viewportWidth;
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
nodes: layoutNodes,
|
|
495
|
+
links,
|
|
496
|
+
width: graphWidth,
|
|
497
|
+
height: graphHeight,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Empty space between the right edge of one node and the left edge of the next.
|
|
503
|
+
* @param {boolean} prevIsDecision
|
|
504
|
+
* @param {boolean} nextIsDecision
|
|
505
|
+
*/
|
|
506
|
+
function edgeGap(prevIsDecision, nextIsDecision) {
|
|
507
|
+
const preferred = prevIsDecision || nextIsDecision ? GAP_DECISION : GAP_STD;
|
|
508
|
+
return preferred + MIN_EDGE_GAP;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Push spine nodes apart so bounding boxes never overlap.
|
|
513
|
+
* @param {object[]} spineNodes
|
|
514
|
+
*/
|
|
515
|
+
function resolveSpineCollisions(spineNodes) {
|
|
516
|
+
for (let i = 1; i < spineNodes.length; i += 1) {
|
|
517
|
+
const prev = spineNodes[i - 1];
|
|
518
|
+
const curr = spineNodes[i];
|
|
519
|
+
const requiredGap = edgeGap(prev.isDecision, curr.isDecision);
|
|
520
|
+
const actualGap = curr.x - prev.x - prev.nodeWidth / 2 - curr.nodeWidth / 2;
|
|
521
|
+
const shift = requiredGap - actualGap;
|
|
522
|
+
|
|
523
|
+
if (shift > 0) {
|
|
524
|
+
for (let j = i; j < spineNodes.length; j += 1) {
|
|
525
|
+
spineNodes[j].x += shift;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* @param {object} node
|
|
533
|
+
* @param {boolean} isDecision
|
|
534
|
+
*/
|
|
535
|
+
function nodeDimensions(node, isDecision) {
|
|
536
|
+
if (isDecision) {
|
|
537
|
+
return { nodeWidth: DECISION_W, nodeHeight: DECISION_H };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return { nodeWidth: NODE_W, nodeHeight: NODE_H };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* @param {object} link
|
|
545
|
+
*/
|
|
546
|
+
function linkColor(link) {
|
|
547
|
+
if (link.kind?.startsWith('db-')) return NODE_COLORS.db;
|
|
548
|
+
if (!link.active) return link.target.pathColor || '#4b5563';
|
|
549
|
+
return link.target.pathColor || link.source.pathColor || PATH_PALETTE[0];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Smooth mind-map style connectors.
|
|
554
|
+
* @param {object} source
|
|
555
|
+
* @param {object} target
|
|
556
|
+
* @param {string} [kind]
|
|
557
|
+
*/
|
|
558
|
+
function mindMapLink(source, target, kind) {
|
|
559
|
+
const sw = source.nodeWidth / 2;
|
|
560
|
+
const tw = target.nodeWidth / 2;
|
|
561
|
+
|
|
562
|
+
if (kind === 'branch') {
|
|
563
|
+
const above = target.y < source.y;
|
|
564
|
+
const startY = above ? source.y - source.nodeHeight / 2 : source.y + source.nodeHeight / 2;
|
|
565
|
+
const endY = above ? target.y + target.nodeHeight / 2 : target.y - target.nodeHeight / 2;
|
|
566
|
+
const bulge = 56;
|
|
567
|
+
return `M${source.x},${startY} C${source.x + bulge},${startY} ${target.x + bulge},${endY} ${target.x},${endY}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (kind === 'db-out') {
|
|
571
|
+
const x1 = source.x;
|
|
572
|
+
const y1 = source.y + source.nodeHeight / 2;
|
|
573
|
+
const x2 = target.x;
|
|
574
|
+
const y2 = target.y - target.nodeHeight / 2;
|
|
575
|
+
const midY = (y1 + y2) / 2;
|
|
576
|
+
return `M${x1},${y1} C${x1},${midY} ${x2},${midY} ${x2},${y2}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (kind === 'db-to-hub') {
|
|
580
|
+
const x1 = source.x + 10;
|
|
581
|
+
const y1 = source.y + source.nodeHeight / 2;
|
|
582
|
+
const x2 = target.x + 14;
|
|
583
|
+
const y2 = target.y - target.nodeHeight / 2;
|
|
584
|
+
const bend = Math.abs(x2 - x1) * 0.35 + 36;
|
|
585
|
+
return `M${x1},${y1} C${x1},${y1 + bend} ${x2},${y2 - bend} ${x2},${y2}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (kind === 'db-from-hub') {
|
|
589
|
+
const x1 = source.x - 10;
|
|
590
|
+
const y1 = source.y - source.nodeHeight / 2;
|
|
591
|
+
const x2 = target.x - 14;
|
|
592
|
+
const y2 = target.y + target.nodeHeight / 2;
|
|
593
|
+
const bend = Math.abs(x2 - x1) * 0.25 + 28;
|
|
594
|
+
return `M${x1},${y1} C${x1},${y1 - bend} ${x2},${y2 + bend} ${x2},${y2}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (kind === 'db-return') {
|
|
598
|
+
const x1 = source.x;
|
|
599
|
+
const y1 = source.y - source.nodeHeight / 2;
|
|
600
|
+
const x2 = target.x;
|
|
601
|
+
const y2 = target.y - target.nodeHeight / 2;
|
|
602
|
+
const lift = Math.max(42, Math.abs(y2 - y1) * 0.45);
|
|
603
|
+
return `M${x1},${y1} C${x1 - 28},${y1 - lift} ${x2 + 28},${y2 - lift} ${x2},${y2}`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const x1 = source.x + sw;
|
|
607
|
+
const x2 = target.x - tw;
|
|
608
|
+
const y1 = source.y;
|
|
609
|
+
const y2 = target.y;
|
|
610
|
+
const curve = Math.max(50, (x2 - x1) * 0.5);
|
|
611
|
+
|
|
612
|
+
return `M${x1},${y1} C${x1 + curve},${y1} ${x2 - curve},${y2} ${x2},${y2}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderStandardNode(group, d) {
|
|
616
|
+
const halfW = NODE_W / 2;
|
|
617
|
+
const halfH = NODE_H / 2;
|
|
618
|
+
const accent = d.pathColor || NODE_COLORS[d.type] || NODE_COLORS.middleware;
|
|
619
|
+
const isRoot = d.type === 'request';
|
|
620
|
+
const w = isRoot ? NODE_W + 12 : NODE_W;
|
|
621
|
+
const h = isRoot ? NODE_H + 6 : NODE_H;
|
|
622
|
+
const hw = w / 2;
|
|
623
|
+
const hh = h / 2;
|
|
624
|
+
|
|
625
|
+
group.attr('filter', 'url(#node-shadow)');
|
|
626
|
+
|
|
627
|
+
group
|
|
628
|
+
.append('rect')
|
|
629
|
+
.attr('x', -hw)
|
|
630
|
+
.attr('y', -hh)
|
|
631
|
+
.attr('width', w)
|
|
632
|
+
.attr('height', h)
|
|
633
|
+
.attr('rx', hh)
|
|
634
|
+
.attr('fill', SURFACE_FILL)
|
|
635
|
+
.attr('stroke', isRoot ? accent : SURFACE_STROKE)
|
|
636
|
+
.attr('stroke-width', isRoot ? 2.5 : 1.5);
|
|
637
|
+
|
|
638
|
+
group
|
|
639
|
+
.append('rect')
|
|
640
|
+
.attr('x', -hw)
|
|
641
|
+
.attr('y', -hh)
|
|
642
|
+
.attr('width', 5)
|
|
643
|
+
.attr('height', h)
|
|
644
|
+
.attr('rx', 2)
|
|
645
|
+
.attr('fill', accent);
|
|
646
|
+
|
|
647
|
+
group
|
|
648
|
+
.append('text')
|
|
649
|
+
.attr('text-anchor', 'middle')
|
|
650
|
+
.attr('dy', '0.35em')
|
|
651
|
+
.attr('fill', '#e8eaf0')
|
|
652
|
+
.attr('font-size', isRoot ? 12 : 11)
|
|
653
|
+
.attr('font-weight', 600)
|
|
654
|
+
.text(truncate(shortNodeTitle(d), 20));
|
|
655
|
+
|
|
656
|
+
const caption = typeCaption(d);
|
|
657
|
+
if (caption) {
|
|
658
|
+
group
|
|
659
|
+
.append('text')
|
|
660
|
+
.attr('text-anchor', 'middle')
|
|
661
|
+
.attr('dy', hh + 14)
|
|
662
|
+
.attr('fill', '#8b90a5')
|
|
663
|
+
.attr('font-size', 9.5)
|
|
664
|
+
.text(caption);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* @param {d3.Selection} group
|
|
670
|
+
* @param {object} d
|
|
671
|
+
*/
|
|
672
|
+
function renderDecisionNode(group, d) {
|
|
673
|
+
const halfW = DECISION_W / 2;
|
|
674
|
+
const halfH = DECISION_H / 2;
|
|
675
|
+
const accent = d.pathColor || NODE_COLORS.branch;
|
|
676
|
+
|
|
677
|
+
group.attr('filter', 'url(#node-shadow)');
|
|
678
|
+
|
|
679
|
+
group
|
|
680
|
+
.append('rect')
|
|
681
|
+
.attr('class', 'decision-card')
|
|
682
|
+
.attr('x', -halfW)
|
|
683
|
+
.attr('y', -halfH)
|
|
684
|
+
.attr('width', DECISION_W)
|
|
685
|
+
.attr('height', DECISION_H)
|
|
686
|
+
.attr('rx', 14)
|
|
687
|
+
.attr('fill', SURFACE_FILL)
|
|
688
|
+
.attr('stroke', accent)
|
|
689
|
+
.attr('stroke-width', 2.5);
|
|
690
|
+
|
|
691
|
+
group
|
|
692
|
+
.append('text')
|
|
693
|
+
.attr('class', 'decision-eyebrow')
|
|
694
|
+
.attr('text-anchor', 'middle')
|
|
695
|
+
.attr('x', 0)
|
|
696
|
+
.attr('y', -halfH + 16)
|
|
697
|
+
.attr('fill', accent)
|
|
698
|
+
.attr('font-size', 9)
|
|
699
|
+
.attr('font-weight', 700)
|
|
700
|
+
.attr('letter-spacing', '0.06em')
|
|
701
|
+
.text('DECISION');
|
|
702
|
+
|
|
703
|
+
group
|
|
704
|
+
.append('text')
|
|
705
|
+
.attr('class', 'decision-title')
|
|
706
|
+
.attr('text-anchor', 'middle')
|
|
707
|
+
.attr('x', 0)
|
|
708
|
+
.attr('y', -halfH + 34)
|
|
709
|
+
.attr('fill', '#f3f4f6')
|
|
710
|
+
.attr('font-size', 11)
|
|
711
|
+
.attr('font-weight', 600)
|
|
712
|
+
.call(wrapText, DECISION_W - 22, 2);
|
|
713
|
+
|
|
714
|
+
group.select('.decision-title').text(shortNodeTitle(d));
|
|
715
|
+
|
|
716
|
+
const summary = d.summary || '';
|
|
717
|
+
if (summary) {
|
|
718
|
+
group
|
|
719
|
+
.append('text')
|
|
720
|
+
.attr('text-anchor', 'middle')
|
|
721
|
+
.attr('x', 0)
|
|
722
|
+
.attr('y', -halfH + 56)
|
|
723
|
+
.attr('fill', '#9ca3af')
|
|
724
|
+
.attr('font-size', 10)
|
|
725
|
+
.text(truncate(summary, 38));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const outcomes = d.outcomes || [];
|
|
729
|
+
const startY = -halfH + (summary ? 72 : 64);
|
|
730
|
+
|
|
731
|
+
outcomes.forEach((outcome, index) => {
|
|
732
|
+
const y = startY + index * OUTCOME_H;
|
|
733
|
+
const taken = Boolean(outcome.taken);
|
|
734
|
+
const chipColor = taken ? '#22c55e' : '#4b5563';
|
|
735
|
+
|
|
736
|
+
group
|
|
737
|
+
.append('rect')
|
|
738
|
+
.attr('class', `outcome-pill${taken ? ' taken' : ''}`)
|
|
739
|
+
.attr('x', -halfW + 10)
|
|
740
|
+
.attr('y', y - 12)
|
|
741
|
+
.attr('width', DECISION_W - 20)
|
|
742
|
+
.attr('height', 16)
|
|
743
|
+
.attr('rx', 8)
|
|
744
|
+
.attr('fill', taken ? 'rgba(34, 197, 94, 0.15)' : 'rgba(75, 85, 99, 0.12)')
|
|
745
|
+
.attr('stroke', chipColor)
|
|
746
|
+
.attr('stroke-width', 1)
|
|
747
|
+
.attr('opacity', taken ? 1 : 0.65);
|
|
748
|
+
|
|
749
|
+
group
|
|
750
|
+
.append('text')
|
|
751
|
+
.attr('x', -halfW + 16)
|
|
752
|
+
.attr('y', y)
|
|
753
|
+
.attr('fill', taken ? '#bbf7d0' : '#9ca3af')
|
|
754
|
+
.attr('font-size', 9.5)
|
|
755
|
+
.attr('font-weight', taken ? 700 : 500)
|
|
756
|
+
.text(`${taken ? '✓' : '○'} ${truncate(outcome.label, 32)}`);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* @param {d3.Selection} group
|
|
762
|
+
* @param {object} d
|
|
763
|
+
*/
|
|
764
|
+
function renderDbHub(group, d) {
|
|
765
|
+
const halfW = DB_HUB_W / 2;
|
|
766
|
+
const halfH = DB_HUB_H / 2;
|
|
767
|
+
const driverLabel = describeDbDrivers(d.drivers);
|
|
768
|
+
const queryLabel = d.queryCount === 1 ? '1 query' : `${d.queryCount} queries`;
|
|
769
|
+
|
|
770
|
+
group.attr('filter', 'url(#node-shadow)');
|
|
771
|
+
|
|
772
|
+
group
|
|
773
|
+
.append('rect')
|
|
774
|
+
.attr('x', -halfW)
|
|
775
|
+
.attr('y', -halfH)
|
|
776
|
+
.attr('width', DB_HUB_W)
|
|
777
|
+
.attr('height', DB_HUB_H)
|
|
778
|
+
.attr('rx', 16)
|
|
779
|
+
.attr('fill', 'rgba(37, 99, 235, 0.18)')
|
|
780
|
+
.attr('stroke', '#2563eb')
|
|
781
|
+
.attr('stroke-width', 2.5);
|
|
782
|
+
|
|
783
|
+
group
|
|
784
|
+
.append('rect')
|
|
785
|
+
.attr('x', -halfW)
|
|
786
|
+
.attr('y', -halfH)
|
|
787
|
+
.attr('width', DB_HUB_W)
|
|
788
|
+
.attr('height', 8)
|
|
789
|
+
.attr('rx', 16)
|
|
790
|
+
.attr('fill', '#3b82f6');
|
|
791
|
+
|
|
792
|
+
group
|
|
793
|
+
.append('ellipse')
|
|
794
|
+
.attr('cx', -halfW + 28)
|
|
795
|
+
.attr('cy', -6)
|
|
796
|
+
.attr('rx', 16)
|
|
797
|
+
.attr('ry', 5)
|
|
798
|
+
.attr('fill', 'rgba(147, 197, 253, 0.35)')
|
|
799
|
+
.attr('stroke', '#93c5fd')
|
|
800
|
+
.attr('stroke-width', 1.2);
|
|
801
|
+
|
|
802
|
+
group
|
|
803
|
+
.append('rect')
|
|
804
|
+
.attr('x', -halfW + 12)
|
|
805
|
+
.attr('y', -2)
|
|
806
|
+
.attr('width', 32)
|
|
807
|
+
.attr('height', 28)
|
|
808
|
+
.attr('rx', 4)
|
|
809
|
+
.attr('fill', 'rgba(30, 58, 138, 0.55)')
|
|
810
|
+
.attr('stroke', '#60a5fa')
|
|
811
|
+
.attr('stroke-width', 1.5);
|
|
812
|
+
|
|
813
|
+
group
|
|
814
|
+
.append('text')
|
|
815
|
+
.attr('x', -halfW + 56)
|
|
816
|
+
.attr('y', -14)
|
|
817
|
+
.attr('fill', '#eff6ff')
|
|
818
|
+
.attr('font-size', 13)
|
|
819
|
+
.attr('font-weight', 700)
|
|
820
|
+
.text('Database');
|
|
821
|
+
|
|
822
|
+
group
|
|
823
|
+
.append('text')
|
|
824
|
+
.attr('x', -halfW + 56)
|
|
825
|
+
.attr('y', 2)
|
|
826
|
+
.attr('fill', '#bfdbfe')
|
|
827
|
+
.attr('font-size', 9.5)
|
|
828
|
+
.text(truncate(driverLabel, 24));
|
|
829
|
+
|
|
830
|
+
group
|
|
831
|
+
.append('text')
|
|
832
|
+
.attr('x', -halfW + 56)
|
|
833
|
+
.attr('y', 18)
|
|
834
|
+
.attr('fill', '#93c5fd')
|
|
835
|
+
.attr('font-size', 9)
|
|
836
|
+
.text(queryLabel);
|
|
837
|
+
|
|
838
|
+
group
|
|
839
|
+
.append('text')
|
|
840
|
+
.attr('text-anchor', 'middle')
|
|
841
|
+
.attr('y', halfH - 12)
|
|
842
|
+
.attr('fill', '#7dd3fc')
|
|
843
|
+
.attr('font-size', 8.5)
|
|
844
|
+
.attr('font-weight', 600)
|
|
845
|
+
.text('All queries route here');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* @param {string[]} [drivers]
|
|
850
|
+
*/
|
|
851
|
+
function describeDbDrivers(drivers) {
|
|
852
|
+
if (!drivers?.length) return 'SQL datastore';
|
|
853
|
+
|
|
854
|
+
const labels = drivers.map((driver) => {
|
|
855
|
+
if (driver === 'drizzle') return 'Drizzle';
|
|
856
|
+
if (driver === 'pg') return 'PostgreSQL';
|
|
857
|
+
if (driver === 'mysql2') return 'MySQL';
|
|
858
|
+
if (driver === 'prisma') return 'Prisma';
|
|
859
|
+
if (driver === 'mongoose') return 'MongoDB';
|
|
860
|
+
return driver;
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
return labels.join(' · ');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function renderDbQueryNode(group, d) {
|
|
867
|
+
const halfW = DB_QUERY_W / 2;
|
|
868
|
+
const halfH = DB_QUERY_H / 2;
|
|
869
|
+
const lang = window.OpenconsDbLanguage;
|
|
870
|
+
const icon = lang ? lang.dbActionIcon(d) : '◎';
|
|
871
|
+
const title = lang ? lang.dbNodeTitle(d) : d.label || 'Query';
|
|
872
|
+
const intent = lang ? lang.dbNodeIntent(d) : 'Database query';
|
|
873
|
+
const result = lang ? lang.dbNodeResult(d) : d.summary || '';
|
|
874
|
+
const failed = Boolean(d.exit_reason);
|
|
875
|
+
|
|
876
|
+
group.attr('filter', 'url(#node-shadow)');
|
|
877
|
+
|
|
878
|
+
group
|
|
879
|
+
.append('rect')
|
|
880
|
+
.attr('x', -halfW)
|
|
881
|
+
.attr('y', -halfH)
|
|
882
|
+
.attr('width', DB_QUERY_W)
|
|
883
|
+
.attr('height', DB_QUERY_H)
|
|
884
|
+
.attr('rx', 11)
|
|
885
|
+
.attr('fill', failed ? 'rgba(239, 68, 68, 0.12)' : 'rgba(59, 130, 246, 0.12)')
|
|
886
|
+
.attr('stroke', failed ? NODE_COLORS.error : '#60a5fa')
|
|
887
|
+
.attr('stroke-width', 1.8);
|
|
888
|
+
|
|
889
|
+
group
|
|
890
|
+
.append('text')
|
|
891
|
+
.attr('x', -halfW + 12)
|
|
892
|
+
.attr('y', -10)
|
|
893
|
+
.attr('fill', '#93c5fd')
|
|
894
|
+
.attr('font-size', 10)
|
|
895
|
+
.attr('font-weight', 700)
|
|
896
|
+
.text(icon);
|
|
897
|
+
|
|
898
|
+
group
|
|
899
|
+
.append('text')
|
|
900
|
+
.attr('x', -halfW + 26)
|
|
901
|
+
.attr('y', -10)
|
|
902
|
+
.attr('fill', '#eff6ff')
|
|
903
|
+
.attr('font-size', 10)
|
|
904
|
+
.attr('font-weight', 700)
|
|
905
|
+
.text(truncate(title, 18));
|
|
906
|
+
|
|
907
|
+
group
|
|
908
|
+
.append('text')
|
|
909
|
+
.attr('x', -halfW + 12)
|
|
910
|
+
.attr('y', 6)
|
|
911
|
+
.attr('fill', '#bfdbfe')
|
|
912
|
+
.attr('font-size', 8.8)
|
|
913
|
+
.text(truncate(intent, 26));
|
|
914
|
+
|
|
915
|
+
const footer = [];
|
|
916
|
+
if (result) footer.push(truncate(result, 22));
|
|
917
|
+
if (d.duration_ms != null) footer.push(`${d.duration_ms}ms`);
|
|
918
|
+
|
|
919
|
+
group
|
|
920
|
+
.append('text')
|
|
921
|
+
.attr('x', -halfW + 12)
|
|
922
|
+
.attr('y', 20)
|
|
923
|
+
.attr('fill', failed ? '#fca5a5' : '#7dd3fc')
|
|
924
|
+
.attr('font-size', 8.5)
|
|
925
|
+
.attr('font-weight', 600)
|
|
926
|
+
.text(footer.join(' · '));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function renderGhostNode(group, d) {
|
|
930
|
+
const halfW = GHOST_W / 2;
|
|
931
|
+
const halfH = GHOST_H / 2;
|
|
932
|
+
const accent = d.pathColor || '#4b5563';
|
|
933
|
+
|
|
934
|
+
group
|
|
935
|
+
.append('rect')
|
|
936
|
+
.attr('x', -halfW)
|
|
937
|
+
.attr('y', -halfH)
|
|
938
|
+
.attr('width', GHOST_W)
|
|
939
|
+
.attr('height', GHOST_H)
|
|
940
|
+
.attr('rx', halfH)
|
|
941
|
+
.attr('fill', 'rgba(30, 34, 48, 0.6)')
|
|
942
|
+
.attr('stroke', accent)
|
|
943
|
+
.attr('stroke-width', 1.5)
|
|
944
|
+
.attr('stroke-dasharray', '5 4')
|
|
945
|
+
.attr('opacity', 0.85);
|
|
946
|
+
|
|
947
|
+
group
|
|
948
|
+
.append('text')
|
|
949
|
+
.attr('text-anchor', 'middle')
|
|
950
|
+
.attr('dy', '0.35em')
|
|
951
|
+
.attr('fill', '#9ca3af')
|
|
952
|
+
.attr('font-size', 9.5)
|
|
953
|
+
.attr('font-weight', 500)
|
|
954
|
+
.text(truncate(d.label, 28));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* @param {object} node
|
|
959
|
+
*/
|
|
960
|
+
function shortNodeTitle(node) {
|
|
961
|
+
if (node.condition) {
|
|
962
|
+
const text = node.condition.length > 56 ? `${node.condition.slice(0, 56)}…` : node.condition;
|
|
963
|
+
return text;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return displayNodeTitle(node);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* @param {object} node
|
|
971
|
+
*/
|
|
972
|
+
function displayNodeTitle(node) {
|
|
973
|
+
if (node.label) return node.label;
|
|
974
|
+
return node.type;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* @param {object} node
|
|
979
|
+
*/
|
|
980
|
+
function typeCaption(node) {
|
|
981
|
+
if (node.duration_ms != null) return `${node.duration_ms}ms`;
|
|
982
|
+
if (node.summary) return truncate(node.summary, 22);
|
|
983
|
+
return node.type;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* @param {MouseEvent} event
|
|
988
|
+
* @param {object} node
|
|
989
|
+
* @param {HTMLElement} tooltip
|
|
990
|
+
*/
|
|
991
|
+
function showTooltip(event, node, tooltip) {
|
|
992
|
+
if (node.isGhost) {
|
|
993
|
+
tooltip.innerHTML = `<dt>Skipped branch</dt><dd>${escapeHtml(node.label)}</dd>`;
|
|
994
|
+
tooltip.classList.remove('hidden');
|
|
995
|
+
moveTooltip(event, tooltip);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const lines = [`<dt>Step</dt><dd>${escapeHtml(displayNodeTitle(node))}</dd>`];
|
|
1000
|
+
|
|
1001
|
+
if (node.summary) {
|
|
1002
|
+
lines.push(`<dt>What happened</dt><dd>${escapeHtml(node.summary)}</dd>`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (node.condition) {
|
|
1006
|
+
lines.push(`<dt>Condition</dt><dd>${escapeHtml(node.condition)}</dd>`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (node.duration_ms != null) {
|
|
1010
|
+
lines.push(`<dt>Duration</dt><dd>${node.duration_ms}ms</dd>`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (node.outcomes?.length) {
|
|
1014
|
+
const outcomeText = node.outcomes
|
|
1015
|
+
.map((outcome) => `${outcome.taken ? '✓' : '○'} ${outcome.label}`)
|
|
1016
|
+
.join('<br>');
|
|
1017
|
+
lines.push(`<dt>Branches</dt><dd>${outcomeText}</dd>`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (node.type === 'db-hub') {
|
|
1021
|
+
lines.push(`<dt>Role</dt><dd>Central database — every query routes through here</dd>`);
|
|
1022
|
+
lines.push(`<dt>Stack</dt><dd>${escapeHtml(describeDbDrivers(node.drivers))}</dd>`);
|
|
1023
|
+
if (node.dbQueries?.length) {
|
|
1024
|
+
const queryLines = node.dbQueries
|
|
1025
|
+
.map((query) => {
|
|
1026
|
+
const lang = window.OpenconsDbLanguage;
|
|
1027
|
+
const title = lang ? lang.dbNodeTitle(query) : query.label;
|
|
1028
|
+
const result = lang ? lang.dbNodeResult(query) : query.db_result;
|
|
1029
|
+
return `${title} → ${result}`;
|
|
1030
|
+
})
|
|
1031
|
+
.join('<br>');
|
|
1032
|
+
lines.push(`<dt>Queries</dt><dd>${queryLines}</dd>`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (node.type === 'db' || node.isDbQuery) {
|
|
1037
|
+
const lang = window.OpenconsDbLanguage;
|
|
1038
|
+
const intent = lang ? lang.dbNodeIntent(node) : node.db_intent;
|
|
1039
|
+
const result = lang ? lang.dbNodeResult(node) : node.db_result;
|
|
1040
|
+
|
|
1041
|
+
if (node.parentLabel) {
|
|
1042
|
+
lines.push(`<dt>From</dt><dd>${escapeHtml(node.parentLabel)}</dd>`);
|
|
1043
|
+
}
|
|
1044
|
+
if (intent) lines.push(`<dt>Sent to database</dt><dd>${escapeHtml(intent)}</dd>`);
|
|
1045
|
+
if (result) lines.push(`<dt>Came back with</dt><dd>${escapeHtml(result)}</dd>`);
|
|
1046
|
+
if (node.query) lines.push(`<dt>SQL</dt><dd>${escapeHtml(node.query)}</dd>`);
|
|
1047
|
+
if (node.params) {
|
|
1048
|
+
lines.push(`<dt>Parameters</dt><dd>${escapeHtml(JSON.stringify(node.params))}</dd>`);
|
|
1049
|
+
}
|
|
1050
|
+
if (node.rows != null) lines.push(`<dt>Rows</dt><dd>${node.rows}</dd>`);
|
|
1051
|
+
if (node.driver) lines.push(`<dt>Driver</dt><dd>${escapeHtml(node.driver)}</dd>`);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (node.source?.file) {
|
|
1055
|
+
const loc =
|
|
1056
|
+
node.source.line != null ? `${node.source.file}:${node.source.line}` : node.source.file;
|
|
1057
|
+
lines.push(`<dt>Source</dt><dd>${escapeHtml(loc)}</dd>`);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
tooltip.innerHTML = lines.join('');
|
|
1061
|
+
tooltip.classList.remove('hidden');
|
|
1062
|
+
moveTooltip(event, tooltip);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* @param {d3.Selection} root
|
|
1067
|
+
*/
|
|
1068
|
+
function refreshGraphLinks(root) {
|
|
1069
|
+
root.selectAll('.graph-link').attr('d', (link) => mindMapLink(link.source, link.target, link.kind));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* @param {d3.Selection} svg
|
|
1074
|
+
* @param {d3.ZoomBehavior} zoom
|
|
1075
|
+
* @param {d3.Selection} g
|
|
1076
|
+
* @param {number} width
|
|
1077
|
+
* @param {number} height
|
|
1078
|
+
*/
|
|
1079
|
+
function fitGraphToView(svg, zoom, g, width, height) {
|
|
1080
|
+
const bounds = g.node()?.getBBox?.();
|
|
1081
|
+
if (!bounds || bounds.width === 0) return;
|
|
1082
|
+
|
|
1083
|
+
const padding = 48;
|
|
1084
|
+
const scaleX = (width - padding * 2) / bounds.width;
|
|
1085
|
+
const scaleY = (height - padding * 2) / bounds.height;
|
|
1086
|
+
let scale = Math.min(scaleX, scaleY);
|
|
1087
|
+
|
|
1088
|
+
// Keep text readable — don't shrink the whole graph into a blur.
|
|
1089
|
+
const MIN_READABLE_SCALE = 0.72;
|
|
1090
|
+
scale = Math.max(MIN_READABLE_SCALE, Math.min(scale, 1));
|
|
1091
|
+
|
|
1092
|
+
let tx;
|
|
1093
|
+
if (bounds.width * scale > width - padding * 2) {
|
|
1094
|
+
tx = padding - bounds.x * scale;
|
|
1095
|
+
} else {
|
|
1096
|
+
tx = (width - bounds.width * scale) / 2 - bounds.x * scale;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const ty = (height - bounds.height * scale) / 2 - bounds.y * scale;
|
|
1100
|
+
|
|
1101
|
+
svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* @param {d3.Selection} text
|
|
1106
|
+
* @param {number} width
|
|
1107
|
+
* @param {number} maxLines
|
|
1108
|
+
*/
|
|
1109
|
+
function wrapText(text, width, maxLines) {
|
|
1110
|
+
text.each(function wrap() {
|
|
1111
|
+
const self = d3.select(this);
|
|
1112
|
+
const words = self.text().split(/\s+/).filter(Boolean);
|
|
1113
|
+
let line = [];
|
|
1114
|
+
let lineNumber = 0;
|
|
1115
|
+
let tspan = self.text(null).append('tspan').attr('x', 0).attr('dy', 0);
|
|
1116
|
+
|
|
1117
|
+
for (const word of words) {
|
|
1118
|
+
line.push(word);
|
|
1119
|
+
tspan.text(line.join(' '));
|
|
1120
|
+
if (tspan.node().getComputedTextLength() > width) {
|
|
1121
|
+
line.pop();
|
|
1122
|
+
tspan.text(line.join(' '));
|
|
1123
|
+
line = [word];
|
|
1124
|
+
lineNumber += 1;
|
|
1125
|
+
if (lineNumber >= maxLines) break;
|
|
1126
|
+
tspan = self.append('tspan').attr('x', 0).attr('dy', '1.1em').text(word);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* @param {MouseEvent} event
|
|
1134
|
+
* @param {HTMLElement} tooltip
|
|
1135
|
+
*/
|
|
1136
|
+
function moveTooltip(event, tooltip) {
|
|
1137
|
+
const container = document.getElementById('graph-view');
|
|
1138
|
+
const rect = container.getBoundingClientRect();
|
|
1139
|
+
|
|
1140
|
+
tooltip.style.left = `${event.clientX - rect.left + 12}px`;
|
|
1141
|
+
tooltip.style.top = `${event.clientY - rect.top + 12}px`;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* @param {HTMLElement} tooltip
|
|
1146
|
+
*/
|
|
1147
|
+
function hideTooltip(tooltip) {
|
|
1148
|
+
tooltip.classList.add('hidden');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* @param {string} str
|
|
1153
|
+
* @param {number} max
|
|
1154
|
+
*/
|
|
1155
|
+
function truncate(str, max) {
|
|
1156
|
+
return str.length > max ? `${str.slice(0, max)}…` : str;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* @param {string} str
|
|
1161
|
+
*/
|
|
1162
|
+
function escapeHtml(str) {
|
|
1163
|
+
return str
|
|
1164
|
+
.replace(/&/g, '&')
|
|
1165
|
+
.replace(/</g, '<')
|
|
1166
|
+
.replace(/>/g, '>');
|
|
1167
|
+
}
|