@principal-ai/subsystems-react 0.52.0 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/subsystem/C4Graph.d.ts +8 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +470 -155
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +42 -1
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +75 -20
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +13 -4
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/c4.d.ts +27 -3
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +48 -0
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/model.d.ts +35 -245
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +11 -29
- package/dist/subsystem/model.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +26 -2
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +40 -8
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +4 -3
- package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
- package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
- package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +131 -0
- package/src/subsystem/C4Graph.tsx +578 -198
- package/src/subsystem/C4NodeCard.test.ts +6 -3
- package/src/subsystem/C4NodeCard.tsx +158 -49
- package/src/subsystem/IssueList.category.test.ts +39 -0
- package/src/subsystem/IssueList.tsx +14 -3
- package/src/subsystem/c4.ts +74 -3
- package/src/subsystem/model.ts +62 -305
- package/src/utils/elkLayout.ts +60 -10
|
@@ -11,17 +11,30 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
11
11
|
* `GRAPH_NAV_PROPS` chrome. Deliberately small — no measurement passes, no
|
|
12
12
|
* drawers, no trail playback.
|
|
13
13
|
*/
|
|
14
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
14
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
15
15
|
import { Handle, MarkerType, Position, ReactFlow, ReactFlowProvider, useReactFlow, useViewport, } from '@xyflow/react';
|
|
16
16
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
17
|
+
import { Box, Minimize2 } from 'lucide-react';
|
|
17
18
|
import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
18
19
|
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
|
|
19
|
-
import { protocolColor } from './c4';
|
|
20
|
-
import { C4NodeCard, NODE_H, NODE_W, nodeSize } from './C4NodeCard';
|
|
20
|
+
import { deriveC4Groups, protocolColor } from './c4';
|
|
21
|
+
import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
|
|
22
|
+
import { TechMark, technologyBrand } from './techIcons';
|
|
21
23
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
22
24
|
function C4NodeView(props) {
|
|
23
|
-
const { element, selected } = props.data;
|
|
24
|
-
|
|
25
|
+
const { element, selected, componentCount, onExpand, open, settled } = props.data;
|
|
26
|
+
const grown = open === true;
|
|
27
|
+
// The card takes its box from the React Flow node rather than from its own
|
|
28
|
+
// `nodeSize`, so on the opened container ELK's fitted size drives it — and
|
|
29
|
+
// because the element persists across the swap, the card's own width/height
|
|
30
|
+
// transition *is* the grow. No FLIP needed, and no text distortion.
|
|
31
|
+
//
|
|
32
|
+
// While grown it is taken out of flow: the node box already carries the layout
|
|
33
|
+
// (and is what sibling containers and edges are positioned against), so the
|
|
34
|
+
// card must not compete with it.
|
|
35
|
+
return (_jsx("div", { style: grown
|
|
36
|
+
? { position: 'absolute', top: 0, left: 0, width: props.width, height: props.height }
|
|
37
|
+
: undefined, children: _jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, onExpand: onExpand, width: props.width, height: props.height, showDescription: settled !== false, handles: _jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", position: Position.Left, style: { opacity: 0 } }), _jsx(Handle, { type: "source", position: Position.Right, style: { opacity: 0 } })] }) }) }));
|
|
25
38
|
}
|
|
26
39
|
/**
|
|
27
40
|
* Edge-label overlay.
|
|
@@ -98,6 +111,29 @@ function C4EdgeView({ data, markerEnd }) {
|
|
|
98
111
|
function C4GroupView(props) {
|
|
99
112
|
const { theme } = useTheme();
|
|
100
113
|
const color = props.data.color;
|
|
114
|
+
const isSystem = props.data.kind === 'system';
|
|
115
|
+
const onCollapse = props.data.onCollapse;
|
|
116
|
+
const brand = props.data.brand;
|
|
117
|
+
const { growFrom, frameRect } = props.data;
|
|
118
|
+
// FLIP the real frame: start it transformed back onto the box's rect, then
|
|
119
|
+
// release to identity so it grows into place. `transform-origin: top left`
|
|
120
|
+
// makes the scale anchor match the absolute rects.
|
|
121
|
+
const [grown, setGrown] = useState(!growFrom || !frameRect);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!growFrom || !frameRect)
|
|
124
|
+
return;
|
|
125
|
+
setGrown(false);
|
|
126
|
+
const raf = requestAnimationFrame(() => setGrown(true));
|
|
127
|
+
return () => cancelAnimationFrame(raf);
|
|
128
|
+
}, [growFrom, frameRect]);
|
|
129
|
+
const dx = growFrom && frameRect ? growFrom.x - frameRect.x : 0;
|
|
130
|
+
const dy = growFrom && frameRect ? growFrom.y - frameRect.y : 0;
|
|
131
|
+
const sx = growFrom && frameRect && frameRect.width ? growFrom.width / frameRect.width : 1;
|
|
132
|
+
const sy = growFrom && frameRect && frameRect.height ? growFrom.height / frameRect.height : 1;
|
|
133
|
+
// A container frame shows its technology mark (React/Bun/Node…) so the
|
|
134
|
+
// boundary says what it is built with, same as the node it wraps. The system
|
|
135
|
+
// frame has no single technology, so it keeps a neutral `Box`.
|
|
136
|
+
const FrameIcon = isSystem || !brand ? Box : undefined;
|
|
101
137
|
return (_jsx("div", { style: {
|
|
102
138
|
width: '100%',
|
|
103
139
|
height: '100%',
|
|
@@ -106,12 +142,18 @@ function C4GroupView(props) {
|
|
|
106
142
|
border: `2px dashed ${color}`,
|
|
107
143
|
background: 'transparent',
|
|
108
144
|
pointerEvents: 'none',
|
|
109
|
-
|
|
145
|
+
transformOrigin: 'top left',
|
|
146
|
+
transform: grown ? 'none' : `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
|
|
147
|
+
transition: grown ? `transform ${MORPH_MS}ms ease-out` : 'none',
|
|
148
|
+
}, children: _jsxs("span", { style: {
|
|
110
149
|
position: 'absolute',
|
|
111
150
|
top: 0,
|
|
112
151
|
transform: 'translateY(-50%)',
|
|
113
152
|
left: 12,
|
|
114
153
|
zIndex: 1,
|
|
154
|
+
display: 'inline-flex',
|
|
155
|
+
alignItems: 'center',
|
|
156
|
+
gap: 6,
|
|
115
157
|
fontFamily: theme.fonts.monospace,
|
|
116
158
|
fontSize: theme.fontSizes[2],
|
|
117
159
|
fontWeight: 600,
|
|
@@ -126,7 +168,24 @@ function C4GroupView(props) {
|
|
|
126
168
|
padding: '4px 10px',
|
|
127
169
|
boxSizing: 'border-box',
|
|
128
170
|
whiteSpace: 'nowrap',
|
|
129
|
-
}, children: props.data.label
|
|
171
|
+
}, children: [FrameIcon ? (_jsx(FrameIcon, { size: 13, strokeWidth: 2.25, "aria-hidden": true })) : brand ? (_jsx(TechMark, { brand: brand, size: 14 })) : null, props.data.label, onCollapse && (_jsx("button", { type: "button", onClick: (e) => {
|
|
172
|
+
e.stopPropagation();
|
|
173
|
+
onCollapse();
|
|
174
|
+
}, "aria-label": "Collapse", style: {
|
|
175
|
+
pointerEvents: 'auto',
|
|
176
|
+
display: 'inline-flex',
|
|
177
|
+
alignItems: 'center',
|
|
178
|
+
justifyContent: 'center',
|
|
179
|
+
width: 18,
|
|
180
|
+
height: 18,
|
|
181
|
+
marginLeft: 2,
|
|
182
|
+
padding: 0,
|
|
183
|
+
border: 'none',
|
|
184
|
+
background: 'transparent',
|
|
185
|
+
color,
|
|
186
|
+
cursor: 'pointer',
|
|
187
|
+
lineHeight: 1,
|
|
188
|
+
}, children: _jsx(Minimize2, { size: 12, strokeWidth: 2.5 }) }))] }) }));
|
|
130
189
|
}
|
|
131
190
|
const nodeTypes = {
|
|
132
191
|
'c4-node': C4NodeView,
|
|
@@ -135,7 +194,27 @@ const nodeTypes = {
|
|
|
135
194
|
const edgeTypes = {
|
|
136
195
|
'c4-edge': C4EdgeView,
|
|
137
196
|
};
|
|
138
|
-
|
|
197
|
+
/** Drill-down animation durations, in ms. */
|
|
198
|
+
const MORPH_MS = 420;
|
|
199
|
+
const CAMERA_MS = 520;
|
|
200
|
+
/** The container's rect in a laid-out state: its box node, or its frame bounds. */
|
|
201
|
+
function containerRect(layout, id) {
|
|
202
|
+
if (!layout)
|
|
203
|
+
return null;
|
|
204
|
+
const frame = layout.groupBounds.get(id);
|
|
205
|
+
if (frame)
|
|
206
|
+
return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
|
|
207
|
+
const node = layout.nodes.find((n) => n.id === id);
|
|
208
|
+
if (!node)
|
|
209
|
+
return null;
|
|
210
|
+
return {
|
|
211
|
+
x: node.position.x,
|
|
212
|
+
y: node.position.y,
|
|
213
|
+
width: node.width ?? 0,
|
|
214
|
+
height: node.height ?? 0,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }) {
|
|
139
218
|
const { theme } = useTheme();
|
|
140
219
|
const { fitView } = useReactFlow();
|
|
141
220
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -144,198 +223,431 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
|
|
|
144
223
|
const [ready, setReady] = useState(false);
|
|
145
224
|
const [selectedId, setSelectedId] = useState(null);
|
|
146
225
|
const [labelPositions, setLabelPositions] = useState(() => new Map());
|
|
226
|
+
/** Set by the layout effect; consumed by the `ready` effect to run the fit. */
|
|
227
|
+
const pendingFitRef = useRef(false);
|
|
228
|
+
/** Prefetched layouts, keyed by open-container id ('' = closed). */
|
|
229
|
+
const layoutCache = useRef(new Map());
|
|
230
|
+
/**
|
|
231
|
+
* False while the drill-down grow/shrink is in flight. It gates both halves of
|
|
232
|
+
* the reveal: the opened container's components stay hidden, and every card
|
|
233
|
+
* drops its description — returning only once the box settles.
|
|
234
|
+
*
|
|
235
|
+
* Driven by one timer keyed to MORPH_MS rather than `transitionend`: the grow
|
|
236
|
+
* is the card's own width/height transition *and* the system frame's
|
|
237
|
+
* transform, so a DOM event would fire twice per open and gate on the wrong
|
|
238
|
+
* property.
|
|
239
|
+
*/
|
|
240
|
+
const [settled, setSettled] = useState(true);
|
|
241
|
+
/** Set once the first layout has rendered, so only a real change animates. */
|
|
242
|
+
const paintedRef = useRef(false);
|
|
243
|
+
/** The in-flight settle timer; cancelled if the open state changes again. */
|
|
244
|
+
const settleTimer = useRef(null);
|
|
245
|
+
/** The open id rendered last, so a change can morph between the two layouts. */
|
|
246
|
+
const prevOpenRef = useRef(null);
|
|
247
|
+
// Fit the camera AFTER the new nodes are committed (when `ready` flips true),
|
|
248
|
+
// so `fitView` measures the real geometry. Fit the SYSTEM frame explicitly:
|
|
249
|
+
// on a drill-down the drawn nodes are just the opened container's contents,
|
|
250
|
+
// so fitting them would zoom to the container, not the whole system.
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (!ready || !pendingFitRef.current)
|
|
253
|
+
return;
|
|
254
|
+
pendingFitRef.current = false;
|
|
255
|
+
fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
|
|
256
|
+
}, [ready, model.system.id, fitView]);
|
|
257
|
+
/** Called once the new layout is committed, from the layout effect. */
|
|
258
|
+
const afterLayout = () => {
|
|
259
|
+
pendingFitRef.current = true;
|
|
260
|
+
};
|
|
147
261
|
const layoutKey = useMemo(() => [
|
|
148
262
|
...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
|
|
149
|
-
...model.groups.map((g) => `${g.id}\0${g.parentId ?? ''}\0${g.memberIds.length}`).sort(),
|
|
150
263
|
...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
|
|
264
|
+
`open:${model.openContainerId ?? ''}`,
|
|
151
265
|
].join('\n'), [model]);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Build the React Flow inputs for a given open state — everything ELK needs,
|
|
268
|
+
* independent of whether a layout has been run yet. Pure over `openId`, so it
|
|
269
|
+
* serves both the rendered state and the background prefetch.
|
|
270
|
+
*/
|
|
271
|
+
const buildInputs = (openId) => {
|
|
156
272
|
// Draw only the elements for this view. A container diagram shows the
|
|
157
273
|
// runtime units (containers, externals, people); components belong to the
|
|
158
|
-
// component view
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
274
|
+
// component view — or to a container that has been opened.
|
|
275
|
+
const inView = (n) => {
|
|
276
|
+
if (model.view === 'component')
|
|
277
|
+
return n.kind !== 'container';
|
|
278
|
+
if (n.kind === 'component')
|
|
279
|
+
return (n.container ?? n.parentId) === openId;
|
|
280
|
+
// A container is always drawn — it is a card either way. Opened, it grows
|
|
281
|
+
// and nests its components; closed, it is the bare box.
|
|
282
|
+
return true;
|
|
283
|
+
};
|
|
284
|
+
const childComponentCount = new Map();
|
|
285
|
+
for (const n of model.nodes) {
|
|
286
|
+
if (n.kind !== 'component')
|
|
287
|
+
continue;
|
|
288
|
+
const parent = n.container ?? n.parentId;
|
|
289
|
+
if (parent)
|
|
290
|
+
childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
|
|
291
|
+
}
|
|
164
292
|
const rfNodes = model.nodes.filter(inView).map((n, i) => {
|
|
165
293
|
const size = nodeSize(n);
|
|
294
|
+
// A component of the opened container nests under it (a React Flow child),
|
|
295
|
+
// so edges between components can be drawn later. Everything else keeps its
|
|
296
|
+
// authored parentId.
|
|
297
|
+
const nested = n.kind === 'component' && openId ? openId : undefined;
|
|
298
|
+
const parentId = nested ?? n.parentId;
|
|
166
299
|
return {
|
|
167
300
|
id: n.id,
|
|
168
301
|
type: 'c4-node',
|
|
169
|
-
// Fallback grid pitch uses the container box — the largest, so the
|
|
170
|
-
// pre-layout positions never overlap. ELK replaces them on success.
|
|
171
302
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
172
303
|
width: size.width,
|
|
173
304
|
height: size.height,
|
|
174
|
-
...(
|
|
175
|
-
|
|
305
|
+
...(parentId ? { parentId } : {}),
|
|
306
|
+
// Nested cards are separate node divs in the viewport, so they must be
|
|
307
|
+
// told to paint above the grown container they sit inside — DOM order
|
|
308
|
+
// follows the model, not the containment.
|
|
309
|
+
...(nested ? { zIndex: 2 } : {}),
|
|
310
|
+
data: {
|
|
311
|
+
element: n,
|
|
312
|
+
selected: false,
|
|
313
|
+
componentCount: childComponentCount.get(n.id) ?? 0,
|
|
314
|
+
// A container with components opens; the same affordance closes it
|
|
315
|
+
// again, so the toggle lives in one place.
|
|
316
|
+
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && onOpenContainer
|
|
317
|
+
? {
|
|
318
|
+
onExpand: () => {
|
|
319
|
+
if (n.id === openId)
|
|
320
|
+
onCloseContainer?.();
|
|
321
|
+
else
|
|
322
|
+
onOpenContainer?.(n.id);
|
|
323
|
+
},
|
|
324
|
+
open: n.id === openId,
|
|
325
|
+
}
|
|
326
|
+
: {}),
|
|
327
|
+
},
|
|
176
328
|
};
|
|
177
329
|
});
|
|
178
|
-
|
|
330
|
+
// Every frame this layout draws: the system boundary, plus a container frame per
|
|
331
|
+
// container whose components are in view (the component view leans on these).
|
|
332
|
+
// The *opened* container's frame is dropped — it is drawn as a grown card
|
|
333
|
+
// instead, as an ELK parent below.
|
|
334
|
+
const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter((g) => g.id !== openId);
|
|
335
|
+
const groups = frames.map((g) => ({
|
|
179
336
|
id: g.id,
|
|
180
337
|
memberIds: g.memberIds,
|
|
181
338
|
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
182
339
|
}));
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
340
|
+
// Opened: the container becomes a real ELK parent so it is sized to hold
|
|
341
|
+
// its components — the "layout B" the closed layout transitions to. Only in
|
|
342
|
+
// the container view: that is where a container is a card, and where the
|
|
343
|
+
// drill-down is offered.
|
|
344
|
+
const openMembers = model.view === 'container' && openId
|
|
345
|
+
? model.nodes
|
|
346
|
+
.filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
|
|
347
|
+
.map((n) => n.id)
|
|
348
|
+
: [];
|
|
349
|
+
if (openId && openMembers.length > 0) {
|
|
350
|
+
groups.push({
|
|
351
|
+
id: openId,
|
|
352
|
+
memberIds: openMembers,
|
|
353
|
+
// Never narrower than the closed card, so opening only ever grows the box.
|
|
354
|
+
minWidth: NODE_W,
|
|
355
|
+
// Room for the container's own label slot + technology row.
|
|
356
|
+
padTop: OPEN_HEADER_PAD,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Bands come from the *frames* only. The opened container is an ELK parent,
|
|
360
|
+
// not a band — treating it as one would push it into its own layer, away from
|
|
361
|
+
// the containers it belongs beside (and, the system frame being flat, drag
|
|
362
|
+
// that frame's union across the gap).
|
|
363
|
+
const frameIds = frames.map((g) => g.id);
|
|
193
364
|
const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
|
|
194
365
|
const partitionByNode = new Map(model.nodes.map((n) => {
|
|
195
366
|
if (n.kind === 'person')
|
|
196
367
|
return [n.id, 0];
|
|
197
368
|
if (n.kind === 'external-system')
|
|
198
369
|
return [n.id, frameIds.length + 1];
|
|
199
|
-
// A node inside a frame shares its frame's band; a frame-less node
|
|
200
|
-
// (a container at container view) gets its own by id order.
|
|
201
370
|
const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
|
|
202
371
|
return [n.id, 1 + band];
|
|
203
372
|
}));
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
373
|
+
// The compound parent needs its band stated explicitly — partitioning is
|
|
374
|
+
// activated on the root, and an unset parent falls to partition 0 (the
|
|
375
|
+
// people band), which would land the opened container outside the frame.
|
|
376
|
+
const openGroup = openId ? groups.find((g) => g.id === openId) : undefined;
|
|
377
|
+
if (openId && openGroup)
|
|
378
|
+
openGroup.partition = partitionByNode.get(openId) ?? 1;
|
|
379
|
+
const drawnIds = new Set(rfNodes.map((n) => n.id));
|
|
380
|
+
const rfEdges = model.edges
|
|
381
|
+
.filter((e) => drawnIds.has(e.source) && drawnIds.has(e.target))
|
|
382
|
+
.map((e) => {
|
|
207
383
|
const color = protocolColor(e.protocol);
|
|
208
384
|
return {
|
|
209
385
|
id: e.id,
|
|
210
386
|
type: 'c4-edge',
|
|
211
387
|
source: e.source,
|
|
212
388
|
target: e.target,
|
|
213
|
-
// The reserved label box and the chip must agree on size, so ELK is
|
|
214
|
-
// given the same text the chip draws (protocol first, then label). The
|
|
215
|
-
// chip itself is the C4EdgeLabels overlay, not this field.
|
|
216
389
|
label: e.protocol ?? e.label,
|
|
217
390
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
218
391
|
data: { edge: e, color, path: '' },
|
|
219
392
|
};
|
|
220
393
|
});
|
|
221
|
-
|
|
394
|
+
return { rfNodes, rfEdges, groups, partitionByNode, frames };
|
|
395
|
+
};
|
|
396
|
+
/**
|
|
397
|
+
* Run ELK for one open state and turn the result into React Flow nodes /
|
|
398
|
+
* edges. The heavy step the prefetch caches.
|
|
399
|
+
*/
|
|
400
|
+
const layoutFor = async (openId, inputs) => {
|
|
401
|
+
const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
|
|
402
|
+
// Flatten every frame *except* the opened container. The system frame is
|
|
403
|
+
// decorative and flattens (one spacing, no nested edge runs); the opened
|
|
404
|
+
// container has to be a real ELK parent so ELK sizes it to hold its nested
|
|
405
|
+
// components — that fitted box is "layout B". Both passes are precomputed,
|
|
406
|
+
// so the drill-down is a swap between two ELK results, not a reflow at click.
|
|
407
|
+
const result = await computeElkLayout(rfNodes, rfEdges, {
|
|
222
408
|
direction: 'RIGHT',
|
|
223
409
|
preserveNodePositions: false,
|
|
224
410
|
groups,
|
|
225
411
|
keepSingletonGroups: true,
|
|
226
412
|
edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
|
|
227
|
-
// The gutter that used to inflate each node box is now real spacing, so
|
|
228
|
-
// the ports still sit on the card edge.
|
|
229
413
|
nodeSpacing: gutter * 2,
|
|
230
414
|
edgeNodeSpacing: gutter,
|
|
231
|
-
// Between-layer/between-partition gap. This is what keeps the external
|
|
232
|
-
// band clear of the system frame's padding — ELK gives partitions zero
|
|
233
|
-
// default separation. It stacks on top of ELK's reserved label layer, so
|
|
234
|
-
// it is kept modest.
|
|
235
415
|
interLayerSpacing: 40,
|
|
236
|
-
|
|
237
|
-
// ELK parent. Nesting made in-frame edges inherit the frame's padding and
|
|
238
|
-
// spacing, so they ran longer than a root-level edge touching the same
|
|
239
|
-
// nodes; flattening gives every edge one spacing.
|
|
240
|
-
flatGroups: true,
|
|
416
|
+
flatGroups: openId ? new Set(groups.map((g) => g.id).filter((id) => id !== openId)) : true,
|
|
241
417
|
partitionByNode,
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
418
|
+
});
|
|
419
|
+
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
420
|
+
const labelPositions = new Map();
|
|
421
|
+
const withPaths = rfEdges.map((e) => {
|
|
422
|
+
const pts = result.edgePathPoints?.get(e.id);
|
|
423
|
+
if (!pts || pts.length === 0)
|
|
424
|
+
return e;
|
|
425
|
+
labelPositions.set(e.id, pointAlongPath(pts, 0.5));
|
|
426
|
+
return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
|
|
427
|
+
});
|
|
428
|
+
const builtGroups = new Set(result.groupBounds.keys());
|
|
429
|
+
// The opened container comes back from ELK as a parent, so it lands in
|
|
430
|
+
// `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
|
|
431
|
+
// from the shells; keep it in `groupBounds` so nested children can take
|
|
432
|
+
// their coordinates relative to it.
|
|
433
|
+
const shellBounds = new Map(result.groupBounds);
|
|
434
|
+
if (openId)
|
|
435
|
+
shellBounds.delete(openId);
|
|
436
|
+
const groupById = new Map(frames.map((g) => [g.id, g]));
|
|
437
|
+
const depthOf = (id) => {
|
|
438
|
+
let d = 0;
|
|
439
|
+
let g = groupById.get(id);
|
|
440
|
+
while (g?.parentId) {
|
|
441
|
+
d += 1;
|
|
442
|
+
g = groupById.get(g.parentId);
|
|
443
|
+
}
|
|
444
|
+
return d;
|
|
445
|
+
};
|
|
446
|
+
const shellAbs = result.groupBounds;
|
|
447
|
+
const shellOrigin = (id) => {
|
|
448
|
+
if (!id)
|
|
449
|
+
return { x: 0, y: 0 };
|
|
450
|
+
const b = shellAbs.get(id);
|
|
451
|
+
return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
|
|
452
|
+
};
|
|
453
|
+
// Only the system frame is emitted today (the drill-down's container frames
|
|
454
|
+
// became grown cards), but the mapping stays keyed off the derived group so a
|
|
455
|
+
// container frame can come back without reshaping this.
|
|
456
|
+
const shells = [...shellBounds.entries()]
|
|
457
|
+
.sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
|
|
458
|
+
.map(([id, b]) => {
|
|
459
|
+
const def = groupById.get(id);
|
|
460
|
+
const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
|
|
461
|
+
const origin = shellOrigin(parentId);
|
|
462
|
+
const element = model.nodes.find((n) => n.id === id);
|
|
463
|
+
const frameColor = def?.kind === 'system'
|
|
464
|
+
? (theme.colors.text ?? '#888')
|
|
465
|
+
: element
|
|
466
|
+
? nodeStyle(element, theme).color
|
|
467
|
+
: (theme.colors.accent ?? theme.colors.info);
|
|
468
|
+
return {
|
|
469
|
+
id,
|
|
470
|
+
type: 'c4-group',
|
|
471
|
+
position: { x: b.x - origin.x, y: b.y - origin.y },
|
|
472
|
+
width: Math.max(200, b.width),
|
|
473
|
+
height: Math.max(120, b.height),
|
|
474
|
+
draggable: false,
|
|
475
|
+
selectable: false,
|
|
476
|
+
...(parentId ? { parentId } : {}),
|
|
477
|
+
data: {
|
|
478
|
+
label: def?.label ?? id,
|
|
479
|
+
kind: def?.kind ?? 'container',
|
|
480
|
+
color: frameColor,
|
|
481
|
+
...(def?.kind !== 'system' && element
|
|
482
|
+
? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
|
|
483
|
+
: {}),
|
|
484
|
+
...(def?.kind === 'container' && openId === id && onCloseContainer
|
|
485
|
+
? { onCollapse: () => onCloseContainer?.() }
|
|
486
|
+
: {}),
|
|
487
|
+
},
|
|
270
488
|
};
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
489
|
+
});
|
|
490
|
+
const leaves = rfNodes.map((n) => {
|
|
491
|
+
const p = positioned.get(n.id);
|
|
492
|
+
const abs = p ? p.position : n.position;
|
|
493
|
+
let parentId;
|
|
494
|
+
if (n.data && typeof n.data === 'object') {
|
|
495
|
+
const modelNode = n.data.element;
|
|
496
|
+
// Only the opened container's own components nest under it — the sibling
|
|
497
|
+
// containers and out-of-boundary nodes in the same layout must not.
|
|
498
|
+
const nested = modelNode?.kind === 'component' &&
|
|
499
|
+
openId &&
|
|
500
|
+
(modelNode.container ?? modelNode.parentId) === openId
|
|
501
|
+
? openId
|
|
502
|
+
: undefined;
|
|
503
|
+
const candidate = nested ?? modelNode?.parentId;
|
|
504
|
+
if (candidate && builtGroups.has(candidate))
|
|
505
|
+
parentId = candidate;
|
|
506
|
+
}
|
|
507
|
+
const origin = shellOrigin(parentId);
|
|
508
|
+
// The opened container is an ELK parent, so its box is whatever ELK fitted
|
|
509
|
+
// to the nested components — `nodeSize` only knows the closed card.
|
|
510
|
+
const fitted = openId === n.id ? result.groupBounds.get(openId) : undefined;
|
|
511
|
+
const node = {
|
|
512
|
+
...n,
|
|
513
|
+
position: { x: abs.x - origin.x, y: abs.y - origin.y },
|
|
514
|
+
...(fitted ? { width: fitted.width, height: fitted.height } : {}),
|
|
280
515
|
};
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
516
|
+
return (parentId ? { ...node, parentId } : { ...node, parentId: undefined });
|
|
517
|
+
});
|
|
518
|
+
return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds: result.groupBounds };
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* Stamp each growing frame's span onto its shell node, so its view can FLIP
|
|
522
|
+
* from the old rect to its own. `grows` is keyed by frame id, flow-space.
|
|
523
|
+
*/
|
|
524
|
+
const injectGrow = (nodes, grows) => {
|
|
525
|
+
if (!grows || grows.size === 0)
|
|
526
|
+
return nodes;
|
|
527
|
+
return nodes.map((n) => {
|
|
528
|
+
const g = grows.get(n.id);
|
|
529
|
+
return n.type === 'c4-group' && g
|
|
530
|
+
? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
|
|
531
|
+
: n;
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
const renderLayout = (r, grows) => {
|
|
535
|
+
setNodes(injectGrow(r.nodes, grows));
|
|
536
|
+
setRfEdges(r.edges);
|
|
537
|
+
setLabelPositions(r.labelPositions);
|
|
538
|
+
setReady(true);
|
|
539
|
+
afterLayout();
|
|
540
|
+
};
|
|
541
|
+
// Render the current open state. Uses the prefetched layout when present
|
|
542
|
+
// (instant swap), else computes it now.
|
|
543
|
+
useEffect(() => {
|
|
544
|
+
let alive = true;
|
|
545
|
+
setReady(false);
|
|
546
|
+
setSelectedId(null);
|
|
547
|
+
const openId = model.openContainerId ?? null;
|
|
548
|
+
const prevOpen = prevOpenRef.current;
|
|
549
|
+
const inputs = buildInputs(openId);
|
|
550
|
+
// Only a real open-state change animates; the first paint is already at rest.
|
|
551
|
+
if (paintedRef.current && prevOpen !== openId) {
|
|
552
|
+
setSettled(false);
|
|
553
|
+
if (settleTimer.current)
|
|
554
|
+
clearTimeout(settleTimer.current);
|
|
555
|
+
settleTimer.current = setTimeout(() => setSettled(true), MORPH_MS);
|
|
556
|
+
}
|
|
557
|
+
paintedRef.current = true;
|
|
558
|
+
/**
|
|
559
|
+
* Every frame whose rect changes between the previous and new layouts, so
|
|
560
|
+
* all boundaries can grow/shrink together (container frames *and* the
|
|
561
|
+
* system frame). Empty when nothing changed (e.g. the initial paint).
|
|
562
|
+
*/
|
|
563
|
+
const growsFor = (next) => {
|
|
564
|
+
if (prevOpen === openId)
|
|
565
|
+
return null;
|
|
566
|
+
const prevLayout = layoutCache.current.get(prevOpen ?? '');
|
|
567
|
+
if (!prevLayout)
|
|
568
|
+
return null;
|
|
569
|
+
const grows = new Map();
|
|
570
|
+
const ids = new Set([...prevLayout.groupBounds.keys(), ...next.groupBounds.keys()]);
|
|
571
|
+
for (const id of ids) {
|
|
572
|
+
const fromRect = containerRect(prevLayout, id);
|
|
573
|
+
const to = containerRect(next, id);
|
|
574
|
+
if (!fromRect || !to)
|
|
575
|
+
continue;
|
|
576
|
+
if (fromRect.x === to.x &&
|
|
577
|
+
fromRect.y === to.y &&
|
|
578
|
+
fromRect.width === to.width &&
|
|
579
|
+
fromRect.height === to.height) {
|
|
580
|
+
continue;
|
|
315
581
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
582
|
+
grows.set(id, { from: fromRect, to });
|
|
583
|
+
}
|
|
584
|
+
return grows.size > 0 ? grows : null;
|
|
585
|
+
};
|
|
586
|
+
const cached = layoutCache.current.get(openId ?? '');
|
|
587
|
+
if (cached) {
|
|
588
|
+
prevOpenRef.current = openId;
|
|
589
|
+
renderLayout(cached, growsFor(cached));
|
|
590
|
+
return () => {
|
|
591
|
+
alive = false;
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
void layoutFor(openId, inputs)
|
|
595
|
+
.then((r) => {
|
|
596
|
+
if (!alive)
|
|
597
|
+
return;
|
|
598
|
+
layoutCache.current.set(openId ?? '', r);
|
|
599
|
+
const span = growsFor(r);
|
|
600
|
+
prevOpenRef.current = openId;
|
|
601
|
+
renderLayout(r, span);
|
|
324
602
|
})
|
|
325
603
|
.catch((err) => {
|
|
326
604
|
if (!alive)
|
|
327
605
|
return;
|
|
328
606
|
console.warn('[c4-graph] ELK layout failed, using grid:', err);
|
|
329
|
-
setNodes(rfNodes);
|
|
330
|
-
setRfEdges(rfEdges);
|
|
607
|
+
setNodes(inputs.rfNodes.map((n) => ({ ...n, parentId: undefined })));
|
|
608
|
+
setRfEdges(inputs.rfEdges);
|
|
331
609
|
setReady(true);
|
|
332
|
-
|
|
610
|
+
afterLayout();
|
|
333
611
|
});
|
|
334
612
|
return () => {
|
|
335
613
|
alive = false;
|
|
614
|
+
if (settleTimer.current) {
|
|
615
|
+
clearTimeout(settleTimer.current);
|
|
616
|
+
settleTimer.current = null;
|
|
617
|
+
}
|
|
336
618
|
};
|
|
337
619
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
338
620
|
}, [layoutKey]);
|
|
621
|
+
// After the first paint, prefetch the opened layout for every container, so a
|
|
622
|
+
// click swaps to a cached result instead of waiting on ELK.
|
|
623
|
+
useEffect(() => {
|
|
624
|
+
if (model.view !== 'container')
|
|
625
|
+
return;
|
|
626
|
+
let alive = true;
|
|
627
|
+
const containers = model.nodes.filter((n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id));
|
|
628
|
+
void (async () => {
|
|
629
|
+
for (const c of containers) {
|
|
630
|
+
if (!alive)
|
|
631
|
+
return;
|
|
632
|
+
if (layoutCache.current.has(c.id))
|
|
633
|
+
continue;
|
|
634
|
+
try {
|
|
635
|
+
const inputs = buildInputs(c.id);
|
|
636
|
+
const r = await layoutFor(c.id, inputs);
|
|
637
|
+
if (!alive)
|
|
638
|
+
return;
|
|
639
|
+
layoutCache.current.set(c.id, r);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Prefetch is best-effort; a miss just falls back to compute-on-click.
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
})();
|
|
646
|
+
return () => {
|
|
647
|
+
alive = false;
|
|
648
|
+
};
|
|
649
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
650
|
+
}, [layoutKey, model.view]);
|
|
339
651
|
const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
|
|
340
652
|
/**
|
|
341
653
|
* The components authored inside the selected element, if it is a container.
|
|
@@ -347,13 +659,24 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
|
|
|
347
659
|
? model.nodes.filter((n) => n.kind === 'component' &&
|
|
348
660
|
(n.container === selected.id || n.parentId === selected.id))
|
|
349
661
|
: [], [model, selected]);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
662
|
+
/** The container currently opened for drill-down, if any. */
|
|
663
|
+
const openContainerId = model.openContainerId ?? null;
|
|
664
|
+
const displayNodes = useMemo(() => nodes.map((n) => {
|
|
665
|
+
if (n.type !== 'c4-node')
|
|
666
|
+
return n;
|
|
667
|
+
const element = n.data?.element;
|
|
668
|
+
// The nested components are already positioned inside the grown card,
|
|
669
|
+
// but they only *appear* once the grow settles — otherwise they are
|
|
670
|
+
// squeezed and clipped mid-animation.
|
|
671
|
+
const nested = element?.kind === 'component' &&
|
|
672
|
+
openContainerId != null &&
|
|
673
|
+
(element.container ?? element.parentId) === openContainerId;
|
|
674
|
+
return {
|
|
675
|
+
...n,
|
|
676
|
+
...(nested ? { hidden: !settled } : {}),
|
|
677
|
+
data: { ...n.data, selected: n.id === selectedId, settled },
|
|
678
|
+
};
|
|
679
|
+
}), [nodes, selectedId, settled, openContainerId]);
|
|
357
680
|
return (_jsxs("div", { style: { position: 'relative', width: '100%', height: '100%', background: theme.colors.background }, children: [_jsx(GraphLayerStyle, {}), _jsx(ReactFlow, { nodes: displayNodes, edges: rfEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS, ...GRAPH_NAV_PROPS, onNodeClick: (_e, node) => {
|
|
358
681
|
if (node.type !== 'c4-node')
|
|
359
682
|
return;
|
|
@@ -377,21 +700,13 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
|
|
|
377
700
|
color: muted,
|
|
378
701
|
fontFamily: theme.fonts.monospace,
|
|
379
702
|
fontSize: theme.fontSizes[0],
|
|
380
|
-
}, children: "Laying out C4 view\u2026" })),
|
|
381
|
-
position: 'absolute',
|
|
382
|
-
top: 12,
|
|
383
|
-
left: 12,
|
|
384
|
-
fontSize: theme.fontSizes[2],
|
|
385
|
-
fontWeight: 600,
|
|
386
|
-
color: theme.colors.text,
|
|
387
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
388
|
-
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
389
|
-
borderRadius: 6,
|
|
390
|
-
padding: '4px 10px',
|
|
391
|
-
}, children: [title, _jsxs("span", { style: { fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, marginLeft: 8 }, children: [model.view, " view \u00B7 ", runtime, " ", model.view === 'container' ? 'containers' : 'components', " \u00B7 ", outside, " outside"] })] })), selected && (_jsxs("div", { style: {
|
|
703
|
+
}, children: "Laying out C4 view\u2026" })), selected && (_jsxs("div", { style: {
|
|
392
704
|
position: 'absolute',
|
|
393
705
|
top: 12,
|
|
394
706
|
right: 12,
|
|
707
|
+
// Above the edge-label overlay (zIndex 5), or chips paint over the
|
|
708
|
+
// panel.
|
|
709
|
+
zIndex: 10,
|
|
395
710
|
width: 280,
|
|
396
711
|
maxHeight: 'calc(100% - 24px)',
|
|
397
712
|
overflowY: 'auto',
|