@principal-ai/principal-view-react 0.16.57 → 0.16.59
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/dist/subsystem/SubsystemComponentGraph.d.ts +14 -5
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +436 -50
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +73 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +91 -11
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +38 -3
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +95 -4
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +26 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +152 -13
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +181 -0
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +68 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +600 -50
- package/src/subsystem/model.test.ts +56 -0
- package/src/subsystem/model.ts +156 -12
- package/src/subsystem/nodes.test.ts +56 -0
- package/src/subsystem/nodes.tsx +122 -6
- package/src/utils/elkLayout.ts +164 -13
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* SubsystemComponentGraph — a clickable, read-only React Flow component graph
|
|
3
3
|
* for a subsystem snapshot.
|
|
4
4
|
*
|
|
5
|
-
* Nodes are positioned with ELK auto-layout (layered, minimized crossings
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Nodes are positioned with ELK auto-layout (layered, minimized crossings,
|
|
6
|
+
* process-aware compound groups). Components sharing a `process` render
|
|
7
|
+
* inside one labeled boundary frame; nodes without one sit outside every
|
|
8
|
+
* boundary. Clicking a component invokes `onSelect`.
|
|
9
9
|
*
|
|
10
10
|
* This is a focused fork of the package's `GraphRenderer` pipeline (same ELK
|
|
11
11
|
* edge routing, delayed fitView, Background/Controls/MiniMap, node/edge type
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
applyNodeChanges,
|
|
33
33
|
} from '@xyflow/react';
|
|
34
34
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
35
|
-
import { Map as MapIcon } from 'lucide-react';
|
|
35
|
+
import { Map as MapIcon, X } from 'lucide-react';
|
|
36
36
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
37
37
|
import {
|
|
38
38
|
buildSubsystemGraph,
|
|
@@ -41,9 +41,10 @@ import {
|
|
|
41
41
|
type SubsystemComponentEdge,
|
|
42
42
|
type SubsystemComponent,
|
|
43
43
|
type SubsystemEdgeMechanism,
|
|
44
|
+
type SubsystemThroughline,
|
|
44
45
|
} from './model';
|
|
45
46
|
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
46
|
-
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
|
|
47
|
+
import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
47
48
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
48
49
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
49
50
|
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
@@ -52,9 +53,24 @@ import { FileDrawer } from './FileDrawer';
|
|
|
52
53
|
import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
|
|
53
54
|
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
54
55
|
|
|
56
|
+
/** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
|
|
57
|
+
const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
|
|
58
|
+
/** Rough monospace width at fontSize 10 + horizontal padding/border. */
|
|
59
|
+
const EDGE_LABEL_CHAR_PX = 6.2;
|
|
60
|
+
const EDGE_LABEL_PAD_PX = 18;
|
|
61
|
+
|
|
55
62
|
export interface SubsystemComponentGraphProps {
|
|
56
63
|
components: SubsystemComponent[];
|
|
57
64
|
edges: SubsystemComponentEdge[];
|
|
65
|
+
/**
|
|
66
|
+
* Ordered execution stories over the graph's edges — one throughline per
|
|
67
|
+
* flow. When present the sidebar's bottom half offers a Files/Flows toggle:
|
|
68
|
+
* the flows panel lists each throughline's steps (`symbol` or `file:line`);
|
|
69
|
+
* clicking a flow row toggles its steps; clicking a step focuses that
|
|
70
|
+
* step's edge. Opened flows stay on the canvas (unselected ones dimmed);
|
|
71
|
+
* everything else is hidden.
|
|
72
|
+
*/
|
|
73
|
+
throughlines?: SubsystemThroughline[];
|
|
58
74
|
onSelect?: (componentId: string) => void;
|
|
59
75
|
/** Called when an edge is clicked (relationship / mechanism + refs seam). */
|
|
60
76
|
onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
|
|
@@ -99,6 +115,7 @@ export interface SubsystemComponentGraphProps {
|
|
|
99
115
|
|
|
100
116
|
const nodeTypes: NodeTypes = {
|
|
101
117
|
'subsystem-component': SubsystemComponentNode,
|
|
118
|
+
'subsystem-group': SubsystemGroupNode,
|
|
102
119
|
};
|
|
103
120
|
|
|
104
121
|
const edgeTypes: EdgeTypes = {
|
|
@@ -126,7 +143,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
126
143
|
measured: { w: number; h: number } | null;
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
146
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
130
147
|
const { theme } = useTheme();
|
|
131
148
|
const { fitView } = useReactFlow();
|
|
132
149
|
const viewport = useViewport();
|
|
@@ -147,6 +164,18 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
147
164
|
const [legendOpen, setLegendOpen] = useState(false);
|
|
148
165
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
149
166
|
const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
|
|
167
|
+
// Throughline focus — selected flow (or step) is full strength; other
|
|
168
|
+
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
169
|
+
const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
|
|
170
|
+
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
171
|
+
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
|
|
172
|
+
// Sidebar bottom half: which panel is shown when throughlines exist.
|
|
173
|
+
const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
|
|
174
|
+
throughlines?.length ? 'flows' : 'files',
|
|
175
|
+
);
|
|
176
|
+
// Throughline flows the user has expanded (via the title row). Closed by
|
|
177
|
+
// default so a graph with several flows doesn't dump every step list at once.
|
|
178
|
+
const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
|
|
150
179
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
151
180
|
// closure over the effect deps) can toggle without a stale value.
|
|
152
181
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
@@ -202,22 +231,25 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
202
231
|
const measuredDimsRef = useRef(new Map<string, { width: number; height: number }>());
|
|
203
232
|
const pendingMeasuredRef = useRef(false);
|
|
204
233
|
|
|
205
|
-
// Pass 2: once every node has a measured dimension, re-run ELK.
|
|
234
|
+
// Pass 2: once every leaf node has a measured dimension, re-run ELK.
|
|
235
|
+
// Group parents are sized by ELK, not measured — exclude them or pass 2
|
|
236
|
+
// would wait forever for dimensions that never arrive.
|
|
206
237
|
const prevMeasuredSigRef = useRef('');
|
|
207
238
|
const pass2DoneRef = useRef(false);
|
|
208
239
|
const triggerPass2 = useCallback(() => {
|
|
209
240
|
if (pass2DoneRef.current) return;
|
|
210
241
|
const dims = measuredDimsRef.current;
|
|
211
|
-
|
|
212
|
-
|
|
242
|
+
const leafNodes = built.nodes.filter((n) => n.type !== 'subsystem-group');
|
|
243
|
+
if (dims.size < leafNodes.length) return;
|
|
244
|
+
const sig = leafNodes.map((n) => `${n.id}:${dims.get(n.id)?.width ?? '?'}`).join(',');
|
|
213
245
|
if (sig.includes('?:')) return;
|
|
214
246
|
if (sig === prevMeasuredSigRef.current) return;
|
|
215
247
|
prevMeasuredSigRef.current = sig;
|
|
216
248
|
pendingMeasuredRef.current = false;
|
|
217
249
|
pass2DoneRef.current = true;
|
|
218
250
|
|
|
219
|
-
const measuredWidths = new Map(
|
|
220
|
-
const measuredHeights = new Map(
|
|
251
|
+
const measuredWidths = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.width]));
|
|
252
|
+
const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.height]));
|
|
221
253
|
let alive = true;
|
|
222
254
|
void buildSubsystemGraph(
|
|
223
255
|
{ components, edges },
|
|
@@ -245,6 +277,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
245
277
|
}
|
|
246
278
|
const src = edgeById.get(edgeId);
|
|
247
279
|
setSelectedEdgeId(edgeId);
|
|
280
|
+
// A direct edge selection on the canvas supersedes any throughline focus.
|
|
281
|
+
setFocusedThroughlineId(null);
|
|
282
|
+
setFocusedStepIndex(null);
|
|
248
283
|
if (src) onEdgeSelect?.(src);
|
|
249
284
|
},
|
|
250
285
|
[edgeById, onEdgeSelect],
|
|
@@ -263,6 +298,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
263
298
|
}
|
|
264
299
|
setSelected(comp);
|
|
265
300
|
setSelectedEdgeId(null);
|
|
301
|
+
setFocusedThroughlineId(null);
|
|
302
|
+
setFocusedStepIndex(null);
|
|
266
303
|
onSelect?.(id);
|
|
267
304
|
}
|
|
268
305
|
};
|
|
@@ -281,26 +318,140 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
281
318
|
const xyflowNodesBase = nodes as Node[];
|
|
282
319
|
const baseEdges = convertedEdges as Edge[];
|
|
283
320
|
|
|
321
|
+
// When an edge is selected, dim every other edge + its label to focus it.
|
|
322
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
323
|
+
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
324
|
+
// without a stale closure value.
|
|
325
|
+
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
326
|
+
selectedEdgeIdRef.current = selectedEdgeId;
|
|
327
|
+
// Edge ids in throughline focus (an active flow's edge set, or a single
|
|
328
|
+
// step's edge). Used to frame the camera. `null` = no throughline focus.
|
|
329
|
+
const focusEdgeIds = useMemo(() => {
|
|
330
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
331
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
332
|
+
if (!tl) return null;
|
|
333
|
+
if (focusedStepIndex != null) {
|
|
334
|
+
const step = tl.steps[focusedStepIndex];
|
|
335
|
+
return step ? new Set([step.edgeId]) : null;
|
|
336
|
+
}
|
|
337
|
+
return new Set(tl.steps.map((s) => s.edgeId));
|
|
338
|
+
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
339
|
+
|
|
340
|
+
// 1-based step numbers per edge of the selected flow (an edge can appear
|
|
341
|
+
// in more than one step).
|
|
342
|
+
const selectedFlowStepNos = useMemo(() => {
|
|
343
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
344
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
345
|
+
if (!tl) return null;
|
|
346
|
+
const map = new Map<string, number[]>();
|
|
347
|
+
tl.steps.forEach((s, i) => {
|
|
348
|
+
const list = map.get(s.edgeId) ?? [];
|
|
349
|
+
list.push(i + 1);
|
|
350
|
+
map.set(s.edgeId, list);
|
|
351
|
+
});
|
|
352
|
+
return map;
|
|
353
|
+
}, [throughlines, focusedThroughlineId]);
|
|
354
|
+
|
|
355
|
+
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
356
|
+
const openedEdgeIds = useMemo(() => {
|
|
357
|
+
if (!throughlines || expandedThroughlines.size === 0) return null;
|
|
358
|
+
const ids = new Set<string>();
|
|
359
|
+
for (const tl of throughlines) {
|
|
360
|
+
if (!expandedThroughlines.has(tl.id)) continue;
|
|
361
|
+
for (const s of tl.steps) ids.add(s.edgeId);
|
|
362
|
+
}
|
|
363
|
+
return ids.size > 0 ? ids : null;
|
|
364
|
+
}, [throughlines, expandedThroughlines]);
|
|
365
|
+
|
|
366
|
+
const endpointsOf = (edgeIds: ReadonlySet<string> | null): Set<string> | null => {
|
|
367
|
+
if (!edgeIds) return null;
|
|
368
|
+
const ids = new Set<string>();
|
|
369
|
+
for (const e of baseEdges) {
|
|
370
|
+
if (!edgeIds.has(e.id)) continue;
|
|
371
|
+
ids.add(e.source);
|
|
372
|
+
ids.add(e.target);
|
|
373
|
+
}
|
|
374
|
+
return ids.size > 0 ? ids : null;
|
|
375
|
+
};
|
|
376
|
+
const openedNodeIds = useMemo(
|
|
377
|
+
() => endpointsOf(openedEdgeIds),
|
|
378
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
379
|
+
[baseEdges, openedEdgeIds],
|
|
380
|
+
);
|
|
381
|
+
// Endpoints of the bright set: the selected step's edge, or the whole flow
|
|
382
|
+
// when no step is focused.
|
|
383
|
+
const brightNodeIds = useMemo(
|
|
384
|
+
() => endpointsOf(focusEdgeIds),
|
|
385
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
386
|
+
[baseEdges, focusEdgeIds],
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
// Source + target of the focused step/flow (or a canvas-selected edge). If a
|
|
390
|
+
// file is open, those endpoints stay undimmed even when they don't live in
|
|
391
|
+
// that file.
|
|
392
|
+
const focusNodeIds = useMemo(() => {
|
|
393
|
+
if (brightNodeIds) return brightNodeIds;
|
|
394
|
+
if (selectedEdgeId) {
|
|
395
|
+
const e = baseEdges.find((x) => x.id === selectedEdgeId);
|
|
396
|
+
if (e) return new Set([e.source, e.target]);
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}, [baseEdges, brightNodeIds, selectedEdgeId]);
|
|
400
|
+
|
|
284
401
|
// While a file is open in the drawer, tag each node with whether its
|
|
285
402
|
// component lives in that file — the node renderer spotlights matches and
|
|
286
403
|
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
404
|
+
// Opened-but-unselected members are dimmed; a selected step further dims
|
|
405
|
+
// the rest of its own flow. Nodes not on any opened flow are hidden.
|
|
287
406
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
288
|
-
// React Flow's own selection state from
|
|
407
|
+
// React Flow's own selection state from updating.
|
|
289
408
|
const dispNodes = useMemo(() => {
|
|
290
409
|
return xyflowNodesBase.map((n) => {
|
|
410
|
+
// Boundary frames follow their members: hidden when no member is
|
|
411
|
+
// visible, dimmed when members are dimmed. Never selectable.
|
|
412
|
+
if (n.type === 'subsystem-group') {
|
|
413
|
+
const memberIds = ((n.data as { region?: { memberIds?: string[] } } | undefined)?.region?.memberIds) ?? [];
|
|
414
|
+
const vis = flowElementVisibility({
|
|
415
|
+
inOpened: memberIds.some((id) => openedNodeIds?.has(id) === true),
|
|
416
|
+
inSelected: memberIds.some((id) => brightNodeIds?.has(id) === true),
|
|
417
|
+
anyOpened: openedNodeIds != null,
|
|
418
|
+
anySelected: brightNodeIds != null,
|
|
419
|
+
});
|
|
420
|
+
return {
|
|
421
|
+
...n,
|
|
422
|
+
hidden: vis.hidden,
|
|
423
|
+
selectable: false,
|
|
424
|
+
data: {
|
|
425
|
+
...(n.data as object),
|
|
426
|
+
...(vis.dimmed && { dimmed: true }),
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
291
430
|
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
292
|
-
const fileMatch =
|
|
431
|
+
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
293
432
|
const isSelected = selected?.id !== undefined && comp?.id === selected.id;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
433
|
+
const vis = flowElementVisibility({
|
|
434
|
+
inOpened: openedNodeIds?.has(n.id) === true,
|
|
435
|
+
inSelected: brightNodeIds?.has(n.id) === true,
|
|
436
|
+
anyOpened: openedNodeIds != null,
|
|
437
|
+
anySelected: brightNodeIds != null,
|
|
438
|
+
});
|
|
439
|
+
if (fileMatch === undefined && !isSelected && !vis.dimmed) {
|
|
440
|
+
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
441
|
+
return { ...n, hidden: vis.hidden, data: rest };
|
|
297
442
|
}
|
|
298
443
|
return {
|
|
299
444
|
...n,
|
|
300
|
-
|
|
445
|
+
hidden: vis.hidden,
|
|
446
|
+
data: {
|
|
447
|
+
...(n.data as object),
|
|
448
|
+
...(fileMatch !== undefined && { fileMatch }),
|
|
449
|
+
...(isSelected && { isSelected }),
|
|
450
|
+
...(vis.dimmed && { dimmed: true }),
|
|
451
|
+
},
|
|
301
452
|
};
|
|
302
453
|
});
|
|
303
|
-
}, [xyflowNodesBase, openFile, selected]);
|
|
454
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
304
455
|
|
|
305
456
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
306
457
|
const baseEdgesKey = useMemo(
|
|
@@ -308,28 +459,43 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
308
459
|
[convertedEdges],
|
|
309
460
|
);
|
|
310
461
|
|
|
311
|
-
// When an edge is selected, dim every other edge + its label to focus it.
|
|
312
|
-
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
313
|
-
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
314
|
-
// without a stale closure value.
|
|
315
|
-
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
316
|
-
selectedEdgeIdRef.current = selectedEdgeId;
|
|
317
462
|
const dispEdges = useMemo(() => {
|
|
463
|
+
const paint = (e: Edge, dimmed: boolean): Edge => {
|
|
464
|
+
const markerEnd = e.markerEnd;
|
|
465
|
+
const nextMarker =
|
|
466
|
+
dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
467
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
468
|
+
: markerEnd;
|
|
469
|
+
return {
|
|
470
|
+
...e,
|
|
471
|
+
data: { ...(e.data as object), dimmed },
|
|
472
|
+
markerEnd: nextMarker,
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
if (openedEdgeIds || focusEdgeIds) {
|
|
476
|
+
return baseEdges.map((e) => {
|
|
477
|
+
const vis = flowElementVisibility({
|
|
478
|
+
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
479
|
+
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
480
|
+
anyOpened: openedEdgeIds != null,
|
|
481
|
+
anySelected: focusEdgeIds != null,
|
|
482
|
+
});
|
|
483
|
+
return { ...paint(e, vis.dimmed), hidden: vis.hidden };
|
|
484
|
+
});
|
|
485
|
+
}
|
|
318
486
|
if (!selectedEdgeId) return baseEdges;
|
|
319
|
-
return baseEdges.map((e) => (
|
|
320
|
-
|
|
321
|
-
data: {
|
|
322
|
-
...(e.data as object),
|
|
323
|
-
dimmed: e.id !== selectedEdgeId,
|
|
324
|
-
},
|
|
325
|
-
}));
|
|
326
|
-
}, [baseEdges, selectedEdgeId]);
|
|
487
|
+
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
488
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
327
489
|
|
|
328
490
|
const onNodesChange = useCallback(
|
|
329
491
|
(changes: NodeChange[]) => {
|
|
330
492
|
// Capture dimension changes (React Flow's measurement callback).
|
|
493
|
+
// Group parents are ELK-sized — ignore their measurements.
|
|
494
|
+
const groupIds = new Set(
|
|
495
|
+
dispNodes.filter((n) => n.type === 'subsystem-group').map((n) => n.id),
|
|
496
|
+
);
|
|
331
497
|
for (const ch of changes) {
|
|
332
|
-
if (ch.type === 'dimensions' && ch.dimensions) {
|
|
498
|
+
if (ch.type === 'dimensions' && ch.dimensions && !groupIds.has(ch.id)) {
|
|
333
499
|
measuredDimsRef.current.set(ch.id, ch.dimensions);
|
|
334
500
|
pendingMeasuredRef.current = true;
|
|
335
501
|
}
|
|
@@ -363,6 +529,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
363
529
|
(_e, node: Node) => {
|
|
364
530
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
365
531
|
setSelectedEdgeId(null);
|
|
532
|
+
setFocusedThroughlineId(null);
|
|
533
|
+
setFocusedStepIndex(null);
|
|
366
534
|
if (node.type === 'subsystem-component' && comp) {
|
|
367
535
|
// Clicking the already-selected node unselects it (toggle off).
|
|
368
536
|
// Selection is independent of the file drawer — nodes never open it.
|
|
@@ -388,11 +556,14 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
388
556
|
const onPaneClick = useCallback(() => {
|
|
389
557
|
setSelected(null);
|
|
390
558
|
setSelectedEdgeId(null);
|
|
559
|
+
setFocusedThroughlineId(null);
|
|
560
|
+
setFocusedStepIndex(null);
|
|
391
561
|
}, []);
|
|
392
562
|
|
|
393
563
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
394
564
|
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
395
565
|
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
566
|
+
const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
|
|
396
567
|
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
|
|
397
568
|
const toggleRepoCollapsed = useCallback((key: string) => {
|
|
398
569
|
setCollapsedRepos((prev) => {
|
|
@@ -434,6 +605,71 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
434
605
|
[onFileSelect],
|
|
435
606
|
);
|
|
436
607
|
|
|
608
|
+
// Camera helper shared by the throughline interactions: frames the focused
|
|
609
|
+
// edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
|
|
610
|
+
// live positions + measured dims — so the frame always includes BOTH
|
|
611
|
+
// components the edge attaches to (and therefore the edge line between them).
|
|
612
|
+
const fitFocusBounds = useCallback(
|
|
613
|
+
(ids: ReadonlySet<string>) => {
|
|
614
|
+
const nodeIds = new Set<string>();
|
|
615
|
+
for (const e of baseEdges) {
|
|
616
|
+
if (!ids.has(e.id)) continue;
|
|
617
|
+
nodeIds.add(e.source);
|
|
618
|
+
nodeIds.add(e.target);
|
|
619
|
+
}
|
|
620
|
+
if (nodeIds.size === 0) return;
|
|
621
|
+
fitView({
|
|
622
|
+
nodes: [...nodeIds].map((id) => ({ id })),
|
|
623
|
+
padding: 0.25,
|
|
624
|
+
duration: 300,
|
|
625
|
+
});
|
|
626
|
+
},
|
|
627
|
+
[baseEdges, fitView],
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
631
|
+
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
632
|
+
// reads as the narrative.
|
|
633
|
+
const focusThroughlineEdges = useCallback(
|
|
634
|
+
(tl: SubsystemThroughline) => {
|
|
635
|
+
setSelected(null);
|
|
636
|
+
setSelectedEdgeId(null);
|
|
637
|
+
setFocusedStepIndex(null);
|
|
638
|
+
setFocusedThroughlineId(tl.id);
|
|
639
|
+
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
640
|
+
},
|
|
641
|
+
[fitFocusBounds],
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const clearThroughlineFocus = useCallback(() => {
|
|
645
|
+
setFocusedThroughlineId(null);
|
|
646
|
+
setFocusedStepIndex(null);
|
|
647
|
+
}, []);
|
|
648
|
+
|
|
649
|
+
// Focus a single step's edge on the canvas. The step's file:line is listed
|
|
650
|
+
// in the row; we don't open the drawer from here.
|
|
651
|
+
const focusThroughlineStep = useCallback(
|
|
652
|
+
(tl: SubsystemThroughline, stepIndex: number) => {
|
|
653
|
+
const step = tl.steps[stepIndex];
|
|
654
|
+
if (!step) return;
|
|
655
|
+
setSelected(null);
|
|
656
|
+
setSelectedEdgeId(null);
|
|
657
|
+
setFocusedStepIndex(stepIndex);
|
|
658
|
+
setFocusedThroughlineId(tl.id);
|
|
659
|
+
fitFocusBounds(new Set([step.edgeId]));
|
|
660
|
+
},
|
|
661
|
+
[fitFocusBounds],
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const toggleThroughlineCollapsed = useCallback((tlId: string) => {
|
|
665
|
+
setExpandedThroughlines((prev) => {
|
|
666
|
+
const next = new Set(prev);
|
|
667
|
+
if (next.has(tlId)) next.delete(tlId);
|
|
668
|
+
else next.add(tlId);
|
|
669
|
+
return next;
|
|
670
|
+
});
|
|
671
|
+
}, []);
|
|
672
|
+
|
|
437
673
|
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
438
674
|
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
439
675
|
useEffect(() => {
|
|
@@ -461,6 +697,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
461
697
|
if (!comp || comp.id === selectedRef.current?.id) return;
|
|
462
698
|
setSelected(comp);
|
|
463
699
|
setSelectedEdgeId(null);
|
|
700
|
+
setFocusedThroughlineId(null);
|
|
701
|
+
setFocusedStepIndex(null);
|
|
464
702
|
onSelect?.(comp.id);
|
|
465
703
|
},
|
|
466
704
|
[components, onSelect],
|
|
@@ -470,17 +708,27 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
470
708
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
471
709
|
// the actual edge path (not node-center approximations).
|
|
472
710
|
const edgeLabels = useMemo(() => {
|
|
473
|
-
return dispEdges
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
711
|
+
return dispEdges
|
|
712
|
+
.filter((e) => !e.hidden)
|
|
713
|
+
.map((e) => {
|
|
714
|
+
const d = e.data as {
|
|
715
|
+
mechanism?: string;
|
|
716
|
+
dimmed?: boolean;
|
|
717
|
+
labelX?: number;
|
|
718
|
+
labelY?: number;
|
|
719
|
+
pathLength?: number;
|
|
720
|
+
} | undefined;
|
|
721
|
+
return {
|
|
722
|
+
id: e.id,
|
|
723
|
+
mechanism: d?.mechanism ?? 'imports',
|
|
724
|
+
dimmed: d?.dimmed === true,
|
|
725
|
+
midX: d?.labelX ?? 0,
|
|
726
|
+
midY: d?.labelY ?? 0,
|
|
727
|
+
pathLength: d?.pathLength ?? 0,
|
|
728
|
+
stepNos: selectedFlowStepNos?.get(e.id),
|
|
729
|
+
};
|
|
730
|
+
});
|
|
731
|
+
}, [dispEdges, selectedFlowStepNos]);
|
|
484
732
|
|
|
485
733
|
const usedMechanisms = useMemo(() => {
|
|
486
734
|
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
@@ -521,8 +769,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
521
769
|
|
|
522
770
|
return (
|
|
523
771
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
524
|
-
{/* Sidebar: scrollable title/description on top,
|
|
525
|
-
{(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (
|
|
772
|
+
{/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
|
|
773
|
+
{(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
526
774
|
<div
|
|
527
775
|
style={{
|
|
528
776
|
width: 340,
|
|
@@ -576,7 +824,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
576
824
|
)}
|
|
577
825
|
{sidebarAfterDescription}
|
|
578
826
|
</div>
|
|
579
|
-
{treeFilePaths.length > 0 && (
|
|
827
|
+
{(treeFilePaths.length > 0 || hasThroughlines) && (
|
|
580
828
|
<div
|
|
581
829
|
style={{
|
|
582
830
|
height: '50%',
|
|
@@ -588,6 +836,72 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
588
836
|
overflow: 'hidden',
|
|
589
837
|
}}
|
|
590
838
|
>
|
|
839
|
+
{hasThroughlines && (
|
|
840
|
+
<div
|
|
841
|
+
role="tablist"
|
|
842
|
+
aria-label="Sidebar view"
|
|
843
|
+
style={{
|
|
844
|
+
display: 'flex',
|
|
845
|
+
width: '100%',
|
|
846
|
+
flexShrink: 0,
|
|
847
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
848
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
849
|
+
}}
|
|
850
|
+
>
|
|
851
|
+
{(['files', 'flows'] as const).map((view) => (
|
|
852
|
+
<button
|
|
853
|
+
key={view}
|
|
854
|
+
type="button"
|
|
855
|
+
role="tab"
|
|
856
|
+
aria-selected={sidebarView === view}
|
|
857
|
+
onClick={() => setSidebarView(view)}
|
|
858
|
+
style={{
|
|
859
|
+
flex: 1,
|
|
860
|
+
minWidth: 0,
|
|
861
|
+
padding: '8px 8px',
|
|
862
|
+
border: 'none',
|
|
863
|
+
borderRadius: 0,
|
|
864
|
+
background: sidebarView === view ? theme.colors.background : 'transparent',
|
|
865
|
+
color:
|
|
866
|
+
sidebarView === view
|
|
867
|
+
? theme.colors.text
|
|
868
|
+
: theme.colors.textSecondary,
|
|
869
|
+
fontSize: theme.fontSizes[1],
|
|
870
|
+
fontFamily: theme.fonts.monospace,
|
|
871
|
+
textTransform: 'capitalize',
|
|
872
|
+
cursor: 'pointer',
|
|
873
|
+
}}
|
|
874
|
+
>
|
|
875
|
+
{view}
|
|
876
|
+
</button>
|
|
877
|
+
))}
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
880
|
+
{sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (
|
|
881
|
+
<div
|
|
882
|
+
style={{
|
|
883
|
+
flex: 1,
|
|
884
|
+
minHeight: 0,
|
|
885
|
+
overflowY: 'auto',
|
|
886
|
+
padding: '0 4px 12px',
|
|
887
|
+
}}
|
|
888
|
+
>
|
|
889
|
+
{throughlines.map((tl) => (
|
|
890
|
+
<ThroughlineFlow
|
|
891
|
+
key={tl.id}
|
|
892
|
+
throughline={tl}
|
|
893
|
+
edges={edges}
|
|
894
|
+
collapsed={!expandedThroughlines.has(tl.id)}
|
|
895
|
+
active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
|
|
896
|
+
onToggleCollapsed={toggleThroughlineCollapsed}
|
|
897
|
+
onFocusFlow={focusThroughlineEdges}
|
|
898
|
+
onClearFocus={clearThroughlineFocus}
|
|
899
|
+
onFocusStep={focusThroughlineStep}
|
|
900
|
+
/>
|
|
901
|
+
))}
|
|
902
|
+
</div>
|
|
903
|
+
) : treeFilePaths.length > 0 ? (
|
|
904
|
+
<>
|
|
591
905
|
{repoGroups.groups.map((group, i) => {
|
|
592
906
|
const groupKey = group.repoKey ?? '__no-repo__';
|
|
593
907
|
const collapsed = collapsedRepos.has(groupKey);
|
|
@@ -621,6 +935,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
621
935
|
</div>
|
|
622
936
|
);
|
|
623
937
|
})}
|
|
938
|
+
</>
|
|
939
|
+
) : null}
|
|
624
940
|
</div>
|
|
625
941
|
)}
|
|
626
942
|
</div>
|
|
@@ -648,6 +964,17 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
648
964
|
const verifiable = MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] ?? true;
|
|
649
965
|
const screenX = lbl.midX * viewport.zoom + viewport.x;
|
|
650
966
|
const screenY = lbl.midY * viewport.zoom + viewport.y;
|
|
967
|
+
const text = lbl.stepNos?.length
|
|
968
|
+
? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
|
|
969
|
+
: lbl.mechanism;
|
|
970
|
+
// Labels stay readable at full size until they'd exceed a share of
|
|
971
|
+
// the edge's screen length, then shrink with zoom.
|
|
972
|
+
const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
|
|
973
|
+
const screenEdgeLen = lbl.pathLength * viewport.zoom;
|
|
974
|
+
const scale =
|
|
975
|
+
lbl.pathLength > 0 && estWidth > 0
|
|
976
|
+
? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
|
|
977
|
+
: 1;
|
|
651
978
|
return (
|
|
652
979
|
<div
|
|
653
980
|
key={lbl.id}
|
|
@@ -661,21 +988,29 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
661
988
|
position: 'absolute',
|
|
662
989
|
left: screenX,
|
|
663
990
|
top: screenY,
|
|
991
|
+
// Center on the flow-space midpoint. Labels live in screen
|
|
992
|
+
// space (fixed size when zoomed out), so top-left anchoring
|
|
993
|
+
// would drift them right/down of the edge as zoom drops.
|
|
994
|
+
transform: `translate(-50%, -50%) scale(${scale})`,
|
|
995
|
+
transformOrigin: 'center center',
|
|
996
|
+
display: 'flex',
|
|
997
|
+
alignItems: 'center',
|
|
664
998
|
fontSize: 10,
|
|
999
|
+
lineHeight: 1,
|
|
665
1000
|
fontFamily: theme.fonts.monospace,
|
|
666
1001
|
fontWeight: 500,
|
|
667
1002
|
color,
|
|
668
1003
|
background: 'rgba(21,21,21,0.9)',
|
|
669
1004
|
border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
|
|
670
1005
|
borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
|
|
671
|
-
padding: '
|
|
1006
|
+
padding: '3px 8px',
|
|
672
1007
|
cursor: 'pointer',
|
|
673
1008
|
pointerEvents: 'auto',
|
|
674
1009
|
opacity: lbl.dimmed ? 0.15 : 1,
|
|
675
1010
|
whiteSpace: 'nowrap',
|
|
676
1011
|
}}
|
|
677
1012
|
>
|
|
678
|
-
{
|
|
1013
|
+
{text}
|
|
679
1014
|
</div>
|
|
680
1015
|
);
|
|
681
1016
|
})}
|
|
@@ -872,6 +1207,221 @@ function RepoGroupHeader({
|
|
|
872
1207
|
);
|
|
873
1208
|
}
|
|
874
1209
|
|
|
1210
|
+
/** One collapsible throughline in the sidebar's flows panel. Clicking the
|
|
1211
|
+
* title: closed → open + select; open and unselected → select; open and
|
|
1212
|
+
* selected → close + clear focus. The right-aligned close button collapses
|
|
1213
|
+
* without selecting. A step row focuses that step's edge. */
|
|
1214
|
+
function ThroughlineFlow({
|
|
1215
|
+
throughline,
|
|
1216
|
+
edges,
|
|
1217
|
+
collapsed,
|
|
1218
|
+
active,
|
|
1219
|
+
onToggleCollapsed,
|
|
1220
|
+
onFocusFlow,
|
|
1221
|
+
onClearFocus,
|
|
1222
|
+
onFocusStep,
|
|
1223
|
+
}: {
|
|
1224
|
+
throughline: SubsystemThroughline;
|
|
1225
|
+
edges: SubsystemComponentEdge[];
|
|
1226
|
+
collapsed: boolean;
|
|
1227
|
+
/** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
|
|
1228
|
+
active: { stepIndex: number | null } | null;
|
|
1229
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
1230
|
+
onFocusFlow: (tl: SubsystemThroughline) => void;
|
|
1231
|
+
onClearFocus: () => void;
|
|
1232
|
+
onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1233
|
+
}) {
|
|
1234
|
+
const { theme } = useTheme();
|
|
1235
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
1236
|
+
const hoverBg = theme.colors.background;
|
|
1237
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
1238
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
1239
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
1240
|
+
const [closeHover, setCloseHover] = useState(false);
|
|
1241
|
+
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
1242
|
+
|
|
1243
|
+
return (
|
|
1244
|
+
<div style={{ margin: '4px 0', borderRadius: 8 }}>
|
|
1245
|
+
<div
|
|
1246
|
+
onMouseEnter={() => setHeaderHover(true)}
|
|
1247
|
+
onMouseLeave={() => setHeaderHover(false)}
|
|
1248
|
+
style={{
|
|
1249
|
+
display: 'flex',
|
|
1250
|
+
alignItems: 'center',
|
|
1251
|
+
gap: 4,
|
|
1252
|
+
borderRadius: 6,
|
|
1253
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
1254
|
+
transition: 'background 120ms ease',
|
|
1255
|
+
}}
|
|
1256
|
+
>
|
|
1257
|
+
<button
|
|
1258
|
+
type="button"
|
|
1259
|
+
onClick={() => {
|
|
1260
|
+
if (collapsed) {
|
|
1261
|
+
onToggleCollapsed(throughline.id);
|
|
1262
|
+
onFocusFlow(throughline);
|
|
1263
|
+
} else if (active === null) {
|
|
1264
|
+
onFocusFlow(throughline);
|
|
1265
|
+
} else {
|
|
1266
|
+
onToggleCollapsed(throughline.id);
|
|
1267
|
+
onClearFocus();
|
|
1268
|
+
}
|
|
1269
|
+
}}
|
|
1270
|
+
style={{
|
|
1271
|
+
flex: 1,
|
|
1272
|
+
display: 'flex',
|
|
1273
|
+
alignItems: 'center',
|
|
1274
|
+
minWidth: 0,
|
|
1275
|
+
padding: '6px 8px',
|
|
1276
|
+
borderRadius: 6,
|
|
1277
|
+
border: 'none',
|
|
1278
|
+
background: 'transparent',
|
|
1279
|
+
textAlign: 'left',
|
|
1280
|
+
cursor: 'pointer',
|
|
1281
|
+
}}
|
|
1282
|
+
>
|
|
1283
|
+
<span
|
|
1284
|
+
style={{
|
|
1285
|
+
overflow: 'hidden',
|
|
1286
|
+
textOverflow: 'ellipsis',
|
|
1287
|
+
whiteSpace: 'nowrap',
|
|
1288
|
+
fontSize: theme.fontSizes[1],
|
|
1289
|
+
fontFamily: theme.fonts.monospace,
|
|
1290
|
+
fontWeight: 600,
|
|
1291
|
+
color: theme.colors.text,
|
|
1292
|
+
}}
|
|
1293
|
+
>
|
|
1294
|
+
{throughline.title}
|
|
1295
|
+
</span>
|
|
1296
|
+
</button>
|
|
1297
|
+
{!collapsed && (
|
|
1298
|
+
<button
|
|
1299
|
+
type="button"
|
|
1300
|
+
aria-label={`Close ${throughline.title}`}
|
|
1301
|
+
onMouseEnter={() => setCloseHover(true)}
|
|
1302
|
+
onMouseLeave={() => setCloseHover(false)}
|
|
1303
|
+
onClick={(e) => {
|
|
1304
|
+
e.stopPropagation();
|
|
1305
|
+
onToggleCollapsed(throughline.id);
|
|
1306
|
+
if (active !== null) onClearFocus();
|
|
1307
|
+
}}
|
|
1308
|
+
style={{
|
|
1309
|
+
display: 'inline-flex',
|
|
1310
|
+
alignItems: 'center',
|
|
1311
|
+
justifyContent: 'center',
|
|
1312
|
+
flexShrink: 0,
|
|
1313
|
+
width: 22,
|
|
1314
|
+
height: 22,
|
|
1315
|
+
marginRight: 4,
|
|
1316
|
+
padding: 0,
|
|
1317
|
+
border: 'none',
|
|
1318
|
+
borderRadius: 4,
|
|
1319
|
+
background: closeHover ? theme.colors.border : 'transparent',
|
|
1320
|
+
color: closeHover ? theme.colors.text : muted,
|
|
1321
|
+
cursor: 'pointer',
|
|
1322
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
1323
|
+
}}
|
|
1324
|
+
>
|
|
1325
|
+
<X size={12} strokeWidth={2} />
|
|
1326
|
+
</button>
|
|
1327
|
+
)}
|
|
1328
|
+
</div>
|
|
1329
|
+
{!collapsed && (
|
|
1330
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
1331
|
+
{throughline.steps.map((step, i) => {
|
|
1332
|
+
const edge = edgeById.get(step.edgeId);
|
|
1333
|
+
const mech = edge?.mechanism;
|
|
1334
|
+
const color = mech ? MECHANISM_COLOR[mech] : muted;
|
|
1335
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
1336
|
+
return (
|
|
1337
|
+
<button
|
|
1338
|
+
key={`${step.edgeId}-${i}`}
|
|
1339
|
+
type="button"
|
|
1340
|
+
onMouseEnter={() => setHoveredStep(i)}
|
|
1341
|
+
onMouseLeave={() => setHoveredStep(null)}
|
|
1342
|
+
onClick={() => onFocusStep(throughline, i)}
|
|
1343
|
+
style={{
|
|
1344
|
+
display: 'flex',
|
|
1345
|
+
alignItems: 'center',
|
|
1346
|
+
gap: 8,
|
|
1347
|
+
minWidth: 0,
|
|
1348
|
+
padding: '4px 8px 4px 12px',
|
|
1349
|
+
textAlign: 'left',
|
|
1350
|
+
borderRadius: 6,
|
|
1351
|
+
border: 'none',
|
|
1352
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
1353
|
+
cursor: 'pointer',
|
|
1354
|
+
transition: 'background 120ms ease',
|
|
1355
|
+
}}
|
|
1356
|
+
>
|
|
1357
|
+
<span
|
|
1358
|
+
style={{
|
|
1359
|
+
flexShrink: 0,
|
|
1360
|
+
width: 14,
|
|
1361
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
1362
|
+
fontFamily: theme.fonts.monospace,
|
|
1363
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1364
|
+
}}
|
|
1365
|
+
>
|
|
1366
|
+
{i + 1}
|
|
1367
|
+
</span>
|
|
1368
|
+
{step.symbol ? (
|
|
1369
|
+
<span
|
|
1370
|
+
style={{
|
|
1371
|
+
flex: 1,
|
|
1372
|
+
minWidth: 0,
|
|
1373
|
+
overflow: 'hidden',
|
|
1374
|
+
textOverflow: 'ellipsis',
|
|
1375
|
+
whiteSpace: 'nowrap',
|
|
1376
|
+
fontSize: theme.fontSizes[0],
|
|
1377
|
+
fontFamily: theme.fonts.monospace,
|
|
1378
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1379
|
+
}}
|
|
1380
|
+
>
|
|
1381
|
+
{step.symbol}
|
|
1382
|
+
</span>
|
|
1383
|
+
) : (
|
|
1384
|
+
<>
|
|
1385
|
+
<span
|
|
1386
|
+
style={{
|
|
1387
|
+
flexShrink: 0,
|
|
1388
|
+
width: 74,
|
|
1389
|
+
overflow: 'hidden',
|
|
1390
|
+
textOverflow: 'ellipsis',
|
|
1391
|
+
whiteSpace: 'nowrap',
|
|
1392
|
+
fontSize: theme.fontSizes[0],
|
|
1393
|
+
fontFamily: theme.fonts.monospace,
|
|
1394
|
+
color,
|
|
1395
|
+
}}
|
|
1396
|
+
>
|
|
1397
|
+
{mech ?? step.edgeId}
|
|
1398
|
+
</span>
|
|
1399
|
+
<span
|
|
1400
|
+
style={{
|
|
1401
|
+
flex: 1,
|
|
1402
|
+
minWidth: 0,
|
|
1403
|
+
overflow: 'hidden',
|
|
1404
|
+
textOverflow: 'ellipsis',
|
|
1405
|
+
whiteSpace: 'nowrap',
|
|
1406
|
+
fontSize: theme.fontSizes[0],
|
|
1407
|
+
fontFamily: theme.fonts.monospace,
|
|
1408
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1409
|
+
}}
|
|
1410
|
+
>
|
|
1411
|
+
{step.file.split('/').pop()}
|
|
1412
|
+
<span style={{ opacity: 0.7 }}>:{step.line}</span>
|
|
1413
|
+
</span>
|
|
1414
|
+
</>
|
|
1415
|
+
)}
|
|
1416
|
+
</button>
|
|
1417
|
+
);
|
|
1418
|
+
})}
|
|
1419
|
+
</div>
|
|
1420
|
+
)}
|
|
1421
|
+
</div>
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
875
1425
|
export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
876
1426
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
877
1427
|
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
|