@principal-ai/principal-view-react 0.16.58 → 0.16.60
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/pierre/constructColors.d.ts.map +1 -1
- package/dist/pierre/constructColors.js +4 -1
- package/dist/pierre/constructColors.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +18 -4
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +84 -18
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +48 -14
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +90 -10
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +10 -3
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +44 -1
- 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 +150 -12
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/pierre/constructColors.ts +4 -1
- package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +71 -0
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +68 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +118 -18
- package/src/subsystem/model.test.ts +56 -0
- package/src/subsystem/model.ts +128 -17
- package/src/subsystem/nodes.tsx +65 -2
- package/src/utils/elkLayout.ts +162 -12
|
@@ -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
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type SubsystemThroughline,
|
|
45
45
|
} from './model';
|
|
46
46
|
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
47
|
-
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
47
|
+
import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
48
48
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
49
49
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
50
50
|
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
@@ -53,6 +53,12 @@ import { FileDrawer } from './FileDrawer';
|
|
|
53
53
|
import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
|
|
54
54
|
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
55
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
|
+
|
|
56
62
|
export interface SubsystemComponentGraphProps {
|
|
57
63
|
components: SubsystemComponent[];
|
|
58
64
|
edges: SubsystemComponentEdge[];
|
|
@@ -73,8 +79,22 @@ export interface SubsystemComponentGraphProps {
|
|
|
73
79
|
maxNodeWidth?: number;
|
|
74
80
|
/** Show edge labels (mechanism names) on the graph. @default true */
|
|
75
81
|
showEdgeLabels?: boolean;
|
|
82
|
+
/** Show the mechanism-legend button overlay on the canvas. @default true */
|
|
83
|
+
showLegend?: boolean;
|
|
76
84
|
/** Subsystem title displayed in the sidebar. */
|
|
77
85
|
title?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Suppresses the sidebar entirely (title, description, file tree,
|
|
88
|
+
* throughlines) for graph-only embeds. Pair with `graphTitle` to keep the
|
|
89
|
+
* subsystem name visible as an overlay on the canvas.
|
|
90
|
+
*/
|
|
91
|
+
hideSidebar?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Subsystem title rendered as a non-interactive overlay chip on the graph
|
|
94
|
+
* canvas (top-center). Does not trigger the sidebar — for graph-only
|
|
95
|
+
* embeds that still need to name what they show.
|
|
96
|
+
*/
|
|
97
|
+
graphTitle?: string;
|
|
78
98
|
/** Markdown description rendered in the sidebar. */
|
|
79
99
|
description?: string;
|
|
80
100
|
/** Rendered over the graph canvas only (not the title/legend sidebar). */
|
|
@@ -109,6 +129,7 @@ export interface SubsystemComponentGraphProps {
|
|
|
109
129
|
|
|
110
130
|
const nodeTypes: NodeTypes = {
|
|
111
131
|
'subsystem-component': SubsystemComponentNode,
|
|
132
|
+
'subsystem-group': SubsystemGroupNode,
|
|
112
133
|
};
|
|
113
134
|
|
|
114
135
|
const edgeTypes: EdgeTypes = {
|
|
@@ -136,7 +157,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
136
157
|
measured: { w: number; h: number } | null;
|
|
137
158
|
}
|
|
138
159
|
|
|
139
|
-
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
160
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showLegend, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
140
161
|
const { theme } = useTheme();
|
|
141
162
|
const { fitView } = useReactFlow();
|
|
142
163
|
const viewport = useViewport();
|
|
@@ -224,22 +245,25 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
224
245
|
const measuredDimsRef = useRef(new Map<string, { width: number; height: number }>());
|
|
225
246
|
const pendingMeasuredRef = useRef(false);
|
|
226
247
|
|
|
227
|
-
// Pass 2: once every node has a measured dimension, re-run ELK.
|
|
248
|
+
// Pass 2: once every leaf node has a measured dimension, re-run ELK.
|
|
249
|
+
// Group parents are sized by ELK, not measured — exclude them or pass 2
|
|
250
|
+
// would wait forever for dimensions that never arrive.
|
|
228
251
|
const prevMeasuredSigRef = useRef('');
|
|
229
252
|
const pass2DoneRef = useRef(false);
|
|
230
253
|
const triggerPass2 = useCallback(() => {
|
|
231
254
|
if (pass2DoneRef.current) return;
|
|
232
255
|
const dims = measuredDimsRef.current;
|
|
233
|
-
|
|
234
|
-
|
|
256
|
+
const leafNodes = built.nodes.filter((n) => n.type !== 'subsystem-group');
|
|
257
|
+
if (dims.size < leafNodes.length) return;
|
|
258
|
+
const sig = leafNodes.map((n) => `${n.id}:${dims.get(n.id)?.width ?? '?'}`).join(',');
|
|
235
259
|
if (sig.includes('?:')) return;
|
|
236
260
|
if (sig === prevMeasuredSigRef.current) return;
|
|
237
261
|
prevMeasuredSigRef.current = sig;
|
|
238
262
|
pendingMeasuredRef.current = false;
|
|
239
263
|
pass2DoneRef.current = true;
|
|
240
264
|
|
|
241
|
-
const measuredWidths = new Map(
|
|
242
|
-
const measuredHeights = new Map(
|
|
265
|
+
const measuredWidths = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.width]));
|
|
266
|
+
const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.height]));
|
|
243
267
|
let alive = true;
|
|
244
268
|
void buildSubsystemGraph(
|
|
245
269
|
{ components, edges },
|
|
@@ -397,6 +421,26 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
397
421
|
// React Flow's own selection state from updating.
|
|
398
422
|
const dispNodes = useMemo(() => {
|
|
399
423
|
return xyflowNodesBase.map((n) => {
|
|
424
|
+
// Boundary frames follow their members: hidden when no member is
|
|
425
|
+
// visible, dimmed when members are dimmed. Never selectable.
|
|
426
|
+
if (n.type === 'subsystem-group') {
|
|
427
|
+
const memberIds = ((n.data as { region?: { memberIds?: string[] } } | undefined)?.region?.memberIds) ?? [];
|
|
428
|
+
const vis = flowElementVisibility({
|
|
429
|
+
inOpened: memberIds.some((id) => openedNodeIds?.has(id) === true),
|
|
430
|
+
inSelected: memberIds.some((id) => brightNodeIds?.has(id) === true),
|
|
431
|
+
anyOpened: openedNodeIds != null,
|
|
432
|
+
anySelected: brightNodeIds != null,
|
|
433
|
+
});
|
|
434
|
+
return {
|
|
435
|
+
...n,
|
|
436
|
+
hidden: vis.hidden,
|
|
437
|
+
selectable: false,
|
|
438
|
+
data: {
|
|
439
|
+
...(n.data as object),
|
|
440
|
+
...(vis.dimmed && { dimmed: true }),
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
400
444
|
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
401
445
|
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
402
446
|
const isSelected = selected?.id !== undefined && comp?.id === selected.id;
|
|
@@ -460,8 +504,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
460
504
|
const onNodesChange = useCallback(
|
|
461
505
|
(changes: NodeChange[]) => {
|
|
462
506
|
// Capture dimension changes (React Flow's measurement callback).
|
|
507
|
+
// Group parents are ELK-sized — ignore their measurements.
|
|
508
|
+
const groupIds = new Set(
|
|
509
|
+
dispNodes.filter((n) => n.type === 'subsystem-group').map((n) => n.id),
|
|
510
|
+
);
|
|
463
511
|
for (const ch of changes) {
|
|
464
|
-
if (ch.type === 'dimensions' && ch.dimensions) {
|
|
512
|
+
if (ch.type === 'dimensions' && ch.dimensions && !groupIds.has(ch.id)) {
|
|
465
513
|
measuredDimsRef.current.set(ch.id, ch.dimensions);
|
|
466
514
|
pendingMeasuredRef.current = true;
|
|
467
515
|
}
|
|
@@ -677,13 +725,20 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
677
725
|
return dispEdges
|
|
678
726
|
.filter((e) => !e.hidden)
|
|
679
727
|
.map((e) => {
|
|
680
|
-
const d = e.data as {
|
|
728
|
+
const d = e.data as {
|
|
729
|
+
mechanism?: string;
|
|
730
|
+
dimmed?: boolean;
|
|
731
|
+
labelX?: number;
|
|
732
|
+
labelY?: number;
|
|
733
|
+
pathLength?: number;
|
|
734
|
+
} | undefined;
|
|
681
735
|
return {
|
|
682
736
|
id: e.id,
|
|
683
737
|
mechanism: d?.mechanism ?? 'imports',
|
|
684
738
|
dimmed: d?.dimmed === true,
|
|
685
739
|
midX: d?.labelX ?? 0,
|
|
686
740
|
midY: d?.labelY ?? 0,
|
|
741
|
+
pathLength: d?.pathLength ?? 0,
|
|
687
742
|
stepNos: selectedFlowStepNos?.get(e.id),
|
|
688
743
|
};
|
|
689
744
|
});
|
|
@@ -729,7 +784,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
729
784
|
return (
|
|
730
785
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
731
786
|
{/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
|
|
732
|
-
{(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
787
|
+
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
733
788
|
<div
|
|
734
789
|
style={{
|
|
735
790
|
width: 340,
|
|
@@ -923,6 +978,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
923
978
|
const verifiable = MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] ?? true;
|
|
924
979
|
const screenX = lbl.midX * viewport.zoom + viewport.x;
|
|
925
980
|
const screenY = lbl.midY * viewport.zoom + viewport.y;
|
|
981
|
+
const text = lbl.stepNos?.length
|
|
982
|
+
? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
|
|
983
|
+
: lbl.mechanism;
|
|
984
|
+
// Labels stay readable at full size until they'd exceed a share of
|
|
985
|
+
// the edge's screen length, then shrink with zoom.
|
|
986
|
+
const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
|
|
987
|
+
const screenEdgeLen = lbl.pathLength * viewport.zoom;
|
|
988
|
+
const scale =
|
|
989
|
+
lbl.pathLength > 0 && estWidth > 0
|
|
990
|
+
? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
|
|
991
|
+
: 1;
|
|
926
992
|
return (
|
|
927
993
|
<div
|
|
928
994
|
key={lbl.id}
|
|
@@ -939,22 +1005,26 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
939
1005
|
// Center on the flow-space midpoint. Labels live in screen
|
|
940
1006
|
// space (fixed size when zoomed out), so top-left anchoring
|
|
941
1007
|
// would drift them right/down of the edge as zoom drops.
|
|
942
|
-
transform:
|
|
1008
|
+
transform: `translate(-50%, -50%) scale(${scale})`,
|
|
1009
|
+
transformOrigin: 'center center',
|
|
1010
|
+
display: 'flex',
|
|
1011
|
+
alignItems: 'center',
|
|
943
1012
|
fontSize: 10,
|
|
1013
|
+
lineHeight: 1,
|
|
944
1014
|
fontFamily: theme.fonts.monospace,
|
|
945
1015
|
fontWeight: 500,
|
|
946
1016
|
color,
|
|
947
1017
|
background: 'rgba(21,21,21,0.9)',
|
|
948
1018
|
border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
|
|
949
1019
|
borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
|
|
950
|
-
padding: '
|
|
1020
|
+
padding: '3px 8px',
|
|
951
1021
|
cursor: 'pointer',
|
|
952
1022
|
pointerEvents: 'auto',
|
|
953
1023
|
opacity: lbl.dimmed ? 0.15 : 1,
|
|
954
1024
|
whiteSpace: 'nowrap',
|
|
955
1025
|
}}
|
|
956
1026
|
>
|
|
957
|
-
{
|
|
1027
|
+
{text}
|
|
958
1028
|
</div>
|
|
959
1029
|
);
|
|
960
1030
|
})}
|
|
@@ -996,7 +1066,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
996
1066
|
<Controls showZoom showFitView showInteractive />
|
|
997
1067
|
</ReactFlow>
|
|
998
1068
|
{/* Legend button — top-left overlay on the canvas; opens the modal. */}
|
|
999
|
-
{usedMechanisms.size > 0 && (
|
|
1069
|
+
{showLegend !== false && usedMechanisms.size > 0 && (
|
|
1000
1070
|
<button
|
|
1001
1071
|
type="button"
|
|
1002
1072
|
onClick={() => setLegendOpen(true)}
|
|
@@ -1022,6 +1092,36 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1022
1092
|
<MapIcon size={13} />
|
|
1023
1093
|
Legend
|
|
1024
1094
|
</button>
|
|
1095
|
+
)}
|
|
1096
|
+
{/* Graph title — non-interactive chip centered at the top of the canvas
|
|
1097
|
+
(clear of the top-left legend button and top-right declaration
|
|
1098
|
+
card). Lets graph-only embeds name the subsystem they show. */}
|
|
1099
|
+
{graphTitle && (
|
|
1100
|
+
<div
|
|
1101
|
+
style={{
|
|
1102
|
+
position: 'absolute',
|
|
1103
|
+
top: 10,
|
|
1104
|
+
left: '50%',
|
|
1105
|
+
transform: 'translateX(-50%)',
|
|
1106
|
+
zIndex: 6,
|
|
1107
|
+
maxWidth: '60%',
|
|
1108
|
+
overflow: 'hidden',
|
|
1109
|
+
textOverflow: 'ellipsis',
|
|
1110
|
+
whiteSpace: 'nowrap',
|
|
1111
|
+
padding: '6px 18px',
|
|
1112
|
+
fontSize: theme.fontSizes[3],
|
|
1113
|
+
fontWeight: 600,
|
|
1114
|
+
fontFamily: theme.fonts.heading,
|
|
1115
|
+
color: theme.colors.text,
|
|
1116
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1117
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1118
|
+
borderRadius: 6,
|
|
1119
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1120
|
+
pointerEvents: 'none',
|
|
1121
|
+
}}
|
|
1122
|
+
>
|
|
1123
|
+
{graphTitle}
|
|
1124
|
+
</div>
|
|
1025
1125
|
)}
|
|
1026
1126
|
{/* Selected-component declaration — floating card over the canvas
|
|
1027
1127
|
(top-right, clear of the top-left legend button). The graph never
|
|
@@ -2,6 +2,9 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
convertSubsystemToNodes,
|
|
4
4
|
convertSubsystemToEdges,
|
|
5
|
+
convertSubsystemToGroups,
|
|
6
|
+
getSubsystemRegions,
|
|
7
|
+
processGroupNodeId,
|
|
5
8
|
buildSubsystemGraph,
|
|
6
9
|
deriveNameFromSymbol,
|
|
7
10
|
formatPurl,
|
|
@@ -121,6 +124,59 @@ describe('subsystem graph model', () => {
|
|
|
121
124
|
expect(formatPurl('not-a-purl')).toBe('not-a-purl');
|
|
122
125
|
});
|
|
123
126
|
|
|
127
|
+
test('getSubsystemRegions groups by process, skipping process-less nodes', () => {
|
|
128
|
+
const regions = getSubsystemRegions({
|
|
129
|
+
components: [
|
|
130
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
131
|
+
{ id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
132
|
+
{ id: 'c', name: 'c', construct: 'function', file: 'c.ts', purl: 'pkg:github/acme/app', process: 'app/renderer' },
|
|
133
|
+
{ id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
expect(regions.map((r) => r.key)).toEqual(['app/host', 'app/renderer']);
|
|
137
|
+
expect(regions[0]!.memberIds).toEqual(['a', 'b']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('convertSubsystemToNodes stamps parentId for process members only', () => {
|
|
141
|
+
const nodes = convertSubsystemToNodes({
|
|
142
|
+
components: [
|
|
143
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
144
|
+
{ id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
|
|
145
|
+
],
|
|
146
|
+
edges: [],
|
|
147
|
+
});
|
|
148
|
+
expect((nodes.find((n) => n.id === 'a') as { parentId?: string }).parentId).toBe(
|
|
149
|
+
processGroupNodeId('app/host'),
|
|
150
|
+
);
|
|
151
|
+
expect((nodes.find((n) => n.id === 'd') as { parentId?: string }).parentId).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('convertSubsystemToGroups emits one parent per process', () => {
|
|
155
|
+
const groups = convertSubsystemToGroups({
|
|
156
|
+
components: [
|
|
157
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
158
|
+
{ id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
expect(groups).toHaveLength(1);
|
|
162
|
+
expect(groups[0]!.id).toBe(processGroupNodeId('app/host'));
|
|
163
|
+
expect(groups[0]!.type).toBe('subsystem-group');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('buildSubsystemGraph drops singleton process frames (no parentId, no group)', async () => {
|
|
167
|
+
const { nodes, regions } = await buildSubsystemGraph({
|
|
168
|
+
components: [
|
|
169
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
170
|
+
{ id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
171
|
+
{ id: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
|
|
172
|
+
],
|
|
173
|
+
edges: [{ id: 'e1', from: 'a', to: 'b', mechanism: 'calls' }],
|
|
174
|
+
});
|
|
175
|
+
expect(regions.map((r) => r.key)).toEqual(['app/host']);
|
|
176
|
+
expect((nodes.find((n) => n.id === 'solo') as { parentId?: string }).parentId).toBeUndefined();
|
|
177
|
+
expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
124
180
|
test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
|
|
125
181
|
const base = { components: comps, edges };
|
|
126
182
|
const withRef = {
|
package/src/subsystem/model.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
type Edge,
|
|
18
18
|
type Node,
|
|
19
19
|
} from '@xyflow/react';
|
|
20
|
-
import { computeElkLayout } from '../utils/elkLayout';
|
|
20
|
+
import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
|
|
21
21
|
import type { GraphifyComponentDetail } from '../graphify';
|
|
22
22
|
import type { SubsystemDeclarationRef } from './declarationRef';
|
|
23
23
|
|
|
@@ -28,6 +28,7 @@ export type SubsystemComponentConstruct =
|
|
|
28
28
|
| 'interface'
|
|
29
29
|
| 'type_alias'
|
|
30
30
|
| 'enum'
|
|
31
|
+
| 'react_component'
|
|
31
32
|
| 'module'
|
|
32
33
|
| 'store'
|
|
33
34
|
| 'external';
|
|
@@ -92,12 +93,14 @@ export interface SubsystemComponent {
|
|
|
92
93
|
name: string;
|
|
93
94
|
/**
|
|
94
95
|
* The node's construct — what it IS as a declaration (class, function,
|
|
95
|
-
* method, interface, type alias, enum, store, external),
|
|
96
|
-
* anatomy, color, badge, and the verification strategy. Every
|
|
97
|
-
* anchors to a definition; runtime occurrences (variables,
|
|
98
|
-
* instances) are NOT constructs — they belong to a future
|
|
99
|
-
* graph whose occurrence nodes reference these definitions.
|
|
100
|
-
* construct = what it is, role = where it sits, process = where
|
|
96
|
+
* method, interface, type alias, enum, react_component, store, external),
|
|
97
|
+
* driving node anatomy, color, badge, and the verification strategy. Every
|
|
98
|
+
* construct anchors to a definition; runtime occurrences (variables,
|
|
99
|
+
* activations, instances) are NOT constructs — they belong to a future
|
|
100
|
+
* execution-mode graph whose occurrence nodes reference these definitions.
|
|
101
|
+
* Ontology: construct = what it is, role = where it sits, process = where
|
|
102
|
+
* it runs. Use `react_component` (not `function`) for JSX/TSX UI units so
|
|
103
|
+
* the badge reads "component" instead of "function".
|
|
101
104
|
*/
|
|
102
105
|
construct: SubsystemComponentConstruct;
|
|
103
106
|
/** Source location the component lives in (repo-root-relative path). */
|
|
@@ -292,6 +295,46 @@ export interface SubsystemGraphDocument {
|
|
|
292
295
|
|
|
293
296
|
export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
|
|
294
297
|
|
|
298
|
+
/**
|
|
299
|
+
* One process boundary region — all components sharing a `process` value.
|
|
300
|
+
* Nodes without a `process` sit outside every boundary (no region).
|
|
301
|
+
*/
|
|
302
|
+
export interface SubsystemProcessRegion {
|
|
303
|
+
/** The `process` value (e.g. `trail-viewer/host`). */
|
|
304
|
+
key: string;
|
|
305
|
+
/** Display label for the boundary frame. */
|
|
306
|
+
label: string;
|
|
307
|
+
/** Component ids that are members of this region. */
|
|
308
|
+
memberIds: string[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** React Flow id for a process boundary group node. */
|
|
312
|
+
export function processGroupNodeId(processKey: string): string {
|
|
313
|
+
return `process:${processKey}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Derive boundary regions from a document — one per distinct non-empty
|
|
318
|
+
* `process` value, in first-appearance order.
|
|
319
|
+
*/
|
|
320
|
+
export function getSubsystemRegions(
|
|
321
|
+
doc: Pick<SubsystemGraphDocument, 'components'>,
|
|
322
|
+
): SubsystemProcessRegion[] {
|
|
323
|
+
const byProcess = new Map<string, string[]>();
|
|
324
|
+
for (const c of doc.components) {
|
|
325
|
+
const p = c.process?.trim();
|
|
326
|
+
if (!p) continue;
|
|
327
|
+
const list = byProcess.get(p) ?? [];
|
|
328
|
+
list.push(c.id);
|
|
329
|
+
byProcess.set(p, list);
|
|
330
|
+
}
|
|
331
|
+
return [...byProcess.entries()].map(([key, memberIds]) => ({
|
|
332
|
+
key,
|
|
333
|
+
label: key,
|
|
334
|
+
memberIds,
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
|
|
295
338
|
export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
296
339
|
component: SubsystemComponent;
|
|
297
340
|
/** Set while a file is open in the drawer: true if this node's component
|
|
@@ -302,7 +345,15 @@ export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
|
302
345
|
dimmed?: boolean;
|
|
303
346
|
}
|
|
304
347
|
|
|
305
|
-
export
|
|
348
|
+
export interface SubsystemGroupNodeData extends Record<string, unknown> {
|
|
349
|
+
region: SubsystemProcessRegion;
|
|
350
|
+
/** True while the region's members are dimmed by flow focus. */
|
|
351
|
+
dimmed?: boolean;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export type SubsystemGraphNode =
|
|
355
|
+
| Node<SubsystemGraphNodeData, 'subsystem-component'>
|
|
356
|
+
| Node<SubsystemGroupNodeData, 'subsystem-group'>;
|
|
306
357
|
|
|
307
358
|
export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
308
359
|
mechanism: SubsystemEdgeMechanism;
|
|
@@ -313,6 +364,8 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
|
313
364
|
/** ELK-computed label midpoint (from the actual edge path, not node centers). */
|
|
314
365
|
labelX?: number;
|
|
315
366
|
labelY?: number;
|
|
367
|
+
/** Polyline length in flow-space units (for capping screen-space label size). */
|
|
368
|
+
pathLength?: number;
|
|
316
369
|
/** ELK-computed SVG edge path (overrides React Flow's default path). */
|
|
317
370
|
elkPath?: string;
|
|
318
371
|
}
|
|
@@ -394,12 +447,11 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
|
|
|
394
447
|
};
|
|
395
448
|
|
|
396
449
|
/**
|
|
397
|
-
* Convert a subsystem graph document into React Flow nodes.
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
* Flow; the package boundary is a visual region, not a sub-flow node.
|
|
450
|
+
* Convert a subsystem graph document into React Flow nodes. Components that
|
|
451
|
+
* carry a `process` get a `parentId` pointing at their boundary group node
|
|
452
|
+
* (`process:<process>`); nodes without one stay top-level (outside every
|
|
453
|
+
* boundary). The initial grid groups by `process ?? purl` so the pre-ELK
|
|
454
|
+
* positions are already clustered; ELK then refines with compound layout.
|
|
403
455
|
*/
|
|
404
456
|
export function convertSubsystemToNodes(
|
|
405
457
|
doc: SubsystemGraphDocument,
|
|
@@ -447,9 +499,11 @@ export function convertSubsystemToNodes(
|
|
|
447
499
|
const cssBorder = 4; // 2px border each side
|
|
448
500
|
const rawWidth = Math.max(cssMinWidth, textWidth + cssPadding + cssBorder);
|
|
449
501
|
const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
|
|
502
|
+
const processKey = c.process?.trim();
|
|
450
503
|
nodes.push({
|
|
451
504
|
id: c.id,
|
|
452
505
|
type: 'subsystem-component',
|
|
506
|
+
...(processKey ? { parentId: processGroupNodeId(processKey) } : {}),
|
|
453
507
|
position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
|
|
454
508
|
width: nodeWidth,
|
|
455
509
|
height: 84,
|
|
@@ -461,6 +515,24 @@ export function convertSubsystemToNodes(
|
|
|
461
515
|
return nodes;
|
|
462
516
|
}
|
|
463
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Convert boundary regions into React Flow parent (group) nodes. One per
|
|
520
|
+
* distinct `process` value; member components point at these via `parentId`.
|
|
521
|
+
* Positions/sizes are placeholders — ELK compound layout overwrites them.
|
|
522
|
+
*/
|
|
523
|
+
export function convertSubsystemToGroups(
|
|
524
|
+
doc: Pick<SubsystemGraphDocument, 'components'>,
|
|
525
|
+
): SubsystemGraphNode[] {
|
|
526
|
+
return getSubsystemRegions(doc).map((region) => ({
|
|
527
|
+
id: processGroupNodeId(region.key),
|
|
528
|
+
type: 'subsystem-group',
|
|
529
|
+
position: { x: 0, y: 0 },
|
|
530
|
+
width: 400,
|
|
531
|
+
height: 300,
|
|
532
|
+
data: { region },
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
|
|
464
536
|
/**
|
|
465
537
|
* Convert a subsystem graph document into React Flow edges. Edges whose target
|
|
466
538
|
* is an external label (not a component id) point at a synthetic stub so the
|
|
@@ -522,10 +594,23 @@ export async function buildSubsystemGraph(
|
|
|
522
594
|
): Promise<{
|
|
523
595
|
nodes: SubsystemGraphNode[];
|
|
524
596
|
edges: SubsystemGraphEdge[];
|
|
597
|
+
regions: SubsystemProcessRegion[];
|
|
525
598
|
}> {
|
|
526
599
|
const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
|
|
527
600
|
const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
|
|
528
601
|
const edges = convertSubsystemToEdges(doc);
|
|
602
|
+
// Boundary regions: one per multi-member process. Singletons get no frame —
|
|
603
|
+
// strip the parentId convertSubsystemToNodes stamped so React Flow never
|
|
604
|
+
// points at a non-existent parent.
|
|
605
|
+
const regions = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
|
|
606
|
+
const regionKeys = new Set(regions.map((r) => r.key));
|
|
607
|
+
for (const n of nodes) {
|
|
608
|
+
if (n.type !== 'subsystem-component') continue;
|
|
609
|
+
const proc = (n.data as SubsystemGraphNodeData).component?.process?.trim();
|
|
610
|
+
if (proc && !regionKeys.has(proc)) {
|
|
611
|
+
delete (n as { parentId?: string }).parentId;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
529
614
|
|
|
530
615
|
// External edge targets that aren't real components → create stub nodes so
|
|
531
616
|
// cross-package edges have something to land on.
|
|
@@ -574,10 +659,12 @@ export async function buildSubsystemGraph(
|
|
|
574
659
|
}
|
|
575
660
|
}
|
|
576
661
|
|
|
577
|
-
// ELK auto-layout: position nodes (layered, minimized crossings)
|
|
662
|
+
// ELK auto-layout: position nodes (layered, minimized crossings) with
|
|
663
|
+
// process partitions as compound parents so boundaries shape the layout.
|
|
578
664
|
let placedNodes = nodes;
|
|
579
665
|
let labelPositions = new Map<string, { x: number; y: number }>();
|
|
580
666
|
let elkPathStrings = new Map<string, string>();
|
|
667
|
+
let elkPathPoints = new Map<string, { x: number; y: number }[]>();
|
|
581
668
|
if (nodes.length > 0) {
|
|
582
669
|
try {
|
|
583
670
|
const result = await computeElkLayout(nodes, edges, {
|
|
@@ -589,10 +676,29 @@ export async function buildSubsystemGraph(
|
|
|
589
676
|
interLayerSpacing: 120,
|
|
590
677
|
preserveNodePositions: false,
|
|
591
678
|
edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
|
|
679
|
+
groups: regions.map((r) => ({
|
|
680
|
+
id: processGroupNodeId(r.key),
|
|
681
|
+
memberIds: r.memberIds,
|
|
682
|
+
})),
|
|
592
683
|
});
|
|
593
|
-
|
|
684
|
+
const groupNodes: SubsystemGraphNode[] = regions.flatMap((region) => {
|
|
685
|
+
const bounds = result.groupBounds.get(processGroupNodeId(region.key));
|
|
686
|
+
if (!bounds) return [];
|
|
687
|
+
const group: SubsystemGraphNode = {
|
|
688
|
+
id: processGroupNodeId(region.key),
|
|
689
|
+
type: 'subsystem-group',
|
|
690
|
+
position: { x: bounds.x, y: bounds.y },
|
|
691
|
+
width: Math.max(200, bounds.width),
|
|
692
|
+
height: Math.max(160, bounds.height),
|
|
693
|
+
data: { region },
|
|
694
|
+
};
|
|
695
|
+
return [group];
|
|
696
|
+
});
|
|
697
|
+
// Parents first — React Flow resolves children via parentId.
|
|
698
|
+
placedNodes = [...groupNodes, ...(result.nodes as SubsystemGraphNode[])];
|
|
594
699
|
labelPositions = result.edgeLabelPositions;
|
|
595
700
|
elkPathStrings = result.edgePaths;
|
|
701
|
+
elkPathPoints = result.edgePathPoints;
|
|
596
702
|
} catch (err) {
|
|
597
703
|
// Fall back to the (unpositioned) grid if ELK is unavailable.
|
|
598
704
|
console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
|
|
@@ -613,7 +719,12 @@ export async function buildSubsystemGraph(
|
|
|
613
719
|
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
614
720
|
d.elkPath = elkP;
|
|
615
721
|
}
|
|
722
|
+
const pts = elkPathPoints.get(e.id);
|
|
723
|
+
if (pts && pts.length > 1) {
|
|
724
|
+
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
725
|
+
d.pathLength = calculatePathLength(pts);
|
|
726
|
+
}
|
|
616
727
|
}
|
|
617
728
|
|
|
618
|
-
return { nodes: placedNodes, edges };
|
|
729
|
+
return { nodes: placedNodes, edges, regions };
|
|
619
730
|
}
|