@principal-ai/subsystems-react 0.25.1 → 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 +35 -0
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -0
- 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/ComposedGraph.stories.tsx +73 -0
- 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 +17 -0
- package/src/subsystem/model.ts +34 -0
|
@@ -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
|
|
|
@@ -727,6 +728,22 @@ describe('isConstructsOnlyModel', () => {
|
|
|
727
728
|
});
|
|
728
729
|
});
|
|
729
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
|
+
|
|
730
747
|
describe('module badge labels', () => {
|
|
731
748
|
test('moduleBadgeLabel keeps the full path when it fits the module width', () => {
|
|
732
749
|
expect(moduleBadgeLabel('src/main.ts', 1000)).toBe('src/main.ts');
|
package/src/subsystem/model.ts
CHANGED
|
@@ -435,6 +435,40 @@ export function deriveNameFromSymbol(
|
|
|
435
435
|
return name;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
const CONSTRUCT_PLURALS: Record<string, string> = {
|
|
439
|
+
class: 'classes',
|
|
440
|
+
function: 'functions',
|
|
441
|
+
method: 'methods',
|
|
442
|
+
interface: 'interfaces',
|
|
443
|
+
type_alias: 'type aliases',
|
|
444
|
+
enum: 'enums',
|
|
445
|
+
store: 'stores',
|
|
446
|
+
external: 'externals',
|
|
447
|
+
custom_entity: 'custom entities',
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* One-line summary of what a group of declarations contains, e.g.
|
|
452
|
+
* `6 functions · 2 classes`. Used for aggregate frame boxes, which describe
|
|
453
|
+
* their contents (constructs) rather than their container. Empty/blank
|
|
454
|
+
* constructs are ignored. At most three groups; the rest folds into `+N more`.
|
|
455
|
+
*/
|
|
456
|
+
export function describeConstructBreakdown(constructs: readonly string[]): string {
|
|
457
|
+
const counts = new Map<string, number>();
|
|
458
|
+
for (const raw of constructs) {
|
|
459
|
+
const c = (raw ?? '').trim();
|
|
460
|
+
if (!c) continue;
|
|
461
|
+
counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
462
|
+
}
|
|
463
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
464
|
+
const parts = ranked.slice(0, 3).map(([c, n]) => {
|
|
465
|
+
if (n === 1) return `1 ${c}`;
|
|
466
|
+
return `${n} ${CONSTRUCT_PLURALS[c] ?? `${c}s`}`;
|
|
467
|
+
});
|
|
468
|
+
if (ranked.length > 3) parts.push(`+${ranked.length - 3} more`);
|
|
469
|
+
return parts.join(' · ') || 'empty';
|
|
470
|
+
}
|
|
471
|
+
|
|
438
472
|
/**
|
|
439
473
|
* Human-readable purl identity — drops the `pkg:<type>/` scheme wrapper and
|
|
440
474
|
* trailing version, keeping the package / owner-repo identity:
|