@principal-ai/principal-view-react 0.16.57 → 0.16.58
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 +10 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +379 -39
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +34 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +28 -0
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +52 -3
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +4 -3
- 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/subsystem/SubsystemComponentGraph.tsx +533 -39
- package/src/subsystem/model.ts +37 -1
- package/src/subsystem/nodes.test.ts +56 -0
- package/src/subsystem/nodes.tsx +58 -4
- package/src/utils/elkLayout.ts +4 -3
|
@@ -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, 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';
|
|
@@ -55,6 +56,15 @@ import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
|
55
56
|
export interface SubsystemComponentGraphProps {
|
|
56
57
|
components: SubsystemComponent[];
|
|
57
58
|
edges: SubsystemComponentEdge[];
|
|
59
|
+
/**
|
|
60
|
+
* Ordered execution stories over the graph's edges — one throughline per
|
|
61
|
+
* flow. When present the sidebar's bottom half offers a Files/Flows toggle:
|
|
62
|
+
* the flows panel lists each throughline's steps (`symbol` or `file:line`);
|
|
63
|
+
* clicking a flow row toggles its steps; clicking a step focuses that
|
|
64
|
+
* step's edge. Opened flows stay on the canvas (unselected ones dimmed);
|
|
65
|
+
* everything else is hidden.
|
|
66
|
+
*/
|
|
67
|
+
throughlines?: SubsystemThroughline[];
|
|
58
68
|
onSelect?: (componentId: string) => void;
|
|
59
69
|
/** Called when an edge is clicked (relationship / mechanism + refs seam). */
|
|
60
70
|
onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
|
|
@@ -126,7 +136,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
126
136
|
measured: { w: number; h: number } | null;
|
|
127
137
|
}
|
|
128
138
|
|
|
129
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
139
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
130
140
|
const { theme } = useTheme();
|
|
131
141
|
const { fitView } = useReactFlow();
|
|
132
142
|
const viewport = useViewport();
|
|
@@ -147,6 +157,18 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
147
157
|
const [legendOpen, setLegendOpen] = useState(false);
|
|
148
158
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
149
159
|
const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
|
|
160
|
+
// Throughline focus — selected flow (or step) is full strength; other
|
|
161
|
+
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
162
|
+
const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
|
|
163
|
+
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
164
|
+
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
|
|
165
|
+
// Sidebar bottom half: which panel is shown when throughlines exist.
|
|
166
|
+
const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
|
|
167
|
+
throughlines?.length ? 'flows' : 'files',
|
|
168
|
+
);
|
|
169
|
+
// Throughline flows the user has expanded (via the title row). Closed by
|
|
170
|
+
// default so a graph with several flows doesn't dump every step list at once.
|
|
171
|
+
const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
|
|
150
172
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
151
173
|
// closure over the effect deps) can toggle without a stale value.
|
|
152
174
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
@@ -245,6 +267,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
245
267
|
}
|
|
246
268
|
const src = edgeById.get(edgeId);
|
|
247
269
|
setSelectedEdgeId(edgeId);
|
|
270
|
+
// A direct edge selection on the canvas supersedes any throughline focus.
|
|
271
|
+
setFocusedThroughlineId(null);
|
|
272
|
+
setFocusedStepIndex(null);
|
|
248
273
|
if (src) onEdgeSelect?.(src);
|
|
249
274
|
},
|
|
250
275
|
[edgeById, onEdgeSelect],
|
|
@@ -263,6 +288,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
263
288
|
}
|
|
264
289
|
setSelected(comp);
|
|
265
290
|
setSelectedEdgeId(null);
|
|
291
|
+
setFocusedThroughlineId(null);
|
|
292
|
+
setFocusedStepIndex(null);
|
|
266
293
|
onSelect?.(id);
|
|
267
294
|
}
|
|
268
295
|
};
|
|
@@ -281,26 +308,120 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
281
308
|
const xyflowNodesBase = nodes as Node[];
|
|
282
309
|
const baseEdges = convertedEdges as Edge[];
|
|
283
310
|
|
|
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
|
+
// Edge ids in throughline focus (an active flow's edge set, or a single
|
|
318
|
+
// step's edge). Used to frame the camera. `null` = no throughline focus.
|
|
319
|
+
const focusEdgeIds = useMemo(() => {
|
|
320
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
321
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
322
|
+
if (!tl) return null;
|
|
323
|
+
if (focusedStepIndex != null) {
|
|
324
|
+
const step = tl.steps[focusedStepIndex];
|
|
325
|
+
return step ? new Set([step.edgeId]) : null;
|
|
326
|
+
}
|
|
327
|
+
return new Set(tl.steps.map((s) => s.edgeId));
|
|
328
|
+
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
329
|
+
|
|
330
|
+
// 1-based step numbers per edge of the selected flow (an edge can appear
|
|
331
|
+
// in more than one step).
|
|
332
|
+
const selectedFlowStepNos = useMemo(() => {
|
|
333
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
334
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
335
|
+
if (!tl) return null;
|
|
336
|
+
const map = new Map<string, number[]>();
|
|
337
|
+
tl.steps.forEach((s, i) => {
|
|
338
|
+
const list = map.get(s.edgeId) ?? [];
|
|
339
|
+
list.push(i + 1);
|
|
340
|
+
map.set(s.edgeId, list);
|
|
341
|
+
});
|
|
342
|
+
return map;
|
|
343
|
+
}, [throughlines, focusedThroughlineId]);
|
|
344
|
+
|
|
345
|
+
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
346
|
+
const openedEdgeIds = useMemo(() => {
|
|
347
|
+
if (!throughlines || expandedThroughlines.size === 0) return null;
|
|
348
|
+
const ids = new Set<string>();
|
|
349
|
+
for (const tl of throughlines) {
|
|
350
|
+
if (!expandedThroughlines.has(tl.id)) continue;
|
|
351
|
+
for (const s of tl.steps) ids.add(s.edgeId);
|
|
352
|
+
}
|
|
353
|
+
return ids.size > 0 ? ids : null;
|
|
354
|
+
}, [throughlines, expandedThroughlines]);
|
|
355
|
+
|
|
356
|
+
const endpointsOf = (edgeIds: ReadonlySet<string> | null): Set<string> | null => {
|
|
357
|
+
if (!edgeIds) return null;
|
|
358
|
+
const ids = new Set<string>();
|
|
359
|
+
for (const e of baseEdges) {
|
|
360
|
+
if (!edgeIds.has(e.id)) continue;
|
|
361
|
+
ids.add(e.source);
|
|
362
|
+
ids.add(e.target);
|
|
363
|
+
}
|
|
364
|
+
return ids.size > 0 ? ids : null;
|
|
365
|
+
};
|
|
366
|
+
const openedNodeIds = useMemo(
|
|
367
|
+
() => endpointsOf(openedEdgeIds),
|
|
368
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
369
|
+
[baseEdges, openedEdgeIds],
|
|
370
|
+
);
|
|
371
|
+
// Endpoints of the bright set: the selected step's edge, or the whole flow
|
|
372
|
+
// when no step is focused.
|
|
373
|
+
const brightNodeIds = useMemo(
|
|
374
|
+
() => endpointsOf(focusEdgeIds),
|
|
375
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
376
|
+
[baseEdges, focusEdgeIds],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Source + target of the focused step/flow (or a canvas-selected edge). If a
|
|
380
|
+
// file is open, those endpoints stay undimmed even when they don't live in
|
|
381
|
+
// that file.
|
|
382
|
+
const focusNodeIds = useMemo(() => {
|
|
383
|
+
if (brightNodeIds) return brightNodeIds;
|
|
384
|
+
if (selectedEdgeId) {
|
|
385
|
+
const e = baseEdges.find((x) => x.id === selectedEdgeId);
|
|
386
|
+
if (e) return new Set([e.source, e.target]);
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}, [baseEdges, brightNodeIds, selectedEdgeId]);
|
|
390
|
+
|
|
284
391
|
// While a file is open in the drawer, tag each node with whether its
|
|
285
392
|
// component lives in that file — the node renderer spotlights matches and
|
|
286
393
|
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
394
|
+
// Opened-but-unselected members are dimmed; a selected step further dims
|
|
395
|
+
// the rest of its own flow. Nodes not on any opened flow are hidden.
|
|
287
396
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
288
|
-
// React Flow's own selection state from
|
|
397
|
+
// React Flow's own selection state from updating.
|
|
289
398
|
const dispNodes = useMemo(() => {
|
|
290
399
|
return xyflowNodesBase.map((n) => {
|
|
291
400
|
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
292
|
-
const fileMatch =
|
|
401
|
+
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
293
402
|
const isSelected = selected?.id !== undefined && comp?.id === selected.id;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
403
|
+
const vis = flowElementVisibility({
|
|
404
|
+
inOpened: openedNodeIds?.has(n.id) === true,
|
|
405
|
+
inSelected: brightNodeIds?.has(n.id) === true,
|
|
406
|
+
anyOpened: openedNodeIds != null,
|
|
407
|
+
anySelected: brightNodeIds != null,
|
|
408
|
+
});
|
|
409
|
+
if (fileMatch === undefined && !isSelected && !vis.dimmed) {
|
|
410
|
+
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
411
|
+
return { ...n, hidden: vis.hidden, data: rest };
|
|
297
412
|
}
|
|
298
413
|
return {
|
|
299
414
|
...n,
|
|
300
|
-
|
|
415
|
+
hidden: vis.hidden,
|
|
416
|
+
data: {
|
|
417
|
+
...(n.data as object),
|
|
418
|
+
...(fileMatch !== undefined && { fileMatch }),
|
|
419
|
+
...(isSelected && { isSelected }),
|
|
420
|
+
...(vis.dimmed && { dimmed: true }),
|
|
421
|
+
},
|
|
301
422
|
};
|
|
302
423
|
});
|
|
303
|
-
}, [xyflowNodesBase, openFile, selected]);
|
|
424
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
304
425
|
|
|
305
426
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
306
427
|
const baseEdgesKey = useMemo(
|
|
@@ -308,22 +429,33 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
308
429
|
[convertedEdges],
|
|
309
430
|
);
|
|
310
431
|
|
|
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
432
|
const dispEdges = useMemo(() => {
|
|
433
|
+
const paint = (e: Edge, dimmed: boolean): Edge => {
|
|
434
|
+
const markerEnd = e.markerEnd;
|
|
435
|
+
const nextMarker =
|
|
436
|
+
dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
437
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
438
|
+
: markerEnd;
|
|
439
|
+
return {
|
|
440
|
+
...e,
|
|
441
|
+
data: { ...(e.data as object), dimmed },
|
|
442
|
+
markerEnd: nextMarker,
|
|
443
|
+
};
|
|
444
|
+
};
|
|
445
|
+
if (openedEdgeIds || focusEdgeIds) {
|
|
446
|
+
return baseEdges.map((e) => {
|
|
447
|
+
const vis = flowElementVisibility({
|
|
448
|
+
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
449
|
+
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
450
|
+
anyOpened: openedEdgeIds != null,
|
|
451
|
+
anySelected: focusEdgeIds != null,
|
|
452
|
+
});
|
|
453
|
+
return { ...paint(e, vis.dimmed), hidden: vis.hidden };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
318
456
|
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]);
|
|
457
|
+
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
458
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
327
459
|
|
|
328
460
|
const onNodesChange = useCallback(
|
|
329
461
|
(changes: NodeChange[]) => {
|
|
@@ -363,6 +495,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
363
495
|
(_e, node: Node) => {
|
|
364
496
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
365
497
|
setSelectedEdgeId(null);
|
|
498
|
+
setFocusedThroughlineId(null);
|
|
499
|
+
setFocusedStepIndex(null);
|
|
366
500
|
if (node.type === 'subsystem-component' && comp) {
|
|
367
501
|
// Clicking the already-selected node unselects it (toggle off).
|
|
368
502
|
// Selection is independent of the file drawer — nodes never open it.
|
|
@@ -388,11 +522,14 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
388
522
|
const onPaneClick = useCallback(() => {
|
|
389
523
|
setSelected(null);
|
|
390
524
|
setSelectedEdgeId(null);
|
|
525
|
+
setFocusedThroughlineId(null);
|
|
526
|
+
setFocusedStepIndex(null);
|
|
391
527
|
}, []);
|
|
392
528
|
|
|
393
529
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
394
530
|
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
395
531
|
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
532
|
+
const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
|
|
396
533
|
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
|
|
397
534
|
const toggleRepoCollapsed = useCallback((key: string) => {
|
|
398
535
|
setCollapsedRepos((prev) => {
|
|
@@ -434,6 +571,71 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
434
571
|
[onFileSelect],
|
|
435
572
|
);
|
|
436
573
|
|
|
574
|
+
// Camera helper shared by the throughline interactions: frames the focused
|
|
575
|
+
// edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
|
|
576
|
+
// live positions + measured dims — so the frame always includes BOTH
|
|
577
|
+
// components the edge attaches to (and therefore the edge line between them).
|
|
578
|
+
const fitFocusBounds = useCallback(
|
|
579
|
+
(ids: ReadonlySet<string>) => {
|
|
580
|
+
const nodeIds = new Set<string>();
|
|
581
|
+
for (const e of baseEdges) {
|
|
582
|
+
if (!ids.has(e.id)) continue;
|
|
583
|
+
nodeIds.add(e.source);
|
|
584
|
+
nodeIds.add(e.target);
|
|
585
|
+
}
|
|
586
|
+
if (nodeIds.size === 0) return;
|
|
587
|
+
fitView({
|
|
588
|
+
nodes: [...nodeIds].map((id) => ({ id })),
|
|
589
|
+
padding: 0.25,
|
|
590
|
+
duration: 300,
|
|
591
|
+
});
|
|
592
|
+
},
|
|
593
|
+
[baseEdges, fitView],
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
597
|
+
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
598
|
+
// reads as the narrative.
|
|
599
|
+
const focusThroughlineEdges = useCallback(
|
|
600
|
+
(tl: SubsystemThroughline) => {
|
|
601
|
+
setSelected(null);
|
|
602
|
+
setSelectedEdgeId(null);
|
|
603
|
+
setFocusedStepIndex(null);
|
|
604
|
+
setFocusedThroughlineId(tl.id);
|
|
605
|
+
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
606
|
+
},
|
|
607
|
+
[fitFocusBounds],
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
const clearThroughlineFocus = useCallback(() => {
|
|
611
|
+
setFocusedThroughlineId(null);
|
|
612
|
+
setFocusedStepIndex(null);
|
|
613
|
+
}, []);
|
|
614
|
+
|
|
615
|
+
// Focus a single step's edge on the canvas. The step's file:line is listed
|
|
616
|
+
// in the row; we don't open the drawer from here.
|
|
617
|
+
const focusThroughlineStep = useCallback(
|
|
618
|
+
(tl: SubsystemThroughline, stepIndex: number) => {
|
|
619
|
+
const step = tl.steps[stepIndex];
|
|
620
|
+
if (!step) return;
|
|
621
|
+
setSelected(null);
|
|
622
|
+
setSelectedEdgeId(null);
|
|
623
|
+
setFocusedStepIndex(stepIndex);
|
|
624
|
+
setFocusedThroughlineId(tl.id);
|
|
625
|
+
fitFocusBounds(new Set([step.edgeId]));
|
|
626
|
+
},
|
|
627
|
+
[fitFocusBounds],
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const toggleThroughlineCollapsed = useCallback((tlId: string) => {
|
|
631
|
+
setExpandedThroughlines((prev) => {
|
|
632
|
+
const next = new Set(prev);
|
|
633
|
+
if (next.has(tlId)) next.delete(tlId);
|
|
634
|
+
else next.add(tlId);
|
|
635
|
+
return next;
|
|
636
|
+
});
|
|
637
|
+
}, []);
|
|
638
|
+
|
|
437
639
|
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
438
640
|
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
439
641
|
useEffect(() => {
|
|
@@ -461,6 +663,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
461
663
|
if (!comp || comp.id === selectedRef.current?.id) return;
|
|
462
664
|
setSelected(comp);
|
|
463
665
|
setSelectedEdgeId(null);
|
|
666
|
+
setFocusedThroughlineId(null);
|
|
667
|
+
setFocusedStepIndex(null);
|
|
464
668
|
onSelect?.(comp.id);
|
|
465
669
|
},
|
|
466
670
|
[components, onSelect],
|
|
@@ -470,17 +674,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
470
674
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
471
675
|
// the actual edge path (not node-center approximations).
|
|
472
676
|
const edgeLabels = useMemo(() => {
|
|
473
|
-
return dispEdges
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
677
|
+
return dispEdges
|
|
678
|
+
.filter((e) => !e.hidden)
|
|
679
|
+
.map((e) => {
|
|
680
|
+
const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
|
|
681
|
+
return {
|
|
682
|
+
id: e.id,
|
|
683
|
+
mechanism: d?.mechanism ?? 'imports',
|
|
684
|
+
dimmed: d?.dimmed === true,
|
|
685
|
+
midX: d?.labelX ?? 0,
|
|
686
|
+
midY: d?.labelY ?? 0,
|
|
687
|
+
stepNos: selectedFlowStepNos?.get(e.id),
|
|
688
|
+
};
|
|
689
|
+
});
|
|
690
|
+
}, [dispEdges, selectedFlowStepNos]);
|
|
484
691
|
|
|
485
692
|
const usedMechanisms = useMemo(() => {
|
|
486
693
|
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
@@ -521,8 +728,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
521
728
|
|
|
522
729
|
return (
|
|
523
730
|
<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) && (
|
|
731
|
+
{/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
|
|
732
|
+
{(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
526
733
|
<div
|
|
527
734
|
style={{
|
|
528
735
|
width: 340,
|
|
@@ -576,7 +783,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
576
783
|
)}
|
|
577
784
|
{sidebarAfterDescription}
|
|
578
785
|
</div>
|
|
579
|
-
{treeFilePaths.length > 0 && (
|
|
786
|
+
{(treeFilePaths.length > 0 || hasThroughlines) && (
|
|
580
787
|
<div
|
|
581
788
|
style={{
|
|
582
789
|
height: '50%',
|
|
@@ -588,6 +795,72 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
588
795
|
overflow: 'hidden',
|
|
589
796
|
}}
|
|
590
797
|
>
|
|
798
|
+
{hasThroughlines && (
|
|
799
|
+
<div
|
|
800
|
+
role="tablist"
|
|
801
|
+
aria-label="Sidebar view"
|
|
802
|
+
style={{
|
|
803
|
+
display: 'flex',
|
|
804
|
+
width: '100%',
|
|
805
|
+
flexShrink: 0,
|
|
806
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
807
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
808
|
+
}}
|
|
809
|
+
>
|
|
810
|
+
{(['files', 'flows'] as const).map((view) => (
|
|
811
|
+
<button
|
|
812
|
+
key={view}
|
|
813
|
+
type="button"
|
|
814
|
+
role="tab"
|
|
815
|
+
aria-selected={sidebarView === view}
|
|
816
|
+
onClick={() => setSidebarView(view)}
|
|
817
|
+
style={{
|
|
818
|
+
flex: 1,
|
|
819
|
+
minWidth: 0,
|
|
820
|
+
padding: '8px 8px',
|
|
821
|
+
border: 'none',
|
|
822
|
+
borderRadius: 0,
|
|
823
|
+
background: sidebarView === view ? theme.colors.background : 'transparent',
|
|
824
|
+
color:
|
|
825
|
+
sidebarView === view
|
|
826
|
+
? theme.colors.text
|
|
827
|
+
: theme.colors.textSecondary,
|
|
828
|
+
fontSize: theme.fontSizes[1],
|
|
829
|
+
fontFamily: theme.fonts.monospace,
|
|
830
|
+
textTransform: 'capitalize',
|
|
831
|
+
cursor: 'pointer',
|
|
832
|
+
}}
|
|
833
|
+
>
|
|
834
|
+
{view}
|
|
835
|
+
</button>
|
|
836
|
+
))}
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
{sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (
|
|
840
|
+
<div
|
|
841
|
+
style={{
|
|
842
|
+
flex: 1,
|
|
843
|
+
minHeight: 0,
|
|
844
|
+
overflowY: 'auto',
|
|
845
|
+
padding: '0 4px 12px',
|
|
846
|
+
}}
|
|
847
|
+
>
|
|
848
|
+
{throughlines.map((tl) => (
|
|
849
|
+
<ThroughlineFlow
|
|
850
|
+
key={tl.id}
|
|
851
|
+
throughline={tl}
|
|
852
|
+
edges={edges}
|
|
853
|
+
collapsed={!expandedThroughlines.has(tl.id)}
|
|
854
|
+
active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
|
|
855
|
+
onToggleCollapsed={toggleThroughlineCollapsed}
|
|
856
|
+
onFocusFlow={focusThroughlineEdges}
|
|
857
|
+
onClearFocus={clearThroughlineFocus}
|
|
858
|
+
onFocusStep={focusThroughlineStep}
|
|
859
|
+
/>
|
|
860
|
+
))}
|
|
861
|
+
</div>
|
|
862
|
+
) : treeFilePaths.length > 0 ? (
|
|
863
|
+
<>
|
|
591
864
|
{repoGroups.groups.map((group, i) => {
|
|
592
865
|
const groupKey = group.repoKey ?? '__no-repo__';
|
|
593
866
|
const collapsed = collapsedRepos.has(groupKey);
|
|
@@ -621,6 +894,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
621
894
|
</div>
|
|
622
895
|
);
|
|
623
896
|
})}
|
|
897
|
+
</>
|
|
898
|
+
) : null}
|
|
624
899
|
</div>
|
|
625
900
|
)}
|
|
626
901
|
</div>
|
|
@@ -661,6 +936,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
661
936
|
position: 'absolute',
|
|
662
937
|
left: screenX,
|
|
663
938
|
top: screenY,
|
|
939
|
+
// Center on the flow-space midpoint. Labels live in screen
|
|
940
|
+
// space (fixed size when zoomed out), so top-left anchoring
|
|
941
|
+
// would drift them right/down of the edge as zoom drops.
|
|
942
|
+
transform: 'translate(-50%, -50%)',
|
|
664
943
|
fontSize: 10,
|
|
665
944
|
fontFamily: theme.fonts.monospace,
|
|
666
945
|
fontWeight: 500,
|
|
@@ -675,7 +954,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
675
954
|
whiteSpace: 'nowrap',
|
|
676
955
|
}}
|
|
677
956
|
>
|
|
678
|
-
{lbl.mechanism}
|
|
957
|
+
{lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism}
|
|
679
958
|
</div>
|
|
680
959
|
);
|
|
681
960
|
})}
|
|
@@ -872,6 +1151,221 @@ function RepoGroupHeader({
|
|
|
872
1151
|
);
|
|
873
1152
|
}
|
|
874
1153
|
|
|
1154
|
+
/** One collapsible throughline in the sidebar's flows panel. Clicking the
|
|
1155
|
+
* title: closed → open + select; open and unselected → select; open and
|
|
1156
|
+
* selected → close + clear focus. The right-aligned close button collapses
|
|
1157
|
+
* without selecting. A step row focuses that step's edge. */
|
|
1158
|
+
function ThroughlineFlow({
|
|
1159
|
+
throughline,
|
|
1160
|
+
edges,
|
|
1161
|
+
collapsed,
|
|
1162
|
+
active,
|
|
1163
|
+
onToggleCollapsed,
|
|
1164
|
+
onFocusFlow,
|
|
1165
|
+
onClearFocus,
|
|
1166
|
+
onFocusStep,
|
|
1167
|
+
}: {
|
|
1168
|
+
throughline: SubsystemThroughline;
|
|
1169
|
+
edges: SubsystemComponentEdge[];
|
|
1170
|
+
collapsed: boolean;
|
|
1171
|
+
/** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
|
|
1172
|
+
active: { stepIndex: number | null } | null;
|
|
1173
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
1174
|
+
onFocusFlow: (tl: SubsystemThroughline) => void;
|
|
1175
|
+
onClearFocus: () => void;
|
|
1176
|
+
onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1177
|
+
}) {
|
|
1178
|
+
const { theme } = useTheme();
|
|
1179
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
1180
|
+
const hoverBg = theme.colors.background;
|
|
1181
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
1182
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
1183
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
1184
|
+
const [closeHover, setCloseHover] = useState(false);
|
|
1185
|
+
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
1186
|
+
|
|
1187
|
+
return (
|
|
1188
|
+
<div style={{ margin: '4px 0', borderRadius: 8 }}>
|
|
1189
|
+
<div
|
|
1190
|
+
onMouseEnter={() => setHeaderHover(true)}
|
|
1191
|
+
onMouseLeave={() => setHeaderHover(false)}
|
|
1192
|
+
style={{
|
|
1193
|
+
display: 'flex',
|
|
1194
|
+
alignItems: 'center',
|
|
1195
|
+
gap: 4,
|
|
1196
|
+
borderRadius: 6,
|
|
1197
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
1198
|
+
transition: 'background 120ms ease',
|
|
1199
|
+
}}
|
|
1200
|
+
>
|
|
1201
|
+
<button
|
|
1202
|
+
type="button"
|
|
1203
|
+
onClick={() => {
|
|
1204
|
+
if (collapsed) {
|
|
1205
|
+
onToggleCollapsed(throughline.id);
|
|
1206
|
+
onFocusFlow(throughline);
|
|
1207
|
+
} else if (active === null) {
|
|
1208
|
+
onFocusFlow(throughline);
|
|
1209
|
+
} else {
|
|
1210
|
+
onToggleCollapsed(throughline.id);
|
|
1211
|
+
onClearFocus();
|
|
1212
|
+
}
|
|
1213
|
+
}}
|
|
1214
|
+
style={{
|
|
1215
|
+
flex: 1,
|
|
1216
|
+
display: 'flex',
|
|
1217
|
+
alignItems: 'center',
|
|
1218
|
+
minWidth: 0,
|
|
1219
|
+
padding: '6px 8px',
|
|
1220
|
+
borderRadius: 6,
|
|
1221
|
+
border: 'none',
|
|
1222
|
+
background: 'transparent',
|
|
1223
|
+
textAlign: 'left',
|
|
1224
|
+
cursor: 'pointer',
|
|
1225
|
+
}}
|
|
1226
|
+
>
|
|
1227
|
+
<span
|
|
1228
|
+
style={{
|
|
1229
|
+
overflow: 'hidden',
|
|
1230
|
+
textOverflow: 'ellipsis',
|
|
1231
|
+
whiteSpace: 'nowrap',
|
|
1232
|
+
fontSize: theme.fontSizes[1],
|
|
1233
|
+
fontFamily: theme.fonts.monospace,
|
|
1234
|
+
fontWeight: 600,
|
|
1235
|
+
color: theme.colors.text,
|
|
1236
|
+
}}
|
|
1237
|
+
>
|
|
1238
|
+
{throughline.title}
|
|
1239
|
+
</span>
|
|
1240
|
+
</button>
|
|
1241
|
+
{!collapsed && (
|
|
1242
|
+
<button
|
|
1243
|
+
type="button"
|
|
1244
|
+
aria-label={`Close ${throughline.title}`}
|
|
1245
|
+
onMouseEnter={() => setCloseHover(true)}
|
|
1246
|
+
onMouseLeave={() => setCloseHover(false)}
|
|
1247
|
+
onClick={(e) => {
|
|
1248
|
+
e.stopPropagation();
|
|
1249
|
+
onToggleCollapsed(throughline.id);
|
|
1250
|
+
if (active !== null) onClearFocus();
|
|
1251
|
+
}}
|
|
1252
|
+
style={{
|
|
1253
|
+
display: 'inline-flex',
|
|
1254
|
+
alignItems: 'center',
|
|
1255
|
+
justifyContent: 'center',
|
|
1256
|
+
flexShrink: 0,
|
|
1257
|
+
width: 22,
|
|
1258
|
+
height: 22,
|
|
1259
|
+
marginRight: 4,
|
|
1260
|
+
padding: 0,
|
|
1261
|
+
border: 'none',
|
|
1262
|
+
borderRadius: 4,
|
|
1263
|
+
background: closeHover ? theme.colors.border : 'transparent',
|
|
1264
|
+
color: closeHover ? theme.colors.text : muted,
|
|
1265
|
+
cursor: 'pointer',
|
|
1266
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
1267
|
+
}}
|
|
1268
|
+
>
|
|
1269
|
+
<X size={12} strokeWidth={2} />
|
|
1270
|
+
</button>
|
|
1271
|
+
)}
|
|
1272
|
+
</div>
|
|
1273
|
+
{!collapsed && (
|
|
1274
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
1275
|
+
{throughline.steps.map((step, i) => {
|
|
1276
|
+
const edge = edgeById.get(step.edgeId);
|
|
1277
|
+
const mech = edge?.mechanism;
|
|
1278
|
+
const color = mech ? MECHANISM_COLOR[mech] : muted;
|
|
1279
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
1280
|
+
return (
|
|
1281
|
+
<button
|
|
1282
|
+
key={`${step.edgeId}-${i}`}
|
|
1283
|
+
type="button"
|
|
1284
|
+
onMouseEnter={() => setHoveredStep(i)}
|
|
1285
|
+
onMouseLeave={() => setHoveredStep(null)}
|
|
1286
|
+
onClick={() => onFocusStep(throughline, i)}
|
|
1287
|
+
style={{
|
|
1288
|
+
display: 'flex',
|
|
1289
|
+
alignItems: 'center',
|
|
1290
|
+
gap: 8,
|
|
1291
|
+
minWidth: 0,
|
|
1292
|
+
padding: '4px 8px 4px 12px',
|
|
1293
|
+
textAlign: 'left',
|
|
1294
|
+
borderRadius: 6,
|
|
1295
|
+
border: 'none',
|
|
1296
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
1297
|
+
cursor: 'pointer',
|
|
1298
|
+
transition: 'background 120ms ease',
|
|
1299
|
+
}}
|
|
1300
|
+
>
|
|
1301
|
+
<span
|
|
1302
|
+
style={{
|
|
1303
|
+
flexShrink: 0,
|
|
1304
|
+
width: 14,
|
|
1305
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
1306
|
+
fontFamily: theme.fonts.monospace,
|
|
1307
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1308
|
+
}}
|
|
1309
|
+
>
|
|
1310
|
+
{i + 1}
|
|
1311
|
+
</span>
|
|
1312
|
+
{step.symbol ? (
|
|
1313
|
+
<span
|
|
1314
|
+
style={{
|
|
1315
|
+
flex: 1,
|
|
1316
|
+
minWidth: 0,
|
|
1317
|
+
overflow: 'hidden',
|
|
1318
|
+
textOverflow: 'ellipsis',
|
|
1319
|
+
whiteSpace: 'nowrap',
|
|
1320
|
+
fontSize: theme.fontSizes[0],
|
|
1321
|
+
fontFamily: theme.fonts.monospace,
|
|
1322
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1323
|
+
}}
|
|
1324
|
+
>
|
|
1325
|
+
{step.symbol}
|
|
1326
|
+
</span>
|
|
1327
|
+
) : (
|
|
1328
|
+
<>
|
|
1329
|
+
<span
|
|
1330
|
+
style={{
|
|
1331
|
+
flexShrink: 0,
|
|
1332
|
+
width: 74,
|
|
1333
|
+
overflow: 'hidden',
|
|
1334
|
+
textOverflow: 'ellipsis',
|
|
1335
|
+
whiteSpace: 'nowrap',
|
|
1336
|
+
fontSize: theme.fontSizes[0],
|
|
1337
|
+
fontFamily: theme.fonts.monospace,
|
|
1338
|
+
color,
|
|
1339
|
+
}}
|
|
1340
|
+
>
|
|
1341
|
+
{mech ?? step.edgeId}
|
|
1342
|
+
</span>
|
|
1343
|
+
<span
|
|
1344
|
+
style={{
|
|
1345
|
+
flex: 1,
|
|
1346
|
+
minWidth: 0,
|
|
1347
|
+
overflow: 'hidden',
|
|
1348
|
+
textOverflow: 'ellipsis',
|
|
1349
|
+
whiteSpace: 'nowrap',
|
|
1350
|
+
fontSize: theme.fontSizes[0],
|
|
1351
|
+
fontFamily: theme.fonts.monospace,
|
|
1352
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1353
|
+
}}
|
|
1354
|
+
>
|
|
1355
|
+
{step.file.split('/').pop()}
|
|
1356
|
+
<span style={{ opacity: 0.7 }}>:{step.line}</span>
|
|
1357
|
+
</span>
|
|
1358
|
+
</>
|
|
1359
|
+
)}
|
|
1360
|
+
</button>
|
|
1361
|
+
);
|
|
1362
|
+
})}
|
|
1363
|
+
</div>
|
|
1364
|
+
)}
|
|
1365
|
+
</div>
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
875
1369
|
export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
876
1370
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
877
1371
|
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
|