@principal-ai/subsystems-react 0.35.7 → 0.36.1
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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
- package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +9 -5
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/graphChrome.d.ts +15 -0
- package/dist/subsystem/graphChrome.d.ts.map +1 -1
- package/dist/subsystem/graphChrome.js +17 -0
- package/dist/subsystem/graphChrome.js.map +1 -1
- package/dist/subsystem/model.d.ts +26 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +90 -2
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +12 -9
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +45 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +114 -84
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/signature.test.ts +1 -1
- package/src/index.ts +1 -0
- package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +8 -8
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +19 -11
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
- package/src/subsystem/SubsystemAggregateGraph.tsx +229 -21
- package/src/subsystem/SubsystemComponentGraph.tsx +34 -16
- package/src/subsystem/graphChrome.tsx +20 -0
- package/src/subsystem/model.test.ts +105 -0
- package/src/subsystem/model.ts +119 -4
- package/src/subsystem/nodes.tsx +49 -64
- package/src/utils/elkLayout.test.ts +117 -3
- package/src/utils/elkLayout.ts +154 -82
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
- /package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.ts +0 -0
|
@@ -27,8 +27,8 @@ import {
|
|
|
27
27
|
} from '@xyflow/react';
|
|
28
28
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
29
29
|
import { computeElkLayout } from '../utils/elkLayout';
|
|
30
|
-
import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
|
|
31
|
-
import { GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
|
|
30
|
+
import { describeConstructBreakdown, MECHANISM_COLOR, assignBoundaryColors, boundaryFill } from './model';
|
|
31
|
+
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
32
32
|
|
|
33
33
|
export interface AggregateFrameMember {
|
|
34
34
|
alias: string;
|
|
@@ -91,13 +91,16 @@ function edgeColor(mechanism: string | undefined, fallback: string): string {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
function AggregateFrameGroupView(
|
|
94
|
-
props: NodeProps<Node<{ label: string; kind: 'process' | 'external' }>>,
|
|
94
|
+
props: NodeProps<Node<{ label: string; color: string; kind: 'process' | 'external' }>>,
|
|
95
95
|
) {
|
|
96
96
|
const { theme } = useTheme();
|
|
97
97
|
const external = props.data.kind === 'external';
|
|
98
|
-
|
|
98
|
+
// Processes take the hue assigned to their key, so boundaries read as regions
|
|
99
|
+
// at a glance; externals keep the warning accent, meaning "outside the repo".
|
|
100
|
+
// Dashed stays reserved for containment — solid chrome means a real entity.
|
|
101
|
+
const color = external
|
|
99
102
|
? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
|
|
100
|
-
:
|
|
103
|
+
: props.data.color;
|
|
101
104
|
return (
|
|
102
105
|
<div
|
|
103
106
|
style={{
|
|
@@ -105,25 +108,42 @@ function AggregateFrameGroupView(
|
|
|
105
108
|
height: '100%',
|
|
106
109
|
boxSizing: 'border-box',
|
|
107
110
|
borderRadius: 12,
|
|
108
|
-
border: `2px
|
|
109
|
-
background: 'transparent',
|
|
111
|
+
border: `2px dashed ${color}`,
|
|
112
|
+
background: external ? 'transparent' : boundaryFill(color),
|
|
110
113
|
pointerEvents: 'none',
|
|
111
114
|
}}
|
|
112
115
|
>
|
|
113
116
|
<span
|
|
114
117
|
style={{
|
|
115
118
|
position: 'absolute',
|
|
116
|
-
top
|
|
119
|
+
// Centred on the frame's top border; translateY keeps it centred as
|
|
120
|
+
// the padding/border below grow instead of re-tuning a magic `top`.
|
|
121
|
+
top: 0,
|
|
122
|
+
transform: 'translateY(-50%)',
|
|
117
123
|
left: 12,
|
|
124
|
+
// Edges route in through the frame's top edge, right where this
|
|
125
|
+
// label sits. The solid border + vertical padding give the opaque
|
|
126
|
+
// chip enough backing to mask a crossing edge with a gap rather than
|
|
127
|
+
// running flush against the text, and zIndex keeps the label above
|
|
128
|
+
// sibling frame nodes (mirrors the component graph's badges).
|
|
129
|
+
zIndex: 1,
|
|
118
130
|
fontFamily: theme.fonts.monospace,
|
|
119
|
-
fontSize: theme.fontSizes[
|
|
131
|
+
fontSize: theme.fontSizes[2],
|
|
132
|
+
fontWeight: 600,
|
|
120
133
|
letterSpacing: 0.5,
|
|
121
134
|
textTransform: 'uppercase',
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
: (theme.colors.textMuted ?? theme.colors.textSecondary),
|
|
135
|
+
lineHeight: '18px',
|
|
136
|
+
color,
|
|
125
137
|
background: theme.colors.background,
|
|
126
|
-
|
|
138
|
+
// A rounded chip leaves transparent notches at its corners, and an
|
|
139
|
+
// edge crossing the frame's top border shows through them. The halo
|
|
140
|
+
// is the same colour as the fill, so it squares the corners back off
|
|
141
|
+
// invisibly while covering the antialiased boundary row too.
|
|
142
|
+
boxShadow: `0 0 0 1.5px ${theme.colors.background}`,
|
|
143
|
+
border: `2px solid ${color}`,
|
|
144
|
+
borderRadius: 4,
|
|
145
|
+
padding: '4px 10px',
|
|
146
|
+
boxSizing: 'border-box',
|
|
127
147
|
whiteSpace: 'nowrap',
|
|
128
148
|
}}
|
|
129
149
|
>
|
|
@@ -294,21 +314,44 @@ function Inner({
|
|
|
294
314
|
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
295
315
|
const [ready, setReady] = useState(false);
|
|
296
316
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
317
|
+
const [focusedBoundaryId, setFocusedBoundaryId] = useState<string | null>(null);
|
|
297
318
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
319
|
+
const [legendOpen, setLegendOpen] = useState(true);
|
|
298
320
|
|
|
299
321
|
const layoutKey = useMemo(
|
|
300
322
|
() =>
|
|
301
323
|
[
|
|
302
|
-
|
|
324
|
+
// Group assignment is part of the key: a frame keeps its id when its
|
|
325
|
+
// majority process flips, and the boundary colors follow the group.
|
|
326
|
+
...frames
|
|
327
|
+
.map((f) => `${f.id}\0${f.group?.kind ?? ''}\0${f.group?.key ?? ''}`)
|
|
328
|
+
.sort(),
|
|
303
329
|
...edges.map((e) => `${e.from}\0${e.to}\0${e.steps}`).sort(),
|
|
304
330
|
].join('\n'),
|
|
305
331
|
[frames, edges, hubs],
|
|
306
332
|
);
|
|
307
333
|
|
|
334
|
+
// Process boundaries and their frame counts. Derived from the frames, so the
|
|
335
|
+
// legend and the boundary nodes can never drift from what is actually drawn.
|
|
336
|
+
const processBoundaries = useMemo(() => {
|
|
337
|
+
const counts = new Map<string, number>();
|
|
338
|
+
for (const f of frames) {
|
|
339
|
+
if (f.group?.kind !== 'process') continue;
|
|
340
|
+
counts.set(f.group.key, (counts.get(f.group.key) ?? 0) + 1);
|
|
341
|
+
}
|
|
342
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
343
|
+
}, [frames]);
|
|
344
|
+
|
|
345
|
+
const boundaryColors = useMemo(
|
|
346
|
+
() => assignBoundaryColors(processBoundaries.map(([key]) => key)),
|
|
347
|
+
[processBoundaries],
|
|
348
|
+
);
|
|
349
|
+
|
|
308
350
|
useEffect(() => {
|
|
309
351
|
let alive = true;
|
|
310
352
|
setReady(false);
|
|
311
353
|
setSelectedId(null);
|
|
354
|
+
setFocusedBoundaryId(null);
|
|
312
355
|
// Compound process frames: each box is parented to exactly one process
|
|
313
356
|
// group (its majority process); process member-boxes to their own
|
|
314
357
|
// process; the rest stays root-level. Exclusive assignment by
|
|
@@ -377,6 +420,11 @@ function Inner({
|
|
|
377
420
|
// process frames.
|
|
378
421
|
preserveNodePositions: false,
|
|
379
422
|
groups,
|
|
423
|
+
// Every process and external boundary is real, including one holding a
|
|
424
|
+
// single frame — a quiet process still has components in it, and
|
|
425
|
+
// without this ELK drops the boundary and promotes the lone frame to
|
|
426
|
+
// root, which is indistinguishable from the process not existing.
|
|
427
|
+
keepSingletonGroups: true,
|
|
380
428
|
})
|
|
381
429
|
.then((result) => {
|
|
382
430
|
if (!alive) return;
|
|
@@ -395,6 +443,10 @@ function Inner({
|
|
|
395
443
|
selectable: false,
|
|
396
444
|
data: {
|
|
397
445
|
label: def?.key ?? id,
|
|
446
|
+
color:
|
|
447
|
+
(def?.kind === 'process' ? boundaryColors.get(def.key) : undefined) ??
|
|
448
|
+
theme.colors.border ??
|
|
449
|
+
'#333',
|
|
398
450
|
kind: def?.kind ?? 'process',
|
|
399
451
|
},
|
|
400
452
|
});
|
|
@@ -429,7 +481,27 @@ function Inner({
|
|
|
429
481
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
430
482
|
}, [layoutKey]);
|
|
431
483
|
|
|
484
|
+
// A boundary is a container, not an endpoint: edges reference frame/hub
|
|
485
|
+
// ids, never the group shell's. Hovering one therefore needs its own rule.
|
|
486
|
+
// Treating the shell as the focus put *nothing* in its neighbour set, so the
|
|
487
|
+
// boundary's own contents were the ones that dimmed. Instead: keep every node
|
|
488
|
+
// lit and dim only the edges that do not touch the boundary.
|
|
489
|
+
const boundaryMembers = useMemo(() => {
|
|
490
|
+
const focus = hoveredId ?? selectedId;
|
|
491
|
+
if (!focus) return null;
|
|
492
|
+
if (nodes.find((n) => n.id === focus)?.type !== 'aggregate-frame-group') {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const members = new Set<string>();
|
|
496
|
+
for (const n of nodes) {
|
|
497
|
+
if ((n as { parentId?: string }).parentId === focus) members.add(n.id);
|
|
498
|
+
}
|
|
499
|
+
return members;
|
|
500
|
+
}, [hoveredId, selectedId, nodes]);
|
|
501
|
+
|
|
432
502
|
const neighborIds = useMemo(() => {
|
|
503
|
+
// Boundary hover owns its own dimming (edges only) — no node focus set.
|
|
504
|
+
if (boundaryMembers) return null;
|
|
433
505
|
const focus = hoveredId ?? selectedId;
|
|
434
506
|
if (!focus) return null;
|
|
435
507
|
const set = new Set<string>([focus]);
|
|
@@ -438,12 +510,15 @@ function Inner({
|
|
|
438
510
|
if (e.to === focus) set.add(e.from);
|
|
439
511
|
}
|
|
440
512
|
return set;
|
|
441
|
-
}, [hoveredId, selectedId, edges]);
|
|
513
|
+
}, [hoveredId, selectedId, edges, boundaryMembers]);
|
|
442
514
|
|
|
443
515
|
const selected = selectedId ? (frames.find((f) => f.id === selectedId) ?? null) : null;
|
|
444
516
|
|
|
445
517
|
const onNodeClick = useCallback(
|
|
446
518
|
(_e: unknown, node: Node) => {
|
|
519
|
+
// Boundaries are shells, not frames — there is no frame to select, and a
|
|
520
|
+
// double-click would otherwise toggle the (empty) selection twice.
|
|
521
|
+
if (node.type === 'aggregate-frame-group') return;
|
|
447
522
|
const next = selectedId === node.id ? null : node.id;
|
|
448
523
|
setSelectedId(next);
|
|
449
524
|
onSelectFrame?.(next);
|
|
@@ -451,6 +526,37 @@ function Inner({
|
|
|
451
526
|
[selectedId, onSelectFrame],
|
|
452
527
|
);
|
|
453
528
|
|
|
529
|
+
// Double-click a boundary to zoom to it; double-click it again to zoom back
|
|
530
|
+
// out to the whole graph. Double-clicking a different boundary moves the
|
|
531
|
+
// focus. A boundary's interior is covered by the frames inside it, so a hit
|
|
532
|
+
// on any frame resolves to its enclosing boundary — otherwise double-clicking
|
|
533
|
+
// "on the boundary" would usually land on a frame and do nothing.
|
|
534
|
+
const onNodeDoubleClick = useCallback(
|
|
535
|
+
(_e: unknown, node: Node) => {
|
|
536
|
+
const boundaryId =
|
|
537
|
+
node.type === 'aggregate-frame-group'
|
|
538
|
+
? node.id
|
|
539
|
+
: (() => {
|
|
540
|
+
const parentId = (node as { parentId?: string }).parentId;
|
|
541
|
+
if (!parentId) return null;
|
|
542
|
+
return nodes.some(
|
|
543
|
+
(n) => n.id === parentId && n.type === 'aggregate-frame-group',
|
|
544
|
+
)
|
|
545
|
+
? parentId
|
|
546
|
+
: null;
|
|
547
|
+
})();
|
|
548
|
+
if (!boundaryId) return;
|
|
549
|
+
if (focusedBoundaryId === boundaryId) {
|
|
550
|
+
setFocusedBoundaryId(null);
|
|
551
|
+
void fitView({ padding: 0.15, duration: 400 });
|
|
552
|
+
} else {
|
|
553
|
+
setFocusedBoundaryId(boundaryId);
|
|
554
|
+
void fitView({ nodes: [{ id: boundaryId }], padding: 0.25, duration: 400 });
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
[focusedBoundaryId, fitView, nodes],
|
|
558
|
+
);
|
|
559
|
+
|
|
454
560
|
const displayNodes = useMemo(
|
|
455
561
|
() =>
|
|
456
562
|
nodes.map((n) => ({
|
|
@@ -458,41 +564,55 @@ function Inner({
|
|
|
458
564
|
data: {
|
|
459
565
|
...(n.data as object),
|
|
460
566
|
selected: selectedId === n.id,
|
|
461
|
-
|
|
567
|
+
// Boundary hover dims everything outside the boundary; otherwise
|
|
568
|
+
// dim anything outside the focused node's neighbourhood.
|
|
569
|
+
dimmed: boundaryMembers
|
|
570
|
+
? !boundaryMembers.has(n.id)
|
|
571
|
+
: neighborIds
|
|
572
|
+
? !neighborIds.has(n.id)
|
|
573
|
+
: false,
|
|
462
574
|
// Surviving parentId means ELK built this node's process frame —
|
|
463
575
|
// the node renders nested, so a process box drops its redundant name.
|
|
464
576
|
nested: (n as { parentId?: string }).parentId != null,
|
|
465
577
|
},
|
|
466
578
|
})),
|
|
467
|
-
[nodes, selectedId, neighborIds],
|
|
579
|
+
[nodes, selectedId, neighborIds, boundaryMembers],
|
|
468
580
|
);
|
|
469
581
|
|
|
470
582
|
const displayEdges = useMemo(
|
|
471
583
|
() =>
|
|
472
584
|
rfEdges.map((e) => {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
585
|
+
// Hovering a boundary keeps only the edges wholly inside it lit — a
|
|
586
|
+
// boundary-crossing edge has an endpoint outside, so it dims with the
|
|
587
|
+
// rest. Otherwise fall back to the node-focus rule.
|
|
588
|
+
const dimmed = boundaryMembers
|
|
589
|
+
? !(boundaryMembers.has(e.source) && boundaryMembers.has(e.target))
|
|
590
|
+
: neighborIds != null &&
|
|
591
|
+
!(neighborIds.has(e.source) && neighborIds.has(e.target));
|
|
476
592
|
return {
|
|
477
593
|
...e,
|
|
478
594
|
style: { ...(e.style as object), opacity: dimmed ? 0.12 : 0.9 },
|
|
479
595
|
};
|
|
480
596
|
}),
|
|
481
|
-
[rfEdges, neighborIds],
|
|
597
|
+
[rfEdges, neighborIds, boundaryMembers],
|
|
482
598
|
);
|
|
483
599
|
|
|
484
600
|
return (
|
|
485
601
|
<div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
|
|
602
|
+
<GraphLayerStyle />
|
|
486
603
|
<ReactFlow
|
|
487
604
|
nodes={displayNodes}
|
|
488
605
|
edges={displayEdges}
|
|
489
606
|
nodeTypes={nodeTypes}
|
|
607
|
+
className={GRAPH_CANVAS_CLASS}
|
|
490
608
|
{...GRAPH_NAV_PROPS}
|
|
491
609
|
onNodeClick={onNodeClick}
|
|
610
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
492
611
|
onNodeMouseEnter={(_e, node) => setHoveredId(node.id)}
|
|
493
612
|
onNodeMouseLeave={() => setHoveredId(null)}
|
|
494
613
|
onPaneClick={() => {
|
|
495
614
|
setSelectedId(null);
|
|
615
|
+
setFocusedBoundaryId(null);
|
|
496
616
|
onSelectFrame?.(null);
|
|
497
617
|
}}
|
|
498
618
|
proOptions={{ hideAttribution: true }}
|
|
@@ -534,6 +654,94 @@ function Inner({
|
|
|
534
654
|
{title}
|
|
535
655
|
</div>
|
|
536
656
|
)}
|
|
657
|
+
{processBoundaries.length > 1 && (
|
|
658
|
+
<div
|
|
659
|
+
style={{
|
|
660
|
+
position: 'absolute',
|
|
661
|
+
top: 12,
|
|
662
|
+
// Sits opposite the selection panel; shift clear of it while open.
|
|
663
|
+
right: selected ? 304 : 12,
|
|
664
|
+
maxHeight: '40%',
|
|
665
|
+
display: 'flex',
|
|
666
|
+
flexDirection: 'column',
|
|
667
|
+
gap: 3,
|
|
668
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
669
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
670
|
+
borderRadius: 6,
|
|
671
|
+
padding: '6px 10px',
|
|
672
|
+
// Only the header toggles; the body must not eat canvas panning.
|
|
673
|
+
pointerEvents: 'none',
|
|
674
|
+
}}
|
|
675
|
+
>
|
|
676
|
+
<button
|
|
677
|
+
type="button"
|
|
678
|
+
onClick={() => setLegendOpen((v) => !v)}
|
|
679
|
+
aria-expanded={legendOpen}
|
|
680
|
+
style={{
|
|
681
|
+
pointerEvents: 'auto',
|
|
682
|
+
display: 'flex',
|
|
683
|
+
alignItems: 'center',
|
|
684
|
+
gap: 6,
|
|
685
|
+
padding: 0,
|
|
686
|
+
border: 'none',
|
|
687
|
+
background: 'none',
|
|
688
|
+
cursor: 'pointer',
|
|
689
|
+
fontFamily: theme.fonts.monospace,
|
|
690
|
+
fontSize: theme.fontSizes[0],
|
|
691
|
+
letterSpacing: 0.5,
|
|
692
|
+
textTransform: 'uppercase',
|
|
693
|
+
color: muted,
|
|
694
|
+
}}
|
|
695
|
+
>
|
|
696
|
+
<span>{legendOpen ? '▾' : '▸'}</span>
|
|
697
|
+
<span>legend</span>
|
|
698
|
+
</button>
|
|
699
|
+
{legendOpen && (
|
|
700
|
+
<div
|
|
701
|
+
style={{
|
|
702
|
+
display: 'flex',
|
|
703
|
+
flexDirection: 'column',
|
|
704
|
+
gap: 3,
|
|
705
|
+
maxHeight: '40vh',
|
|
706
|
+
overflowY: 'auto',
|
|
707
|
+
}}
|
|
708
|
+
>
|
|
709
|
+
{processBoundaries.map(([key, count]) => {
|
|
710
|
+
const color = boundaryColors.get(key) ?? muted;
|
|
711
|
+
return (
|
|
712
|
+
<span
|
|
713
|
+
key={key}
|
|
714
|
+
style={{
|
|
715
|
+
display: 'flex',
|
|
716
|
+
alignItems: 'center',
|
|
717
|
+
gap: 6,
|
|
718
|
+
fontFamily: theme.fonts.monospace,
|
|
719
|
+
fontSize: theme.fontSizes[0],
|
|
720
|
+
color: theme.colors.text,
|
|
721
|
+
whiteSpace: 'nowrap',
|
|
722
|
+
}}
|
|
723
|
+
>
|
|
724
|
+
<span
|
|
725
|
+
style={{
|
|
726
|
+
width: 10,
|
|
727
|
+
height: 10,
|
|
728
|
+
flexShrink: 0,
|
|
729
|
+
borderRadius: 3,
|
|
730
|
+
border: `2px dashed ${color}`,
|
|
731
|
+
background: boundaryFill(color),
|
|
732
|
+
}}
|
|
733
|
+
/>
|
|
734
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 160 }} title={key}>
|
|
735
|
+
{key}
|
|
736
|
+
</span>
|
|
737
|
+
<span style={{ color: muted }}>{count}</span>
|
|
738
|
+
</span>
|
|
739
|
+
);
|
|
740
|
+
})}
|
|
741
|
+
</div>
|
|
742
|
+
)}
|
|
743
|
+
</div>
|
|
744
|
+
)}
|
|
537
745
|
{selected && (
|
|
538
746
|
<div
|
|
539
747
|
style={{
|
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
} from './IssueList';
|
|
63
63
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
64
64
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
65
|
-
import { GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
|
|
65
|
+
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
66
66
|
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
67
67
|
import type { ComponentVerificationState } from './ComponentDeclaration';
|
|
68
68
|
import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
|
|
@@ -173,6 +173,14 @@ export interface SubsystemComponentGraphProps {
|
|
|
173
173
|
maxNodeWidth?: number;
|
|
174
174
|
/** Show edge labels (mechanism names) on the graph. @default true */
|
|
175
175
|
showEdgeLabels?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Keep boundary frames for 1-member process / module / package regions. The
|
|
178
|
+
* singleton rule (frames need 2+ members) drops these, which also hid quiet
|
|
179
|
+
* regions like a one-file process that owns real behaviour. Defaults to true
|
|
180
|
+
* here so the component graph matches the aggregate graph, which always keeps
|
|
181
|
+
* them; pass false to fall back to the 2+ member rule.
|
|
182
|
+
*/
|
|
183
|
+
showSingletonFrames?: boolean;
|
|
176
184
|
/**
|
|
177
185
|
* Which edge vocabulary the canvas draws. The relation and walkthrough
|
|
178
186
|
* vocabularies are disjoint, so a graph carrying both shows one or the
|
|
@@ -396,7 +404,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
396
404
|
measured: { w: number; h: number } | null;
|
|
397
405
|
}
|
|
398
406
|
|
|
399
|
-
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, persistKey }: InnerProps) {
|
|
407
|
+
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, persistKey }: InnerProps) {
|
|
400
408
|
const { theme } = useTheme();
|
|
401
409
|
const { fitView } = useReactFlow();
|
|
402
410
|
const viewport = useViewport();
|
|
@@ -657,7 +665,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
657
665
|
relations: relationsRef.current,
|
|
658
666
|
walkthroughs: walkthroughsRef.current,
|
|
659
667
|
};
|
|
660
|
-
void buildSubsystemGraph(doc, {
|
|
668
|
+
void buildSubsystemGraph(doc, {
|
|
669
|
+
maxNodeWidth,
|
|
670
|
+
showEdgeLabels,
|
|
671
|
+
showSingletonFrames,
|
|
672
|
+
})
|
|
661
673
|
.then(({ nodes, edges: e }) => {
|
|
662
674
|
if (!alive) return;
|
|
663
675
|
// Prune dims for removed leaves; keep measurements for stable ids so a
|
|
@@ -706,7 +718,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
706
718
|
const gen = ++pass2GenRef.current;
|
|
707
719
|
void buildSubsystemGraph(
|
|
708
720
|
{ components, relations, walkthroughs },
|
|
709
|
-
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights },
|
|
721
|
+
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames },
|
|
710
722
|
)
|
|
711
723
|
.then(({ nodes, edges: e }) => {
|
|
712
724
|
if (gen !== pass2GenRef.current) return;
|
|
@@ -1157,6 +1169,21 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1157
1169
|
[onFileSelect],
|
|
1158
1170
|
);
|
|
1159
1171
|
|
|
1172
|
+
// Double-click a component opens its declaration in the file drawer — the
|
|
1173
|
+
// `file` + anchored `declarationRef.startLine` when verify resolved one,
|
|
1174
|
+
// else the file top. Uses the same open path as the declaration panel's
|
|
1175
|
+
// file link, so the drawer and file tree stay in sync.
|
|
1176
|
+
const onNodeDoubleClick: NodeMouseHandler = useCallback(
|
|
1177
|
+
(_e, node: Node) => {
|
|
1178
|
+
if (node.type !== 'subsystem-component') return;
|
|
1179
|
+
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
1180
|
+
if (!comp?.file) return;
|
|
1181
|
+
const startLine = comp.declarationRef?.startLine;
|
|
1182
|
+
onOpenDeclarationFile(comp.file, startLine != null ? { startLine } : undefined);
|
|
1183
|
+
},
|
|
1184
|
+
[onOpenDeclarationFile],
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1160
1187
|
const onOpenFileFromWalkthrough = useCallback(
|
|
1161
1188
|
(file: string, opts?: SubsystemOpenFileOptions) => {
|
|
1162
1189
|
setFileOverlay({ file, startLine: opts?.startLine });
|
|
@@ -1527,18 +1554,6 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1527
1554
|
[expandedWalkthroughs, fitOverview, zoomOnWalkthroughFocus],
|
|
1528
1555
|
);
|
|
1529
1556
|
|
|
1530
|
-
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
1531
|
-
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
1532
|
-
useEffect(() => {
|
|
1533
|
-
SUBSYSTEM_CALLBACKS.onOpenFile = (componentAlias: string) => {
|
|
1534
|
-
const comp = components.find((c) => c.alias === componentAlias);
|
|
1535
|
-
if (comp?.file) onOpenDeclarationFile(comp.file);
|
|
1536
|
-
};
|
|
1537
|
-
return () => {
|
|
1538
|
-
SUBSYSTEM_CALLBACKS.onOpenFile = undefined;
|
|
1539
|
-
};
|
|
1540
|
-
}, [components, onOpenDeclarationFile]);
|
|
1541
|
-
|
|
1542
1557
|
// Detail-panel links: related-name clicks select the matching component —
|
|
1543
1558
|
// resolved by alias, name, or symbol (call labels may carry a trailing `()`).
|
|
1544
1559
|
const resolveRelatedComponent = useCallback(
|
|
@@ -1974,14 +1989,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1974
1989
|
})}
|
|
1975
1990
|
</div>
|
|
1976
1991
|
)}
|
|
1992
|
+
<GraphLayerStyle />
|
|
1977
1993
|
<ReactFlow
|
|
1978
1994
|
key={`${baseNodesKey}-${baseEdgesKey}`}
|
|
1979
1995
|
nodes={dispNodes}
|
|
1980
1996
|
edges={dispEdges}
|
|
1981
1997
|
nodeTypes={nodeTypes}
|
|
1982
1998
|
edgeTypes={edgeTypes}
|
|
1999
|
+
className={GRAPH_CANVAS_CLASS}
|
|
1983
2000
|
{...GRAPH_NAV_PROPS}
|
|
1984
2001
|
onNodeClick={onNodeClick}
|
|
2002
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
1985
2003
|
onEdgeClick={onEdgeClick}
|
|
1986
2004
|
onNodesChange={onNodesChange}
|
|
1987
2005
|
onEdgesChange={onEdgesChange}
|
|
@@ -30,6 +30,26 @@ export const GRAPH_NAV_PROPS = {
|
|
|
30
30
|
edgesReconnectable: false,
|
|
31
31
|
} satisfies Partial<ReactFlowProps>;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* React Flow mounts its edge SVG (`.react-flow__edges`) and node layer
|
|
35
|
+
* (`.react-flow__nodes`) as sibling containers inside the viewport, both at
|
|
36
|
+
* `z-index: auto`, and leans on DOM order (edges first, nodes second) to keep
|
|
37
|
+
* nodes on top. Nodes carry an inline `z-index: 0`, which on its own does not
|
|
38
|
+
* reliably beat the edge layer, so an edge routing into a boundary frame's top
|
|
39
|
+
* border paints *over* that frame's label. Lift the whole node layer above the
|
|
40
|
+
* edge layer once for every graph that spreads `GRAPH_NAV_PROPS`.
|
|
41
|
+
*
|
|
42
|
+
* Scoped by `GRAPH_CANVAS_CLASS` so it only touches our canvases.
|
|
43
|
+
*/
|
|
44
|
+
export const GRAPH_CANVAS_CLASS = 'subsystem-graph-canvas';
|
|
45
|
+
|
|
46
|
+
export const GRAPH_LAYER_CSS = `.${GRAPH_CANVAS_CLASS} .react-flow__nodes { z-index: 1; }`;
|
|
47
|
+
|
|
48
|
+
/** Scoped style tag for `GRAPH_LAYER_CSS`; render once beside `<ReactFlow>`. */
|
|
49
|
+
export function GraphLayerStyle() {
|
|
50
|
+
return <style>{GRAPH_LAYER_CSS}</style>;
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
/** Background dots + zoom/fit/interactive widget; render inside `<ReactFlow>`. */
|
|
34
54
|
export function GraphChrome() {
|
|
35
55
|
return (
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
moduleBadgeHoverLabel,
|
|
13
13
|
moduleBadgeWidth,
|
|
14
14
|
moduleMinWidthForBadge,
|
|
15
|
+
boundaryMinWidthForBadge,
|
|
16
|
+
MODULE_BADGE_INSET,
|
|
15
17
|
packageGroupNodeId,
|
|
16
18
|
buildBoundaryLayoutGroups,
|
|
17
19
|
buildSubsystemGraph,
|
|
@@ -435,6 +437,97 @@ describe('subsystem graph model', () => {
|
|
|
435
437
|
expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
|
|
436
438
|
});
|
|
437
439
|
|
|
440
|
+
// NOTE: buildSubsystemGraph cannot assert ELK-built frames here — elkjs
|
|
441
|
+
// constructs a Worker, which bun's test env lacks, so every call falls back
|
|
442
|
+
// to manual positions. Frame existence is covered by planCompoundGroups
|
|
443
|
+
// tests in utils/elkLayout.test.ts; these cover the grouping inputs.
|
|
444
|
+
|
|
445
|
+
test('buildBoundaryLayoutGroups keeps singleton regions only when asked', () => {
|
|
446
|
+
const doc = {
|
|
447
|
+
components: [
|
|
448
|
+
{ alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
449
|
+
{ alias: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
450
|
+
{ alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
|
|
451
|
+
],
|
|
452
|
+
};
|
|
453
|
+
const off = buildBoundaryLayoutGroups(doc as never);
|
|
454
|
+
expect(off.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
|
|
455
|
+
|
|
456
|
+
const on = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
|
|
457
|
+
const lonely = on.find((g) => g.id === processGroupNodeId('app/lonely'));
|
|
458
|
+
expect(lonely).toBeDefined();
|
|
459
|
+
expect(lonely!.memberAliases).toEqual(['solo']);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('subsystem component graph keeps singleton frames unless opted out', () => {
|
|
463
|
+
// The component graph's default differs from buildBoundaryLayoutGroups'
|
|
464
|
+
// own default: SubsystemComponentGraph passes showSingletonFrames=true so
|
|
465
|
+
// a one-file process keeps its boundary, matching the aggregate graph.
|
|
466
|
+
// Only an explicit false falls back to the 2+ member rule.
|
|
467
|
+
const doc = {
|
|
468
|
+
components: [
|
|
469
|
+
{ alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
|
|
470
|
+
],
|
|
471
|
+
};
|
|
472
|
+
// Mirrors the Inner default: `showSingletonFrames = true`.
|
|
473
|
+
const componentGraphDefault = true;
|
|
474
|
+
const kept = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: componentGraphDefault });
|
|
475
|
+
expect(kept.map((g) => g.id)).toContain(processGroupNodeId('app/lonely'));
|
|
476
|
+
|
|
477
|
+
const optedOut = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: false });
|
|
478
|
+
expect(optedOut.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('buildBoundaryLayoutGroups parents every leaf to exactly one frame', () => {
|
|
482
|
+
// The invariant that keeps ELK from throwing "value already present".
|
|
483
|
+
// Singletons do not claim (see buildBoundaryLayoutGroups): they always
|
|
484
|
+
// nest, so the enclosing frame's own filter has to exclude them. This
|
|
485
|
+
// asserts the outcome rather than the mechanism.
|
|
486
|
+
const doc = {
|
|
487
|
+
components: [
|
|
488
|
+
{ alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
|
|
489
|
+
{ alias: 'x', name: 'x', construct: 'function', file: 'x.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
490
|
+
{ alias: 'y', name: 'y', construct: 'function', file: 'y.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
491
|
+
{ alias: 'z', name: 'z', construct: 'function', file: 'z.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
|
|
492
|
+
{ alias: 'w', name: 'w', construct: 'function', file: 'w.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
|
|
493
|
+
// module-less leaf in a multi-member process
|
|
494
|
+
{ alias: 'loose', name: 'loose', construct: 'function', file: 'l.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
495
|
+
],
|
|
496
|
+
};
|
|
497
|
+
const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
|
|
498
|
+
const owners = new Map<string, string[]>();
|
|
499
|
+
for (const g of groups) {
|
|
500
|
+
for (const alias of g.memberAliases) {
|
|
501
|
+
owners.set(alias, [...(owners.get(alias) ?? []), g.id]);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
for (const [alias, claimedBy] of owners) {
|
|
505
|
+
expect(new Set(claimedBy).size, `leaf ${alias} claimed by ${claimedBy.join(', ')}`).toBe(1);
|
|
506
|
+
}
|
|
507
|
+
// The singleton process nests under its package rather than claiming.
|
|
508
|
+
const lonely = groups.find((g) => g.id === processGroupNodeId('app/lonely'));
|
|
509
|
+
expect(lonely!.memberAliases).toEqual(['solo']);
|
|
510
|
+
expect(lonely!.parentId).toBe(packageGroupNodeId('pkg:github/acme/app'));
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('buildBoundaryLayoutGroups singleton module claims its leaf from the process', () => {
|
|
514
|
+
const doc = {
|
|
515
|
+
components: [
|
|
516
|
+
{ alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
517
|
+
{ alias: 'm1', name: 'm1', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
|
|
518
|
+
{ alias: 'm2', name: 'm2', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
|
|
519
|
+
],
|
|
520
|
+
};
|
|
521
|
+
const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
|
|
522
|
+
const mod = groups.find((g) => g.id === moduleGroupNodeId('src/one.ts'));
|
|
523
|
+
const proc = groups.find((g) => g.id === processGroupNodeId('app/host'));
|
|
524
|
+
expect(mod!.memberAliases).toEqual(['m1', 'm2']);
|
|
525
|
+
// The module is nested, so the process lists the module — not its
|
|
526
|
+
// leaves. `a` has no module, so it stays a direct leaf of the process.
|
|
527
|
+
expect(proc!.memberAliases).toEqual([moduleGroupNodeId('src/one.ts'), 'a']);
|
|
528
|
+
expect(proc!.parentId).toBeUndefined();
|
|
529
|
+
});
|
|
530
|
+
|
|
438
531
|
test('buildSubsystemGraph frames multi-member modules', async () => {
|
|
439
532
|
const { nodes, regions } = await buildSubsystemGraph({
|
|
440
533
|
components: [
|
|
@@ -796,6 +889,18 @@ describe('module badge labels', () => {
|
|
|
796
889
|
});
|
|
797
890
|
});
|
|
798
891
|
|
|
892
|
+
describe('boundary frame min width (process / package badges)', () => {
|
|
893
|
+
test('boundaryMinWidthForBadge reserves the full label plus padding', () => {
|
|
894
|
+
const label = 'principal-studio/worker';
|
|
895
|
+
const min = boundaryMinWidthForBadge(label);
|
|
896
|
+
// Clears the bare badge by an inset on both ends plus the frame border.
|
|
897
|
+
expect(min - moduleBadgeWidth(label)).toBe(MODULE_BADGE_INSET * 2 + 4);
|
|
898
|
+
expect(min).toBeGreaterThanOrEqual(moduleBadgeWidth(label));
|
|
899
|
+
// Longer labels need wider frames.
|
|
900
|
+
expect(boundaryMinWidthForBadge('principal-studio/renderer-worker')).toBeGreaterThan(min);
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
799
904
|
describe('reorderWalkthroughs', () => {
|
|
800
905
|
const wts: SubsystemWalkthrough[] = [
|
|
801
906
|
{ id: 'a', title: 'A', steps: [] },
|