@principal-ai/subsystems-react 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -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/aggregateFixture.d.ts +5 -0
- package/dist/stories/Subsystem/ComponentGraph/aggregateFixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/aggregateFixture.js +4 -0
- package/dist/stories/Subsystem/ComponentGraph/aggregateFixture.js.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts +5 -0
- package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js +4 -0
- package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/composedFixture.d.ts +5 -0
- package/dist/stories/Subsystem/ComponentGraph/composedFixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/composedFixture.js +4 -0
- package/dist/stories/Subsystem/ComponentGraph/composedFixture.js.map +1 -0
- package/dist/subsystem/SubsystemAggregateGraph.d.ts +70 -0
- package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -0
- package/dist/subsystem/SubsystemAggregateGraph.js +385 -0
- package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -0
- package/dist/subsystem/model.d.ts +7 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +65 -1
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -0
- package/src/stories/Subsystem/ComponentDeclarationAudit.stories.tsx +13 -13
- package/src/stories/Subsystem/ComponentGraph/AggregateFrameGraph.stories.tsx +61 -0
- package/src/stories/Subsystem/ComponentGraph/AggregateGraph.stories.tsx +61 -0
- package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +17 -17
- package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +2 -2
- package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +3 -3
- package/src/stories/Subsystem/ComponentGraph/ComposedGraph.stories.tsx +73 -0
- package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +6 -6
- package/src/stories/Subsystem/ComponentGraph/CustomEntities.stories.tsx +8 -8
- package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +11 -11
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +6 -6
- package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +10 -10
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +6 -6
- package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +17 -17
- package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +16 -16
- package/src/stories/Subsystem/ComponentGraph/Packages.stories.tsx +12 -12
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/Proposed.stories.tsx +10 -10
- package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +69 -69
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +27 -27
- package/src/stories/Subsystem/ComponentGraph/aggregateFixture.ts +8 -0
- package/src/stories/Subsystem/ComponentGraph/aggregateViewFixture.ts +8 -0
- package/src/stories/Subsystem/ComponentGraph/composedFixture.ts +9 -0
- package/src/subsystem/SubsystemAggregateGraph.tsx +607 -0
- package/src/subsystem/model.test.ts +63 -0
- package/src/subsystem/model.ts +65 -1
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubsystemAggregateGraph — frame-level overview canvas.
|
|
3
|
+
*
|
|
4
|
+
* Same stack and interaction language as SubsystemComponentGraph
|
|
5
|
+
* (React Flow + ELK + custom nodes + select/hover), but purpose-built for
|
|
6
|
+
* composed views: nodes are process / module frames (plus one ungrouped
|
|
7
|
+
* bucket), edges are the deduped walkthrough flows between them.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately leaner than the component graph: fixed node sizes (no
|
|
10
|
+
* measurement passes), flat ELK layout (no compound groups, so the
|
|
11
|
+
* double-parent failure mode is structurally impossible), no edge labels,
|
|
12
|
+
* no walkthrough playback, no drawers. Frame detail (members, models)
|
|
13
|
+
* renders in a side panel.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
Edge,
|
|
19
|
+
Handle,
|
|
20
|
+
MarkerType,
|
|
21
|
+
Node,
|
|
22
|
+
NodeProps,
|
|
23
|
+
Position,
|
|
24
|
+
ReactFlow,
|
|
25
|
+
ReactFlowProvider,
|
|
26
|
+
useReactFlow,
|
|
27
|
+
} from '@xyflow/react';
|
|
28
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
29
|
+
import { computeElkLayout } from '../utils/elkLayout';
|
|
30
|
+
import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
|
|
31
|
+
|
|
32
|
+
export interface AggregateFrameMember {
|
|
33
|
+
alias: string;
|
|
34
|
+
name: string;
|
|
35
|
+
file: string;
|
|
36
|
+
construct: string;
|
|
37
|
+
models: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AggregateFrameNode {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: 'module' | 'process' | 'external' | 'ungrouped';
|
|
43
|
+
key: string;
|
|
44
|
+
label: string;
|
|
45
|
+
/**
|
|
46
|
+
* Enclosing boundary: process code frames nest in their process frame,
|
|
47
|
+
* every external frame in the single external boundary. Absent =
|
|
48
|
+
* root-level (module with no process, ungrouped).
|
|
49
|
+
*/
|
|
50
|
+
group?: { key: string; kind: 'process' | 'external' };
|
|
51
|
+
members: AggregateFrameMember[];
|
|
52
|
+
models: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AggregateFrameEdge {
|
|
56
|
+
from: string;
|
|
57
|
+
to: string;
|
|
58
|
+
mechanisms: string[];
|
|
59
|
+
walkthroughIds: string[];
|
|
60
|
+
steps: number;
|
|
61
|
+
/** Real member endpoint behind a hub-routed edge (see aggregate). */
|
|
62
|
+
source?: string;
|
|
63
|
+
target?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Intake/outtake hub for a multi-frame boundary. */
|
|
67
|
+
export interface AggregateHub {
|
|
68
|
+
id: string;
|
|
69
|
+
group: { key: string; kind: 'process' | 'external' };
|
|
70
|
+
kind: 'intake' | 'outtake';
|
|
71
|
+
label: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SubsystemAggregateGraphProps {
|
|
75
|
+
frames: AggregateFrameNode[];
|
|
76
|
+
edges: AggregateFrameEdge[];
|
|
77
|
+
/** Intake/outtake hubs; rendered as compact pills inside their boundary. */
|
|
78
|
+
hubs?: AggregateHub[];
|
|
79
|
+
title?: string;
|
|
80
|
+
onSelectFrame?: (id: string | null) => void;
|
|
81
|
+
onOpenFile?: (path: string) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const NODE_W = 232;
|
|
85
|
+
const NODE_H = 78;
|
|
86
|
+
|
|
87
|
+
function edgeColor(mechanism: string | undefined, fallback: string): string {
|
|
88
|
+
if (!mechanism) return fallback;
|
|
89
|
+
return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function AggregateFrameGroupView(
|
|
93
|
+
props: NodeProps<Node<{ label: string; kind: 'process' | 'external' }>>,
|
|
94
|
+
) {
|
|
95
|
+
const { theme } = useTheme();
|
|
96
|
+
const external = props.data.kind === 'external';
|
|
97
|
+
const border = external
|
|
98
|
+
? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
|
|
99
|
+
: (theme.colors.border ?? '#333');
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
style={{
|
|
103
|
+
width: '100%',
|
|
104
|
+
height: '100%',
|
|
105
|
+
boxSizing: 'border-box',
|
|
106
|
+
borderRadius: 12,
|
|
107
|
+
border: `2px ${external ? 'solid' : 'dashed'} ${border}`,
|
|
108
|
+
background: 'transparent',
|
|
109
|
+
pointerEvents: 'none',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<span
|
|
113
|
+
style={{
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
top: -11,
|
|
116
|
+
left: 12,
|
|
117
|
+
fontFamily: theme.fonts.monospace,
|
|
118
|
+
fontSize: theme.fontSizes[1],
|
|
119
|
+
letterSpacing: 0.5,
|
|
120
|
+
textTransform: 'uppercase',
|
|
121
|
+
color: external
|
|
122
|
+
? border
|
|
123
|
+
: (theme.colors.textMuted ?? theme.colors.textSecondary),
|
|
124
|
+
background: theme.colors.background,
|
|
125
|
+
padding: '0 8px',
|
|
126
|
+
whiteSpace: 'nowrap',
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{props.data.label}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const HUB_W = 96;
|
|
136
|
+
const HUB_H = 30;
|
|
137
|
+
|
|
138
|
+
function AggregateHubNodeView(
|
|
139
|
+
props: NodeProps<Node<{ hub: AggregateHub }>>,
|
|
140
|
+
) {
|
|
141
|
+
const { theme } = useTheme();
|
|
142
|
+
const { hub } = props.data;
|
|
143
|
+
const color =
|
|
144
|
+
hub.group.kind === 'external'
|
|
145
|
+
? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
|
|
146
|
+
: (theme.colors.border ?? '#333');
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
style={{
|
|
150
|
+
width: HUB_W,
|
|
151
|
+
height: HUB_H,
|
|
152
|
+
boxSizing: 'border-box',
|
|
153
|
+
display: 'flex',
|
|
154
|
+
alignItems: 'center',
|
|
155
|
+
justifyContent: 'center',
|
|
156
|
+
borderRadius: HUB_H / 2,
|
|
157
|
+
border: `1px solid ${color}`,
|
|
158
|
+
background: theme.colors.background,
|
|
159
|
+
color: theme.colors.textMuted ?? theme.colors.textSecondary,
|
|
160
|
+
fontFamily: theme.fonts.monospace,
|
|
161
|
+
fontSize: theme.fontSizes[0],
|
|
162
|
+
letterSpacing: 0.4,
|
|
163
|
+
whiteSpace: 'nowrap',
|
|
164
|
+
overflow: 'hidden',
|
|
165
|
+
textOverflow: 'ellipsis',
|
|
166
|
+
}}
|
|
167
|
+
title={hub.label}
|
|
168
|
+
>
|
|
169
|
+
{hub.kind === 'intake' ? '▸ in' : 'out ▸'}
|
|
170
|
+
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
171
|
+
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function AggregateFrameNodeView(
|
|
177
|
+
props: NodeProps<
|
|
178
|
+
Node<{ frame: AggregateFrameNode; selected: boolean; dimmed: boolean; nested?: boolean }>
|
|
179
|
+
>,
|
|
180
|
+
) {
|
|
181
|
+
const { theme } = useTheme();
|
|
182
|
+
const { data } = props;
|
|
183
|
+
const f = data.frame;
|
|
184
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
185
|
+
// Process buckets read as sub-regions, not entities: dashed boundary
|
|
186
|
+
// chrome and a flat fill, mirroring the group frames. Externals are real
|
|
187
|
+
// systems — solid chrome like other nodes, just without a module tag.
|
|
188
|
+
// Module/ungrouped boxes keep solid node chrome.
|
|
189
|
+
const boundaryStyled = f.kind === 'process';
|
|
190
|
+
// A process box nested inside its own process boundary would otherwise
|
|
191
|
+
// repeat the boundary's name — it holds that process's module-less
|
|
192
|
+
// members, so it says so. Root-level boxes keep their key for context.
|
|
193
|
+
// External boxes never nest in a same-named frame (their boundary is the
|
|
194
|
+
// shared `external` one), so they always keep their system name.
|
|
195
|
+
const title = f.kind === 'process' && data.nested ? 'unframed' : f.label;
|
|
196
|
+
const fullTitle =
|
|
197
|
+
f.kind === 'process' && data.nested ? `${f.label} · unframed members` : f.label;
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
style={{
|
|
201
|
+
width: NODE_W,
|
|
202
|
+
height: NODE_H,
|
|
203
|
+
boxSizing: 'border-box',
|
|
204
|
+
display: 'flex',
|
|
205
|
+
flexDirection: 'column',
|
|
206
|
+
justifyContent: 'center',
|
|
207
|
+
gap: 2,
|
|
208
|
+
padding: '8px 12px',
|
|
209
|
+
borderRadius: 8,
|
|
210
|
+
// The unframed bucket paints the canvas background rather than
|
|
211
|
+
// staying transparent, so routed edges don't show through its label.
|
|
212
|
+
background: boundaryStyled
|
|
213
|
+
? theme.colors.background
|
|
214
|
+
: (theme.colors.backgroundSecondary ?? theme.colors.background),
|
|
215
|
+
border: `2px ${boundaryStyled ? 'dashed' : 'solid'} ${
|
|
216
|
+
data.selected ? theme.colors.primary : (theme.colors.border ?? '#333')
|
|
217
|
+
}`,
|
|
218
|
+
boxShadow: boundaryStyled ? 'none' : '0 1px 4px rgba(0,0,0,0.25)',
|
|
219
|
+
opacity: data.dimmed ? 0.35 : 1,
|
|
220
|
+
transition: 'opacity 120ms ease, border-color 120ms ease',
|
|
221
|
+
cursor: 'pointer',
|
|
222
|
+
fontFamily: theme.fonts.body,
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
{f.kind === 'module' && (
|
|
226
|
+
<span
|
|
227
|
+
style={{
|
|
228
|
+
fontFamily: theme.fonts.monospace,
|
|
229
|
+
fontSize: theme.fontSizes[0],
|
|
230
|
+
letterSpacing: 0.5,
|
|
231
|
+
textTransform: 'uppercase',
|
|
232
|
+
color: theme.colors.accent ?? theme.colors.info,
|
|
233
|
+
whiteSpace: 'nowrap',
|
|
234
|
+
overflow: 'hidden',
|
|
235
|
+
textOverflow: 'ellipsis',
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
module
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
<span
|
|
242
|
+
style={{
|
|
243
|
+
fontWeight: 600,
|
|
244
|
+
fontSize: theme.fontSizes[2],
|
|
245
|
+
color: theme.colors.text,
|
|
246
|
+
whiteSpace: 'nowrap',
|
|
247
|
+
overflow: 'hidden',
|
|
248
|
+
textOverflow: 'ellipsis',
|
|
249
|
+
}}
|
|
250
|
+
title={fullTitle}
|
|
251
|
+
>
|
|
252
|
+
{title}
|
|
253
|
+
</span>
|
|
254
|
+
<span
|
|
255
|
+
style={{
|
|
256
|
+
fontFamily: theme.fonts.monospace,
|
|
257
|
+
fontSize: theme.fontSizes[0],
|
|
258
|
+
color: muted,
|
|
259
|
+
whiteSpace: 'nowrap',
|
|
260
|
+
overflow: 'hidden',
|
|
261
|
+
textOverflow: 'ellipsis',
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{/* Boxes describe their contents (constructs), not their container —
|
|
265
|
+
the frame boundary already says which process/module this is. */}
|
|
266
|
+
{describeConstructBreakdown(f.members.map((m) => m.construct))}
|
|
267
|
+
{f.models.length > 0 ? ` · ${f.models.length} model${f.models.length === 1 ? '' : 's'}` : ''}
|
|
268
|
+
</span>
|
|
269
|
+
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
270
|
+
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const nodeTypes = {
|
|
276
|
+
'aggregate-frame': AggregateFrameNodeView,
|
|
277
|
+
'aggregate-frame-group': AggregateFrameGroupView,
|
|
278
|
+
'aggregate-hub': AggregateHubNodeView,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
function Inner({
|
|
282
|
+
frames,
|
|
283
|
+
edges,
|
|
284
|
+
hubs = [],
|
|
285
|
+
title,
|
|
286
|
+
onSelectFrame,
|
|
287
|
+
onOpenFile,
|
|
288
|
+
}: SubsystemAggregateGraphProps) {
|
|
289
|
+
const { theme } = useTheme();
|
|
290
|
+
const { fitView } = useReactFlow();
|
|
291
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
292
|
+
const [nodes, setNodes] = useState<Node[]>([]);
|
|
293
|
+
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
294
|
+
const [ready, setReady] = useState(false);
|
|
295
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
296
|
+
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
297
|
+
|
|
298
|
+
const layoutKey = useMemo(
|
|
299
|
+
() =>
|
|
300
|
+
[
|
|
301
|
+
...frames.map((f) => f.id).sort(),
|
|
302
|
+
...edges.map((e) => `${e.from}\0${e.to}\0${e.steps}`).sort(),
|
|
303
|
+
].join('\n'),
|
|
304
|
+
[frames, edges, hubs],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
let alive = true;
|
|
309
|
+
setReady(false);
|
|
310
|
+
setSelectedId(null);
|
|
311
|
+
// Compound process frames: each box is parented to exactly one process
|
|
312
|
+
// group (its majority process); process member-boxes to their own
|
|
313
|
+
// process; the rest stays root-level. Exclusive assignment by
|
|
314
|
+
// construction — the double-parent failure mode cannot occur here.
|
|
315
|
+
const groupIdFor = (kind: 'process' | 'external', key: string) =>
|
|
316
|
+
`frame:${kind}:${key}`;
|
|
317
|
+
const groupDefs = new Map<string, { id: string; kind: 'process' | 'external'; key: string }>();
|
|
318
|
+
for (const f of frames) {
|
|
319
|
+
if (!f.group) continue;
|
|
320
|
+
const id = groupIdFor(f.group.kind, f.group.key);
|
|
321
|
+
if (!groupDefs.has(id)) groupDefs.set(id, { id, ...f.group });
|
|
322
|
+
}
|
|
323
|
+
const rfNodes: Node[] = [
|
|
324
|
+
...frames.map((f, i) => ({
|
|
325
|
+
id: f.id,
|
|
326
|
+
type: 'aggregate-frame',
|
|
327
|
+
// Pre-ELK grid slot; replaced by ELK positions below.
|
|
328
|
+
position: { x: (i % 4) * (NODE_W + 60), y: Math.floor(i / 4) * (NODE_H + 40) },
|
|
329
|
+
width: NODE_W,
|
|
330
|
+
height: NODE_H,
|
|
331
|
+
...(f.group ? { parentId: groupIdFor(f.group.kind, f.group.key) } : {}),
|
|
332
|
+
data: { frame: f, selected: false, dimmed: false },
|
|
333
|
+
})),
|
|
334
|
+
...hubs.map((h) => ({
|
|
335
|
+
id: h.id,
|
|
336
|
+
type: 'aggregate-hub',
|
|
337
|
+
position: { x: 0, y: 0 },
|
|
338
|
+
width: HUB_W,
|
|
339
|
+
height: HUB_H,
|
|
340
|
+
parentId: groupIdFor(h.group.kind, h.group.key),
|
|
341
|
+
data: { hub: h },
|
|
342
|
+
})),
|
|
343
|
+
];
|
|
344
|
+
const groups = [...groupDefs.values()].map((g) => ({
|
|
345
|
+
id: g.id,
|
|
346
|
+
memberIds: [
|
|
347
|
+
...frames
|
|
348
|
+
.filter((f) => f.group?.kind === g.kind && f.group?.key === g.key)
|
|
349
|
+
.map((f) => f.id),
|
|
350
|
+
...hubs
|
|
351
|
+
.filter((h) => h.group.kind === g.kind && h.group.key === g.key)
|
|
352
|
+
.map((h) => h.id),
|
|
353
|
+
],
|
|
354
|
+
}));
|
|
355
|
+
const rfEdges: Edge[] = edges
|
|
356
|
+
.filter((e) => e.from !== e.to)
|
|
357
|
+
.map((e) => ({
|
|
358
|
+
id: `${e.from}\0${e.to}`,
|
|
359
|
+
source: e.from,
|
|
360
|
+
target: e.to,
|
|
361
|
+
style: {
|
|
362
|
+
stroke: edgeColor(e.mechanisms[0], muted),
|
|
363
|
+
strokeWidth: 1 + Math.min(e.steps, 6) * 0.5,
|
|
364
|
+
},
|
|
365
|
+
markerEnd: {
|
|
366
|
+
type: MarkerType.ArrowClosed,
|
|
367
|
+
color: edgeColor(e.mechanisms[0], muted),
|
|
368
|
+
width: 20,
|
|
369
|
+
height: 20,
|
|
370
|
+
},
|
|
371
|
+
data: { edge: e },
|
|
372
|
+
}));
|
|
373
|
+
void computeElkLayout(rfNodes, rfEdges, {
|
|
374
|
+
direction: 'RIGHT',
|
|
375
|
+
// Reposition (never keep the placeholder grid) and lay out compound
|
|
376
|
+
// process frames.
|
|
377
|
+
preserveNodePositions: false,
|
|
378
|
+
groups,
|
|
379
|
+
})
|
|
380
|
+
.then((result) => {
|
|
381
|
+
if (!alive) return;
|
|
382
|
+
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
383
|
+
const builtGroups = new Set(result.groupBounds.keys());
|
|
384
|
+
const shells: Node[] = [];
|
|
385
|
+
for (const [id, b] of result.groupBounds) {
|
|
386
|
+
const def = groupDefs.get(id);
|
|
387
|
+
shells.push({
|
|
388
|
+
id,
|
|
389
|
+
type: 'aggregate-frame-group',
|
|
390
|
+
position: { x: b.x, y: b.y },
|
|
391
|
+
width: Math.max(200, b.width),
|
|
392
|
+
height: Math.max(120, b.height),
|
|
393
|
+
draggable: false,
|
|
394
|
+
selectable: false,
|
|
395
|
+
data: {
|
|
396
|
+
label: def?.key ?? id,
|
|
397
|
+
kind: def?.kind ?? 'process',
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// Clear leaf parentIds that point at groups ELK dropped.
|
|
402
|
+
const leaves = rfNodes.map((n) => {
|
|
403
|
+
const p = positioned.get(n.id);
|
|
404
|
+
const node = p ? { ...n, position: { x: p.position.x, y: p.position.y } } : n;
|
|
405
|
+
const parentId = (node as { parentId?: string }).parentId;
|
|
406
|
+
if (parentId && !builtGroups.has(parentId)) {
|
|
407
|
+
const { parentId: _drop, ...rest } = node as { parentId?: string } & Node;
|
|
408
|
+
return rest as Node;
|
|
409
|
+
}
|
|
410
|
+
return node;
|
|
411
|
+
});
|
|
412
|
+
setNodes([...shells, ...leaves]);
|
|
413
|
+
setRfEdges(rfEdges);
|
|
414
|
+
setReady(true);
|
|
415
|
+
requestAnimationFrame(() => fitView({ padding: 0.15 }));
|
|
416
|
+
})
|
|
417
|
+
.catch((err) => {
|
|
418
|
+
if (!alive) return;
|
|
419
|
+
console.warn('[subsystem-aggregate-graph] ELK layout failed, using grid:', err);
|
|
420
|
+
setNodes(rfNodes);
|
|
421
|
+
setRfEdges(rfEdges);
|
|
422
|
+
setReady(true);
|
|
423
|
+
requestAnimationFrame(() => fitView({ padding: 0.15 }));
|
|
424
|
+
});
|
|
425
|
+
return () => {
|
|
426
|
+
alive = false;
|
|
427
|
+
};
|
|
428
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
429
|
+
}, [layoutKey]);
|
|
430
|
+
|
|
431
|
+
const neighborIds = useMemo(() => {
|
|
432
|
+
const focus = hoveredId ?? selectedId;
|
|
433
|
+
if (!focus) return null;
|
|
434
|
+
const set = new Set<string>([focus]);
|
|
435
|
+
for (const e of edges) {
|
|
436
|
+
if (e.from === focus) set.add(e.to);
|
|
437
|
+
if (e.to === focus) set.add(e.from);
|
|
438
|
+
}
|
|
439
|
+
return set;
|
|
440
|
+
}, [hoveredId, selectedId, edges]);
|
|
441
|
+
|
|
442
|
+
const selected = selectedId ? (frames.find((f) => f.id === selectedId) ?? null) : null;
|
|
443
|
+
|
|
444
|
+
const onNodeClick = useCallback(
|
|
445
|
+
(_e: unknown, node: Node) => {
|
|
446
|
+
const next = selectedId === node.id ? null : node.id;
|
|
447
|
+
setSelectedId(next);
|
|
448
|
+
onSelectFrame?.(next);
|
|
449
|
+
},
|
|
450
|
+
[selectedId, onSelectFrame],
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const displayNodes = useMemo(
|
|
454
|
+
() =>
|
|
455
|
+
nodes.map((n) => ({
|
|
456
|
+
...n,
|
|
457
|
+
data: {
|
|
458
|
+
...(n.data as object),
|
|
459
|
+
selected: selectedId === n.id,
|
|
460
|
+
dimmed: neighborIds ? !neighborIds.has(n.id) : false,
|
|
461
|
+
// Surviving parentId means ELK built this node's process frame —
|
|
462
|
+
// the node renders nested, so a process box drops its redundant name.
|
|
463
|
+
nested: (n as { parentId?: string }).parentId != null,
|
|
464
|
+
},
|
|
465
|
+
})),
|
|
466
|
+
[nodes, selectedId, neighborIds],
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const displayEdges = useMemo(
|
|
470
|
+
() =>
|
|
471
|
+
rfEdges.map((e) => {
|
|
472
|
+
const dimmed =
|
|
473
|
+
neighborIds != null &&
|
|
474
|
+
!(neighborIds.has(e.source) && neighborIds.has(e.target));
|
|
475
|
+
return {
|
|
476
|
+
...e,
|
|
477
|
+
style: { ...(e.style as object), opacity: dimmed ? 0.12 : 0.9 },
|
|
478
|
+
};
|
|
479
|
+
}),
|
|
480
|
+
[rfEdges, neighborIds],
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
|
|
485
|
+
<ReactFlow
|
|
486
|
+
nodes={displayNodes}
|
|
487
|
+
edges={displayEdges}
|
|
488
|
+
nodeTypes={nodeTypes}
|
|
489
|
+
onNodeClick={onNodeClick}
|
|
490
|
+
onNodeMouseEnter={(_e, node) => setHoveredId(node.id)}
|
|
491
|
+
onNodeMouseLeave={() => setHoveredId(null)}
|
|
492
|
+
onPaneClick={() => {
|
|
493
|
+
setSelectedId(null);
|
|
494
|
+
onSelectFrame?.(null);
|
|
495
|
+
}}
|
|
496
|
+
proOptions={{ hideAttribution: true }}
|
|
497
|
+
minZoom={0.2}
|
|
498
|
+
/>
|
|
499
|
+
{!ready && (
|
|
500
|
+
<div
|
|
501
|
+
style={{
|
|
502
|
+
position: 'absolute',
|
|
503
|
+
inset: 0,
|
|
504
|
+
display: 'flex',
|
|
505
|
+
alignItems: 'center',
|
|
506
|
+
justifyContent: 'center',
|
|
507
|
+
background: theme.colors.background,
|
|
508
|
+
color: muted,
|
|
509
|
+
fontFamily: theme.fonts.monospace,
|
|
510
|
+
fontSize: theme.fontSizes[0],
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
Laying out frames…
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
{title && (
|
|
517
|
+
<div
|
|
518
|
+
style={{
|
|
519
|
+
position: 'absolute',
|
|
520
|
+
top: 12,
|
|
521
|
+
left: 12,
|
|
522
|
+
fontSize: theme.fontSizes[2],
|
|
523
|
+
fontWeight: 600,
|
|
524
|
+
color: theme.colors.text,
|
|
525
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
526
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
527
|
+
borderRadius: 6,
|
|
528
|
+
padding: '4px 10px',
|
|
529
|
+
}}
|
|
530
|
+
>
|
|
531
|
+
{title}
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
{selected && (
|
|
535
|
+
<div
|
|
536
|
+
style={{
|
|
537
|
+
position: 'absolute',
|
|
538
|
+
top: 12,
|
|
539
|
+
right: 12,
|
|
540
|
+
width: 280,
|
|
541
|
+
maxHeight: 'calc(100% - 24px)',
|
|
542
|
+
overflowY: 'auto',
|
|
543
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
544
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
545
|
+
borderRadius: 8,
|
|
546
|
+
padding: '10px 12px',
|
|
547
|
+
}}
|
|
548
|
+
>
|
|
549
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
550
|
+
<span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
551
|
+
{selected.label}
|
|
552
|
+
</span>
|
|
553
|
+
<button
|
|
554
|
+
type="button"
|
|
555
|
+
onClick={() => {
|
|
556
|
+
setSelectedId(null);
|
|
557
|
+
onSelectFrame?.(null);
|
|
558
|
+
}}
|
|
559
|
+
aria-label="Clear selection"
|
|
560
|
+
style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
|
|
561
|
+
>
|
|
562
|
+
×
|
|
563
|
+
</button>
|
|
564
|
+
</div>
|
|
565
|
+
<div style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, margin: '4px 0 8px' }}>
|
|
566
|
+
{selected.kind} · {selected.members.length} member{selected.members.length === 1 ? '' : 's'}
|
|
567
|
+
{selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
|
|
568
|
+
</div>
|
|
569
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
570
|
+
{selected.members.map((m) => (
|
|
571
|
+
<button
|
|
572
|
+
key={m.alias}
|
|
573
|
+
type="button"
|
|
574
|
+
title={`${m.alias}${m.file ? ` — ${m.file}` : ''}`}
|
|
575
|
+
disabled={!m.file || !onOpenFile}
|
|
576
|
+
onClick={() => m.file && onOpenFile?.(m.file)}
|
|
577
|
+
style={{
|
|
578
|
+
border: 'none',
|
|
579
|
+
background: 'transparent',
|
|
580
|
+
color: m.file ? theme.colors.text : muted,
|
|
581
|
+
cursor: m.file && onOpenFile ? 'pointer' : 'default',
|
|
582
|
+
fontFamily: theme.fonts.monospace,
|
|
583
|
+
fontSize: theme.fontSizes[0],
|
|
584
|
+
textAlign: 'left',
|
|
585
|
+
padding: '2px 0',
|
|
586
|
+
overflow: 'hidden',
|
|
587
|
+
textOverflow: 'ellipsis',
|
|
588
|
+
whiteSpace: 'nowrap',
|
|
589
|
+
}}
|
|
590
|
+
>
|
|
591
|
+
{m.alias}
|
|
592
|
+
</button>
|
|
593
|
+
))}
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
)}
|
|
597
|
+
</div>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function SubsystemAggregateGraph(props: SubsystemAggregateGraphProps) {
|
|
602
|
+
return (
|
|
603
|
+
<ReactFlowProvider>
|
|
604
|
+
<Inner {...props} />
|
|
605
|
+
</ReactFlowProvider>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
formatPurl,
|
|
29
29
|
packageColor,
|
|
30
30
|
subsystemGraphLayoutKey,
|
|
31
|
+
describeConstructBreakdown,
|
|
31
32
|
} from './model';
|
|
32
33
|
import type { SubsystemComponent, SubsystemComponentEdge } from './model';
|
|
33
34
|
|
|
@@ -626,6 +627,52 @@ describe('subsystem graph model', () => {
|
|
|
626
627
|
expect(groups.filter((g) => g.region.kind === 'package')).toHaveLength(1);
|
|
627
628
|
});
|
|
628
629
|
|
|
630
|
+
test('mixed-process module members live only in the module frame', () => {
|
|
631
|
+
// Same file framed under two processes (composed-model vocabulary drift):
|
|
632
|
+
// the module cannot nest, so its members must not also sit directly in
|
|
633
|
+
// the process frames — ELK throws on double-parented leaves.
|
|
634
|
+
const groups = buildBoundaryLayoutGroups({
|
|
635
|
+
components: [
|
|
636
|
+
{ alias: 'a1', name: 'a1', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', module: 'src/a.ts', process: 'app/host' },
|
|
637
|
+
{ alias: 'a2', name: 'a2', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', module: 'src/a.ts', process: 'app/renderer' },
|
|
638
|
+
{ alias: 'b1', name: 'b1', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
639
|
+
],
|
|
640
|
+
});
|
|
641
|
+
const leafHits = new Map<string, string[]>();
|
|
642
|
+
for (const g of groups) {
|
|
643
|
+
for (const id of g.memberAliases) {
|
|
644
|
+
// Nested group ids legitimately appear in parents; only leaves count.
|
|
645
|
+
if (groups.some((h) => h.id === id)) continue;
|
|
646
|
+
leafHits.set(id, [...(leafHits.get(id) ?? []), g.id]);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const [leaf, owners] of leafHits) {
|
|
650
|
+
expect(`${leaf} in ${owners.length} groups: ${owners.join(',')}`).toBe(`${leaf} in 1 groups: ${owners[0]}`);
|
|
651
|
+
}
|
|
652
|
+
const mod = groups.find((g) => g.region.kind === 'module');
|
|
653
|
+
expect(mod?.memberAliases).toEqual(['a1', 'a2']);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('module-less leaf in a mixed-package process lives only in the process frame', () => {
|
|
657
|
+
const groups = buildBoundaryLayoutGroups({
|
|
658
|
+
components: [
|
|
659
|
+
{ alias: 'x', name: 'x', construct: 'function', file: 'x.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
660
|
+
{ alias: 'y', name: 'y', construct: 'function', file: 'y.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
661
|
+
{ alias: 'z', name: 'z', construct: 'function', file: 'z.ts', purl: 'pkg:github/other/lib', process: 'app/host' },
|
|
662
|
+
],
|
|
663
|
+
});
|
|
664
|
+
const leafHits = new Map<string, string[]>();
|
|
665
|
+
for (const g of groups) {
|
|
666
|
+
for (const id of g.memberAliases) {
|
|
667
|
+
if (groups.some((h) => h.id === id)) continue;
|
|
668
|
+
leafHits.set(id, [...(leafHits.get(id) ?? []), g.id]);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const [leaf, owners] of leafHits) {
|
|
672
|
+
expect(owners).toHaveLength(1);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
629
676
|
test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
|
|
630
677
|
const base = doc;
|
|
631
678
|
const withRef = {
|
|
@@ -681,6 +728,22 @@ describe('isConstructsOnlyModel', () => {
|
|
|
681
728
|
});
|
|
682
729
|
});
|
|
683
730
|
|
|
731
|
+
describe('describeConstructBreakdown', () => {
|
|
732
|
+
test('counts by construct, most common first', () => {
|
|
733
|
+
expect(describeConstructBreakdown(['function', 'class', 'function'])).toBe(
|
|
734
|
+
'2 functions · 1 class',
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('ignores blank constructs and handles empties', () => {
|
|
739
|
+
expect(describeConstructBreakdown(['', ' ', 'store'])).toBe('1 store');
|
|
740
|
+
expect(describeConstructBreakdown([])).toBe('empty');
|
|
741
|
+
expect(describeConstructBreakdown(['interface', 'type_alias', 'enum', 'custom_entity'])).toBe(
|
|
742
|
+
'1 custom_entity · 1 enum · 1 interface · +1 more',
|
|
743
|
+
);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
684
747
|
describe('module badge labels', () => {
|
|
685
748
|
test('moduleBadgeLabel keeps the full path when it fits the module width', () => {
|
|
686
749
|
expect(moduleBadgeLabel('src/main.ts', 1000)).toBe('src/main.ts');
|