@principal-ai/subsystems-react 0.53.0 → 0.53.2
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 +91 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +672 -180
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +36 -1
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +45 -10
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +14 -5
- 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/utils/elkLayout.d.ts +68 -8
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +95 -14
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +2 -1
- 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 +132 -0
- package/src/subsystem/C4Graph.groups.test.ts +217 -0
- package/src/subsystem/C4Graph.morph.test.tsx +208 -0
- package/src/subsystem/C4Graph.tsx +803 -226
- package/src/subsystem/C4NodeCard.test.ts +6 -3
- package/src/subsystem/C4NodeCard.tsx +92 -20
- package/src/subsystem/IssueList.category.test.ts +39 -0
- package/src/subsystem/IssueList.tsx +16 -5
- package/src/subsystem/c4.ts +74 -3
- package/src/utils/elkLayout.test.ts +131 -0
- package/src/utils/elkLayout.ts +133 -16
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* drawers, no trail playback.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
15
|
import {
|
|
16
16
|
Edge,
|
|
17
17
|
EdgeProps,
|
|
@@ -26,18 +26,28 @@ import {
|
|
|
26
26
|
useViewport,
|
|
27
27
|
} from '@xyflow/react';
|
|
28
28
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
29
|
-
import { Box,
|
|
30
|
-
import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
29
|
+
import { Box, Minimize2 } from 'lucide-react';
|
|
30
|
+
import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
31
|
+
import type { CompoundGroupDef, ElkLayoutResult } from '../utils/elkLayout';
|
|
31
32
|
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
|
|
32
|
-
import { protocolColor } from './c4';
|
|
33
|
-
import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
|
|
33
|
+
import { deriveC4Groups, protocolColor, type C4Group } from './c4';
|
|
34
|
+
import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
|
|
35
|
+
import { TechMark, technologyBrand } from './techIcons';
|
|
36
|
+
import type { TechBrand } from './techIcons';
|
|
34
37
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
35
38
|
import type { C4Element, C4Model } from './c4';
|
|
36
39
|
|
|
37
40
|
export interface C4GraphProps {
|
|
38
41
|
model: C4Model;
|
|
39
|
-
title?: string;
|
|
40
42
|
onSelectNode?: (id: string | null) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Drill-down. Called from the expand affordance on a container card; the host
|
|
45
|
+
* sets `openContainerId` on the model to swap the box for a frame with its
|
|
46
|
+
* components inside.
|
|
47
|
+
*/
|
|
48
|
+
onOpenContainer?: (id: string) => void;
|
|
49
|
+
/** Called from the collapse affordance inside an opened container frame. */
|
|
50
|
+
onCloseContainer?: () => void;
|
|
41
51
|
/**
|
|
42
52
|
* Breathing room around each card, in flow px, applied as ELK spacing (not by
|
|
43
53
|
* inflating the node box — that left the edge lines starting short of the
|
|
@@ -48,21 +58,72 @@ export interface C4GraphProps {
|
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
function C4NodeView(
|
|
51
|
-
props: NodeProps<
|
|
61
|
+
props: NodeProps<
|
|
62
|
+
Node<{
|
|
63
|
+
element: C4Element;
|
|
64
|
+
selected: boolean;
|
|
65
|
+
componentCount?: number;
|
|
66
|
+
onExpand?: () => void;
|
|
67
|
+
/** This container is the open one: ELK sized the box to hold its components. */
|
|
68
|
+
open?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* False while the drill-down grow/shrink is in flight. The description is
|
|
71
|
+
* hidden for the whole animation and only returns once the box settles.
|
|
72
|
+
*/
|
|
73
|
+
settled?: boolean;
|
|
74
|
+
/** FLIP: absolute flow rects — where this card was, and where it now is. */
|
|
75
|
+
growFrom?: FlowRect;
|
|
76
|
+
frameRect?: FlowRect;
|
|
77
|
+
}>
|
|
78
|
+
>,
|
|
52
79
|
) {
|
|
53
|
-
const { element, selected, componentCount } =
|
|
80
|
+
const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } =
|
|
81
|
+
props.data;
|
|
82
|
+
const grown = open === true;
|
|
83
|
+
|
|
84
|
+
// Position-only FLIP. The card resizes through its own `width`/`height`
|
|
85
|
+
// transition, so scaling here would distort the text and fight that transition.
|
|
86
|
+
// A card that only changed *size* has nothing to slide — no transform and no
|
|
87
|
+
// transition, so its resize is the card's own and nothing competes with it.
|
|
88
|
+
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
89
|
+
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
90
|
+
// The card takes its box from the React Flow node rather than from its own
|
|
91
|
+
// `nodeSize`, so on the opened container ELK's fitted size drives it — and
|
|
92
|
+
// because the element persists across the swap, the card's own width/height
|
|
93
|
+
// transition *is* the grow. No FLIP needed, and no text distortion.
|
|
94
|
+
//
|
|
95
|
+
// While grown it is taken out of flow: the node box already carries the layout
|
|
96
|
+
// (and is what sibling containers and edges are positioned against), so the
|
|
97
|
+
// card must not compete with it.
|
|
54
98
|
return (
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
99
|
+
<div
|
|
100
|
+
style={{
|
|
101
|
+
...(grown
|
|
102
|
+
? { position: 'absolute', top: 0, left: 0, width: props.width, height: props.height }
|
|
103
|
+
: {}),
|
|
104
|
+
// Position only — the card's own transition owns the size, so these two
|
|
105
|
+
// compose without either fighting the other.
|
|
106
|
+
transformOrigin: 'top left',
|
|
107
|
+
transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
|
|
108
|
+
transition: sliding && placed ? `transform ${MORPH_MS}ms ease-out` : 'none',
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<C4NodeCard
|
|
112
|
+
node={element}
|
|
113
|
+
selected={selected}
|
|
114
|
+
componentCount={componentCount ?? 0}
|
|
115
|
+
onExpand={onExpand}
|
|
116
|
+
width={props.width}
|
|
117
|
+
height={props.height}
|
|
118
|
+
showDescription={showDescriptionFor(open, settled)}
|
|
119
|
+
handles={
|
|
120
|
+
<>
|
|
121
|
+
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
122
|
+
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
|
123
|
+
</>
|
|
124
|
+
}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
66
127
|
);
|
|
67
128
|
}
|
|
68
129
|
|
|
@@ -176,23 +237,62 @@ function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?:
|
|
|
176
237
|
);
|
|
177
238
|
}
|
|
178
239
|
|
|
179
|
-
function C4GroupView(
|
|
240
|
+
function C4GroupView(
|
|
241
|
+
props: NodeProps<
|
|
242
|
+
Node<{
|
|
243
|
+
label: string;
|
|
244
|
+
kind: 'system' | 'container';
|
|
245
|
+
color: string;
|
|
246
|
+
onCollapse?: () => void;
|
|
247
|
+
brand?: TechBrand;
|
|
248
|
+
/** FLIP: absolute flow rects — where to grow from, and the frame's own. */
|
|
249
|
+
growFrom?: FlowRect;
|
|
250
|
+
frameRect?: FlowRect;
|
|
251
|
+
}>
|
|
252
|
+
>,
|
|
253
|
+
) {
|
|
180
254
|
const { theme } = useTheme();
|
|
181
255
|
const color = props.data.color;
|
|
182
256
|
const isSystem = props.data.kind === 'system';
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
257
|
+
const onCollapse = props.data.onCollapse;
|
|
258
|
+
const brand = props.data.brand;
|
|
259
|
+
const { growFrom, frameRect } = props.data;
|
|
260
|
+
|
|
261
|
+
// Position-only FLIP, same as the cards. The frame resizes through its own
|
|
262
|
+
// width/height transition below, so scaling here would fight it *and* distort
|
|
263
|
+
// what it draws: a scaled dashed border changes weight as it grows, and the
|
|
264
|
+
// label chip's border and text scale with it.
|
|
265
|
+
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
266
|
+
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
267
|
+
|
|
268
|
+
// A container frame shows its technology mark (React/Bun/Node…) so the
|
|
269
|
+
// boundary says what it is built with, same as the node it wraps. The system
|
|
270
|
+
// frame has no single technology, so it keeps a neutral `Box`.
|
|
271
|
+
const FrameIcon = isSystem || !brand ? Box : undefined;
|
|
186
272
|
return (
|
|
187
273
|
<div
|
|
188
274
|
style={{
|
|
189
|
-
|
|
190
|
-
|
|
275
|
+
// ELK's own fitted size, as numbers — the same trick the cards use. At
|
|
276
|
+
// `100%` this element inherits the React Flow node box, which snaps to
|
|
277
|
+
// each new layout, and only a scale could disguise that. Transitioning the
|
|
278
|
+
// numbers grows and shrinks the boundary for real, in both directions, and
|
|
279
|
+
// keeps the dashed border a constant weight throughout.
|
|
280
|
+
width: props.width ?? '100%',
|
|
281
|
+
height: props.height ?? '100%',
|
|
282
|
+
// Size is always transitioned; the transform only while sliding, and
|
|
283
|
+
// `transform: none` needs no transition.
|
|
284
|
+
transition: [
|
|
285
|
+
'width 420ms ease-out',
|
|
286
|
+
'height 420ms ease-out',
|
|
287
|
+
...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out`] : []),
|
|
288
|
+
].join(', '),
|
|
191
289
|
boxSizing: 'border-box',
|
|
192
290
|
borderRadius: 12,
|
|
193
291
|
border: `2px dashed ${color}`,
|
|
194
292
|
background: 'transparent',
|
|
195
293
|
pointerEvents: 'none',
|
|
294
|
+
transformOrigin: 'top left',
|
|
295
|
+
transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
|
|
196
296
|
}}
|
|
197
297
|
>
|
|
198
298
|
<span
|
|
@@ -221,8 +321,39 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
|
|
|
221
321
|
whiteSpace: 'nowrap',
|
|
222
322
|
}}
|
|
223
323
|
>
|
|
224
|
-
|
|
324
|
+
{FrameIcon ? (
|
|
325
|
+
<FrameIcon size={13} strokeWidth={2.25} aria-hidden />
|
|
326
|
+
) : brand ? (
|
|
327
|
+
<TechMark brand={brand} size={14} />
|
|
328
|
+
) : null}
|
|
225
329
|
{props.data.label}
|
|
330
|
+
{onCollapse && (
|
|
331
|
+
<button
|
|
332
|
+
type="button"
|
|
333
|
+
onClick={(e) => {
|
|
334
|
+
e.stopPropagation();
|
|
335
|
+
onCollapse();
|
|
336
|
+
}}
|
|
337
|
+
aria-label="Collapse"
|
|
338
|
+
style={{
|
|
339
|
+
pointerEvents: 'auto',
|
|
340
|
+
display: 'inline-flex',
|
|
341
|
+
alignItems: 'center',
|
|
342
|
+
justifyContent: 'center',
|
|
343
|
+
width: 18,
|
|
344
|
+
height: 18,
|
|
345
|
+
marginLeft: 2,
|
|
346
|
+
padding: 0,
|
|
347
|
+
border: 'none',
|
|
348
|
+
background: 'transparent',
|
|
349
|
+
color,
|
|
350
|
+
cursor: 'pointer',
|
|
351
|
+
lineHeight: 1,
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
<Minimize2 size={12} strokeWidth={2.5} />
|
|
355
|
+
</button>
|
|
356
|
+
)}
|
|
226
357
|
</span>
|
|
227
358
|
</div>
|
|
228
359
|
);
|
|
@@ -232,12 +363,215 @@ const nodeTypes = {
|
|
|
232
363
|
'c4-node': C4NodeView,
|
|
233
364
|
'c4-group': C4GroupView,
|
|
234
365
|
};
|
|
235
|
-
|
|
236
366
|
const edgeTypes = {
|
|
237
367
|
'c4-edge': C4EdgeView,
|
|
238
368
|
};
|
|
239
369
|
|
|
240
|
-
|
|
370
|
+
/** Drill-down animation durations, in ms. */
|
|
371
|
+
const MORPH_MS = 420;
|
|
372
|
+
const CAMERA_MS = 520;
|
|
373
|
+
/**
|
|
374
|
+
* When to re-frame the camera after a drill-down: the morph, plus a beat.
|
|
375
|
+
*
|
|
376
|
+
* The extra 60ms is not padding for looks. `fitView` reads the system frame's
|
|
377
|
+
* *measured* size, and that frame's own height transition runs on the same
|
|
378
|
+
* MORPH_MS clock — so asking on the same tick reads it mid-flight and fits to a
|
|
379
|
+
* height that never existed. The beat is for React Flow's resize observer to
|
|
380
|
+
* report the final geometry.
|
|
381
|
+
*/
|
|
382
|
+
const CAMERA_AFTER_MORPH_MS = MORPH_MS + 60;
|
|
383
|
+
|
|
384
|
+
/** A fully laid-out open state: the RF nodes/edges and label anchors. */
|
|
385
|
+
interface Rendered {
|
|
386
|
+
nodes: Node[];
|
|
387
|
+
edges: Edge[];
|
|
388
|
+
labelPositions: Map<string, { x: number; y: number }>;
|
|
389
|
+
groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Flow-space rect of a box node or a frame, for the drill-down morph. */
|
|
393
|
+
interface FlowRect { x: number; y: number; width: number; height: number }
|
|
394
|
+
|
|
395
|
+
/** A frame's growth for the drill-down morph: its flow rect before → after. */
|
|
396
|
+
interface FrameGrow { from: FlowRect; to: FlowRect }
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Whether committing a layout should also move the camera.
|
|
400
|
+
*
|
|
401
|
+
* A morph — a commit that has rects to FLIP from — must not. Every element
|
|
402
|
+
* already on screen is sliding from the rect the reader last saw it at, and that
|
|
403
|
+
* anchoring *is* the morph; a viewport move on top of it makes each card's
|
|
404
|
+
* on-screen path the sum of two independent animations, so nothing lands where
|
|
405
|
+
* the FLIP said it would. `CAMERA_MS` also outlasts `MORPH_MS`, so the camera
|
|
406
|
+
* would still be drifting for 100ms after the cards had settled — which is what
|
|
407
|
+
* reads as the cards "still moving".
|
|
408
|
+
*
|
|
409
|
+
* A commit with no `grows` is a genuine change: the first paint, a new system, a
|
|
410
|
+
* different element set, or the grid fallback after an ELK failure. There is no
|
|
411
|
+
* continuity to preserve there and the content may genuinely not be on screen, so
|
|
412
|
+
* fit.
|
|
413
|
+
*
|
|
414
|
+
* An *empty* map fits for the same reason — no element is anchoring, so there is
|
|
415
|
+
* nothing a camera move could contradict. Testing the size rather than the
|
|
416
|
+
* identity keeps that total over the whole input domain instead of leaning on a
|
|
417
|
+
* caller convention (`growsFor` happens to hand back `null` rather than an empty
|
|
418
|
+
* map, but nothing forces it to).
|
|
419
|
+
*
|
|
420
|
+
* @param grows the rects to animate from, exactly as `injectGrow` received them.
|
|
421
|
+
*/
|
|
422
|
+
export function shouldRefitCamera(grows: Map<string, FrameGrow> | null): boolean {
|
|
423
|
+
return !grows || grows.size === 0;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Whether a card shows its description line.
|
|
428
|
+
*
|
|
429
|
+
* Off for the whole morph: the card is resizing under it, so the line would slide
|
|
430
|
+
* across a moving box.
|
|
431
|
+
*
|
|
432
|
+
* And off for good once the container is grown, which is the half that is easy to
|
|
433
|
+
* get wrong. `settled` alone is not enough — the line comes back the moment the
|
|
434
|
+
* grow finishes, straight into the space the components now occupy. ELK reserves
|
|
435
|
+
* header chrome only (`padTop`), never a description, so the returning line does
|
|
436
|
+
* not push the internals down; it lands on top of the first component.
|
|
437
|
+
*/
|
|
438
|
+
export function showDescriptionFor(open: boolean | undefined, settled: boolean | undefined): boolean {
|
|
439
|
+
return settled === true && open !== true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* `groupBounds` with every entry in absolute flow coordinates.
|
|
444
|
+
*
|
|
445
|
+
* `result.groupBounds` is absolute for a root-level group and *parent-relative*
|
|
446
|
+
* for a nested one — two spaces in one map, which is the same trap as
|
|
447
|
+
* `result.nodes`. The opened container is nested (inside the system frame), and
|
|
448
|
+
* `containerRect` reads this map before the node box, so an unrebased entry would
|
|
449
|
+
* be handed to the morph offset by the frame's origin: the container would glide
|
|
450
|
+
* to a place it was never drawn.
|
|
451
|
+
*/
|
|
452
|
+
export function rebaseGroupBoundsAbsolute(
|
|
453
|
+
result: Pick<ElkLayoutResult, 'absoluteRects' | 'groupBounds'>,
|
|
454
|
+
): Map<string, { x: number; y: number; width: number; height: number }> {
|
|
455
|
+
const rebased = new Map<string, { x: number; y: number; width: number; height: number }>();
|
|
456
|
+
for (const [id, b] of result.groupBounds) {
|
|
457
|
+
// A flat frame has no node of its own, so `absoluteRects` has no entry and
|
|
458
|
+
// `absoluteBoundsOf` falls back to the entry — already absolute, since it was
|
|
459
|
+
// synthesized from absolute member bounds.
|
|
460
|
+
const abs = absoluteBoundsOf(result, id) ?? b;
|
|
461
|
+
rebased.set(id, { ...b, x: abs.x, y: abs.y });
|
|
462
|
+
}
|
|
463
|
+
return rebased;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The compound groups one open state hands to ELK: the frames it draws, and the
|
|
468
|
+
* group defs that make ELK lay them out.
|
|
469
|
+
*
|
|
470
|
+
* Exported because the nesting here is the load-bearing part and it is invisible
|
|
471
|
+
* from the rendered output. The opened container sits *inside* the system frame, so
|
|
472
|
+
* the frame is the origin its contents are laid out from — as a sibling, or as a
|
|
473
|
+
* flat union, the frame has no stable corner and the whole boundary slides as
|
|
474
|
+
* ELK re-places its members.
|
|
475
|
+
*/
|
|
476
|
+
export function c4GroupDefs(
|
|
477
|
+
model: C4Model,
|
|
478
|
+
openId: string | null,
|
|
479
|
+
): { frames: C4Group[]; groups: CompoundGroupDef[] } {
|
|
480
|
+
// Every frame this layout draws: the system boundary, plus a container frame per
|
|
481
|
+
// container whose components are in view (the component view leans on these).
|
|
482
|
+
// The *opened* container's frame is dropped — it is drawn as a grown card
|
|
483
|
+
// instead, as an ELK parent below.
|
|
484
|
+
const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter(
|
|
485
|
+
(g) => g.id !== openId,
|
|
486
|
+
);
|
|
487
|
+
const groups: CompoundGroupDef[] = frames.map((g) => ({
|
|
488
|
+
id: g.id,
|
|
489
|
+
memberIds: g.memberIds,
|
|
490
|
+
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
491
|
+
}));
|
|
492
|
+
|
|
493
|
+
// Opened: the container becomes a real ELK parent so it is sized to hold its
|
|
494
|
+
// components — the "layout B" the closed layout transitions to. Only in the
|
|
495
|
+
// container view: that is where a container is a card, and where the drill-down
|
|
496
|
+
// is offered.
|
|
497
|
+
const openMembers =
|
|
498
|
+
model.view === 'container' && openId
|
|
499
|
+
? model.nodes
|
|
500
|
+
.filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
|
|
501
|
+
.map((n) => n.id)
|
|
502
|
+
: [];
|
|
503
|
+
if (openId && openMembers.length > 0) {
|
|
504
|
+
groups.push({
|
|
505
|
+
id: openId,
|
|
506
|
+
memberIds: openMembers,
|
|
507
|
+
// INSIDE the system frame, not beside it. The boundary *is* the system, so a
|
|
508
|
+
// sibling here would draw the opened container outside the very boundary it
|
|
509
|
+
// belongs to.
|
|
510
|
+
parentId: model.system.id,
|
|
511
|
+
// Never narrower than the closed card, so opening only ever grows the box.
|
|
512
|
+
minWidth: NODE_W,
|
|
513
|
+
// Room for the container's own label slot + technology row.
|
|
514
|
+
padTop: OPEN_HEADER_PAD,
|
|
515
|
+
// Drawn as a card, laid out as a group — both are needed, and only ELK knows
|
|
516
|
+
// which one a bare member id means.
|
|
517
|
+
drawnAsCard: true,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return { frames, groups };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The container's rect in a laid-out state: its box node, or its frame bounds. */
|
|
525
|
+
function containerRect(layout: Rendered | undefined, id: string): FlowRect | null {
|
|
526
|
+
if (!layout) return null;
|
|
527
|
+
const frame = layout.groupBounds.get(id);
|
|
528
|
+
if (frame) return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
|
|
529
|
+
const node = layout.nodes.find((n) => n.id === id);
|
|
530
|
+
if (!node) return null;
|
|
531
|
+
return {
|
|
532
|
+
x: node.position.x,
|
|
533
|
+
y: node.position.y,
|
|
534
|
+
width: node.width ?? 0,
|
|
535
|
+
height: node.height ?? 0,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* FLIP between two absolute flow rects: render transformed back onto `from`,
|
|
541
|
+
* then release onto `to`.
|
|
542
|
+
*
|
|
543
|
+
* The step back **must** happen while rendering, not in an effect. An effect runs
|
|
544
|
+
* after the browser has already painted, so the element is painted at its new
|
|
545
|
+
* position for one frame and only then jumps back — a visible teleport, which is
|
|
546
|
+
* the exact thing this hook exists to remove. Adjusting state during render lets
|
|
547
|
+
* React apply the transform in the same commit, so the new position is never
|
|
548
|
+
* painted untransformed.
|
|
549
|
+
*
|
|
550
|
+
* The rects are keyed by value: `injectGrow` mints fresh objects each layout, so
|
|
551
|
+
* comparing identity would re-fire the whole morph on unrelated re-renders.
|
|
552
|
+
*/
|
|
553
|
+
function useFlip(from: FlowRect | undefined, to: FlowRect | undefined) {
|
|
554
|
+
const key = from && to ? `${from.x},${from.y},${to.x},${to.y}` : '';
|
|
555
|
+
const [lastKey, setLastKey] = useState(key);
|
|
556
|
+
const [placed, setPlaced] = useState(!key);
|
|
557
|
+
if (key !== lastKey) {
|
|
558
|
+
setLastKey(key);
|
|
559
|
+
setPlaced(false);
|
|
560
|
+
}
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
if (!key) return;
|
|
563
|
+
const raf = requestAnimationFrame(() => setPlaced(true));
|
|
564
|
+
return () => cancelAnimationFrame(raf);
|
|
565
|
+
}, [key]);
|
|
566
|
+
return {
|
|
567
|
+
flipping: key !== '',
|
|
568
|
+
placed,
|
|
569
|
+
dx: from && to ? from.x - to.x : 0,
|
|
570
|
+
dy: from && to ? from.y - to.y : 0,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
|
|
241
575
|
const { theme } = useTheme();
|
|
242
576
|
const { fitView } = useReactFlow();
|
|
243
577
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -248,31 +582,80 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
248
582
|
const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
|
|
249
583
|
() => new Map(),
|
|
250
584
|
);
|
|
585
|
+
/** Set by the layout effect; consumed by the `ready` effect to run the fit. */
|
|
586
|
+
const pendingFitRef = useRef(false);
|
|
587
|
+
/** Prefetched layouts, keyed by open-container id ('' = closed). */
|
|
588
|
+
const layoutCache = useRef(new Map<string, Rendered>());
|
|
589
|
+
/**
|
|
590
|
+
* False while the drill-down grow/shrink is in flight. It gates the whole reveal:
|
|
591
|
+
* the opened container's components stay hidden, every card drops its
|
|
592
|
+
* description, and the edges and their labels are withheld — all returning
|
|
593
|
+
* together once the box settles.
|
|
594
|
+
*
|
|
595
|
+
* Driven by one timer keyed to MORPH_MS rather than `transitionend`: the grow
|
|
596
|
+
* is the card's own width/height transition *and* the system frame's
|
|
597
|
+
* transform, so a DOM event would fire twice per open and gate on the wrong
|
|
598
|
+
* property.
|
|
599
|
+
*/
|
|
600
|
+
const [settled, setSettled] = useState(true);
|
|
601
|
+
/** Set once the first layout has rendered, so only a real change animates. */
|
|
602
|
+
const paintedRef = useRef(false);
|
|
603
|
+
/** The in-flight settle timer; cancelled if the open state changes again. */
|
|
604
|
+
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
605
|
+
/** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
|
|
606
|
+
const cameraTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
607
|
+
/** The open id rendered last, so a change can morph between the two layouts. */
|
|
608
|
+
const prevOpenRef = useRef<string | null>(null);
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Frame the whole system. Fit the SYSTEM node explicitly rather than the drawn
|
|
612
|
+
* nodes: on a drill-down those are just the opened container and its components,
|
|
613
|
+
* so fitting them would zoom to the container instead of the system.
|
|
614
|
+
*/
|
|
615
|
+
const fitSystem = useCallback(() => {
|
|
616
|
+
fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
|
|
617
|
+
}, [fitView, model.system.id]);
|
|
618
|
+
|
|
619
|
+
// Fit the camera AFTER the new nodes are committed (when `ready` flips true),
|
|
620
|
+
// so `fitView` measures the real geometry.
|
|
621
|
+
useEffect(() => {
|
|
622
|
+
if (!ready || !pendingFitRef.current) return;
|
|
623
|
+
pendingFitRef.current = false;
|
|
624
|
+
fitSystem();
|
|
625
|
+
}, [ready, fitSystem]);
|
|
626
|
+
|
|
627
|
+
/** Called once the new layout is committed, from the layout effect. */
|
|
628
|
+
const afterLayout = (grows: Map<string, FrameGrow> | null) => {
|
|
629
|
+
pendingFitRef.current = shouldRefitCamera(grows);
|
|
630
|
+
};
|
|
251
631
|
|
|
252
632
|
const layoutKey = useMemo(
|
|
253
633
|
() =>
|
|
254
634
|
[
|
|
255
635
|
...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
|
|
256
|
-
...model.groups.map((g) => `${g.id}\0${g.parentId ?? ''}\0${g.memberIds.length}`).sort(),
|
|
257
636
|
...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
|
|
637
|
+
`open:${model.openContainerId ?? ''}`,
|
|
258
638
|
].join('\n'),
|
|
259
639
|
[model],
|
|
260
640
|
);
|
|
261
641
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
642
|
+
/**
|
|
643
|
+
* Build the React Flow inputs for a given open state — everything ELK needs,
|
|
644
|
+
* independent of whether a layout has been run yet. Pure over `openId`, so it
|
|
645
|
+
* serves both the rendered state and the background prefetch.
|
|
646
|
+
*/
|
|
647
|
+
const buildInputs = (openId: string | null) => {
|
|
267
648
|
// Draw only the elements for this view. A container diagram shows the
|
|
268
649
|
// runtime units (containers, externals, people); components belong to the
|
|
269
|
-
// component view
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
650
|
+
// component view — or to a container that has been opened.
|
|
651
|
+
const inView = (n: C4Element): boolean => {
|
|
652
|
+
if (model.view === 'component') return n.kind !== 'container';
|
|
653
|
+
if (n.kind === 'component') return (n.container ?? n.parentId) === openId;
|
|
654
|
+
// A container is always drawn — it is a card either way. Opened, it grows
|
|
655
|
+
// and nests its components; closed, it is the bare box.
|
|
656
|
+
return true;
|
|
657
|
+
};
|
|
273
658
|
|
|
274
|
-
// How many components each container groups — computed once here because
|
|
275
|
-
// the card only receives its own element, not the model.
|
|
276
659
|
const childComponentCount = new Map<string, number>();
|
|
277
660
|
for (const n of model.nodes) {
|
|
278
661
|
if (n.kind !== 'component') continue;
|
|
@@ -280,200 +663,397 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
280
663
|
if (parent) childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
|
|
281
664
|
}
|
|
282
665
|
|
|
283
|
-
// Layout against the true card size, so ELK's ports sit on the card's
|
|
284
|
-
// border — an inflated box leaves the line starting short of the card edge.
|
|
285
|
-
// The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
|
|
286
666
|
const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
|
|
287
667
|
const size = nodeSize(n);
|
|
668
|
+
// A component of the opened container nests under it (a React Flow child),
|
|
669
|
+
// so edges between components can be drawn later. Everything else keeps its
|
|
670
|
+
// authored parentId.
|
|
671
|
+
const nested = n.kind === 'component' && openId ? openId : undefined;
|
|
672
|
+
const parentId = nested ?? n.parentId;
|
|
288
673
|
return {
|
|
289
674
|
id: n.id,
|
|
290
675
|
type: 'c4-node',
|
|
291
|
-
// Fallback grid pitch uses the container box — the largest, so the
|
|
292
|
-
// pre-layout positions never overlap. ELK replaces them on success.
|
|
293
676
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
294
677
|
width: size.width,
|
|
295
678
|
height: size.height,
|
|
296
|
-
...(
|
|
297
|
-
|
|
679
|
+
...(parentId ? { parentId } : {}),
|
|
680
|
+
// Nested cards are separate node divs in the viewport, so they must be
|
|
681
|
+
// told to paint above the grown container they sit inside — DOM order
|
|
682
|
+
// follows the model, not the containment.
|
|
683
|
+
...(nested ? { zIndex: 2 } : {}),
|
|
684
|
+
data: {
|
|
685
|
+
element: n,
|
|
686
|
+
selected: false,
|
|
687
|
+
componentCount: childComponentCount.get(n.id) ?? 0,
|
|
688
|
+
// A container with components opens; the same affordance closes it
|
|
689
|
+
// again, so the toggle lives in one place.
|
|
690
|
+
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && onOpenContainer
|
|
691
|
+
? {
|
|
692
|
+
onExpand: () => {
|
|
693
|
+
if (n.id === openId) onCloseContainer?.();
|
|
694
|
+
else onOpenContainer?.(n.id);
|
|
695
|
+
},
|
|
696
|
+
open: n.id === openId,
|
|
697
|
+
}
|
|
698
|
+
: {}),
|
|
699
|
+
},
|
|
298
700
|
};
|
|
299
701
|
});
|
|
300
702
|
|
|
301
|
-
const groups = model
|
|
302
|
-
id: g.id,
|
|
303
|
-
memberIds: g.memberIds,
|
|
304
|
-
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
305
|
-
}));
|
|
703
|
+
const { frames, groups } = c4GroupDefs(model, openId);
|
|
306
704
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
// boxes then overlap. A partition per frame keeps each frame's members
|
|
312
|
-
// contiguous; people lead, externals trail.
|
|
313
|
-
//
|
|
314
|
-
// Order (ascending = earlier in the layout direction): people, then each
|
|
315
|
-
// frame in model order, then external systems.
|
|
316
|
-
const frameIds = model.groups.map((g) => g.id);
|
|
705
|
+
// Bands come from the *frames* only. The opened container is an ELK parent,
|
|
706
|
+
// not a band — treating it as one would push it into its own layer, away from
|
|
707
|
+
// the containers it belongs beside.
|
|
708
|
+
const frameIds = frames.map((g) => g.id);
|
|
317
709
|
const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
|
|
318
710
|
const partitionByNode = new Map<string, number>(
|
|
319
711
|
model.nodes.map((n) => {
|
|
320
712
|
if (n.kind === 'person') return [n.id, 0];
|
|
321
713
|
if (n.kind === 'external-system') return [n.id, frameIds.length + 1];
|
|
322
|
-
// A node inside a frame shares its frame's band; a frame-less node
|
|
323
|
-
// (a container at container view) gets its own by id order.
|
|
324
714
|
const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
|
|
325
715
|
return [n.id, 1 + band];
|
|
326
716
|
}),
|
|
327
717
|
);
|
|
328
718
|
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
};
|
|
345
|
-
});
|
|
719
|
+
const drawnIds = new Set(rfNodes.map((n) => n.id));
|
|
720
|
+
const rfEdges: Edge[] = model.edges
|
|
721
|
+
.filter((e) => drawnIds.has(e.source) && drawnIds.has(e.target))
|
|
722
|
+
.map((e) => {
|
|
723
|
+
const color = protocolColor(e.protocol);
|
|
724
|
+
return {
|
|
725
|
+
id: e.id,
|
|
726
|
+
type: 'c4-edge',
|
|
727
|
+
source: e.source,
|
|
728
|
+
target: e.target,
|
|
729
|
+
label: e.protocol ?? e.label,
|
|
730
|
+
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
731
|
+
data: { edge: e, color, path: '' },
|
|
732
|
+
};
|
|
733
|
+
});
|
|
346
734
|
|
|
347
|
-
|
|
735
|
+
return { rfNodes, rfEdges, groups, partitionByNode, frames };
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Run ELK for one open state and turn the result into React Flow nodes /
|
|
740
|
+
* edges. The heavy step the prefetch caches.
|
|
741
|
+
*/
|
|
742
|
+
const layoutFor = async (
|
|
743
|
+
openId: string | null,
|
|
744
|
+
inputs: ReturnType<typeof buildInputs>,
|
|
745
|
+
): Promise<Rendered> => {
|
|
746
|
+
const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
|
|
747
|
+
// The system boundary is a REAL ELK parent: its containers lay out *inside* it,
|
|
748
|
+
// so ELK owns both the frame's origin and its size. That is what holds the
|
|
749
|
+
// boundary's top-left corner steady between the two layouts, which is the whole
|
|
750
|
+
// point of the drill-down. Measured, not assumed — with the frame as a parent,
|
|
751
|
+
// ELK holds the frame at (12,12) and the opened container at (12,202) across
|
|
752
|
+
// both layouts, the container growing 150 -> 556 *downward* while its siblings
|
|
753
|
+
// reflow around it.
|
|
754
|
+
//
|
|
755
|
+
// Synthesized as a flat union instead, the boundary had no position of its own:
|
|
756
|
+
// it inherited its members' top edge, so it slid 206px the moment ELK re-placed
|
|
757
|
+
// one of them, and the FLIP dutifully animated the slide. A union means we own
|
|
758
|
+
// the boundary's geometry; a parent means ELK does.
|
|
759
|
+
//
|
|
760
|
+
// The component view keeps every frame flat. Its container frames nest *inside*
|
|
761
|
+
// the system frame, and flattening a group whose parent is a real group would
|
|
762
|
+
// leave that parent holding a reference to a group ELK never sees.
|
|
763
|
+
const result = await computeElkLayout(rfNodes, rfEdges, {
|
|
348
764
|
direction: 'RIGHT',
|
|
349
765
|
preserveNodePositions: false,
|
|
350
766
|
groups,
|
|
351
767
|
keepSingletonGroups: true,
|
|
352
768
|
edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
|
|
353
|
-
// The gutter that used to inflate each node box is now real spacing, so
|
|
354
|
-
// the ports still sit on the card edge.
|
|
355
769
|
nodeSpacing: gutter * 2,
|
|
356
770
|
edgeNodeSpacing: gutter,
|
|
357
|
-
// Between-layer/between-partition gap. This is what keeps the external
|
|
358
|
-
// band clear of the system frame's padding — ELK gives partitions zero
|
|
359
|
-
// default separation. It stacks on top of ELK's reserved label layer, so
|
|
360
|
-
// it is kept modest.
|
|
361
771
|
interLayerSpacing: 40,
|
|
362
|
-
|
|
363
|
-
// ELK parent. Nesting made in-frame edges inherit the frame's padding and
|
|
364
|
-
// spacing, so they ran longer than a root-level edge touching the same
|
|
365
|
-
// nodes; flattening gives every edge one spacing.
|
|
366
|
-
flatGroups: true,
|
|
772
|
+
flatGroups: model.view === 'component' ? true : new Set<string>(),
|
|
367
773
|
partitionByNode,
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
777
|
+
const labelPositions = new Map<string, { x: number; y: number }>();
|
|
778
|
+
const withPaths = rfEdges.map((e) => {
|
|
779
|
+
const pts = result.edgePathPoints?.get(e.id);
|
|
780
|
+
if (!pts || pts.length === 0) return e;
|
|
781
|
+
labelPositions.set(e.id, pointAlongPath(pts, 0.5));
|
|
782
|
+
return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// `result.groupBounds` mixes coordinate spaces; everything downstream here
|
|
786
|
+
// reads it as absolute, so normalize it once at the boundary.
|
|
787
|
+
const groupBounds = rebaseGroupBoundsAbsolute(result);
|
|
788
|
+
const builtGroups = new Set(groupBounds.keys());
|
|
789
|
+
// The opened container comes back from ELK as a parent, so it lands in
|
|
790
|
+
// `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
|
|
791
|
+
// from the shells; keep it in `groupBounds` so nested children can take
|
|
792
|
+
// their coordinates relative to it.
|
|
793
|
+
const shellBounds = new Map(groupBounds);
|
|
794
|
+
if (openId) shellBounds.delete(openId);
|
|
795
|
+
|
|
796
|
+
const groupById = new Map(frames.map((g) => [g.id, g]));
|
|
797
|
+
const depthOf = (id: string): number => {
|
|
798
|
+
let d = 0;
|
|
799
|
+
let g = groupById.get(id);
|
|
800
|
+
while (g?.parentId) {
|
|
801
|
+
d += 1;
|
|
802
|
+
g = groupById.get(g.parentId);
|
|
803
|
+
}
|
|
804
|
+
return d;
|
|
805
|
+
};
|
|
396
806
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
807
|
+
// Node positions must live in the same space as the edges and the frames:
|
|
808
|
+
// absolute. `flowPositionOf` does the one conversion (and the shells below use
|
|
809
|
+
// the same absolute origin), so nodes, edges and frames cannot disagree.
|
|
810
|
+
const absBoundsOf = (id: string) => absoluteBoundsOf(result, id);
|
|
811
|
+
const parentOriginOf = (id: string | undefined): { x: number; y: number } => {
|
|
812
|
+
if (!id) return { x: 0, y: 0 };
|
|
813
|
+
const r = absBoundsOf(id);
|
|
814
|
+
return r ? { x: r.x, y: r.y } : { x: 0, y: 0 };
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// Only the system frame is emitted today (the drill-down's container frames
|
|
818
|
+
// became grown cards), but the mapping stays keyed off the derived group so a
|
|
819
|
+
// container frame can come back without reshaping this.
|
|
820
|
+
const shells: Node[] = [...shellBounds.keys()]
|
|
821
|
+
.sort((a, b) => depthOf(a) - depthOf(b))
|
|
822
|
+
.map((id) => {
|
|
823
|
+
const def = groupById.get(id);
|
|
824
|
+
const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
|
|
825
|
+
const origin = parentOriginOf(parentId);
|
|
826
|
+
const b = absBoundsOf(id) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
827
|
+
const element = model.nodes.find((n) => n.id === id);
|
|
828
|
+
const frameColor =
|
|
829
|
+
def?.kind === 'system'
|
|
830
|
+
? (theme.colors.text ?? '#888')
|
|
831
|
+
: element
|
|
832
|
+
? nodeStyle(element, theme).color
|
|
833
|
+
: (theme.colors.accent ?? theme.colors.info);
|
|
834
|
+
return {
|
|
835
|
+
id,
|
|
836
|
+
type: 'c4-group',
|
|
837
|
+
position: { x: b.x - origin.x, y: b.y - origin.y },
|
|
838
|
+
width: Math.max(200, b.width),
|
|
839
|
+
height: Math.max(120, b.height),
|
|
840
|
+
draggable: false,
|
|
841
|
+
selectable: false,
|
|
842
|
+
...(parentId ? { parentId } : {}),
|
|
843
|
+
data: {
|
|
844
|
+
label: def?.label ?? id,
|
|
845
|
+
kind: def?.kind ?? 'container',
|
|
846
|
+
color: frameColor,
|
|
847
|
+
...(def?.kind !== 'system' && element
|
|
848
|
+
? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
|
|
849
|
+
: {}),
|
|
850
|
+
...(def?.kind === 'container' && openId === id && onCloseContainer
|
|
851
|
+
? { onCollapse: () => onCloseContainer?.() }
|
|
852
|
+
: {}),
|
|
853
|
+
},
|
|
405
854
|
};
|
|
855
|
+
});
|
|
406
856
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
857
|
+
const leaves = rfNodes.map((n) => {
|
|
858
|
+
let parentId: string | undefined;
|
|
859
|
+
if (n.data && typeof n.data === 'object') {
|
|
860
|
+
const modelNode = (n.data as { element?: C4Element }).element;
|
|
861
|
+
// Only the opened container's own components nest under it — the sibling
|
|
862
|
+
// containers and out-of-boundary nodes in the same layout must not.
|
|
863
|
+
const nested =
|
|
864
|
+
modelNode?.kind === 'component' &&
|
|
865
|
+
openId &&
|
|
866
|
+
(modelNode.container ?? modelNode.parentId) === openId
|
|
867
|
+
? openId
|
|
868
|
+
: undefined;
|
|
869
|
+
const candidate = nested ?? modelNode?.parentId;
|
|
870
|
+
if (candidate && builtGroups.has(candidate)) parentId = candidate;
|
|
871
|
+
}
|
|
872
|
+
// `flowPositionOf` covers every node ELK placed; the fallback is for the
|
|
873
|
+
// pathological case of a node ELK did not return at all.
|
|
874
|
+
const abs =
|
|
875
|
+
flowPositionOf(result, n.id, parentId) ??
|
|
876
|
+
positioned.get(n.id)?.position ??
|
|
877
|
+
n.position;
|
|
878
|
+
// The opened container is an ELK parent, so its box is whatever ELK fitted
|
|
879
|
+
// to the nested components — `nodeSize` only knows the closed card.
|
|
880
|
+
const fitted = openId === n.id ? groupBounds.get(openId) : undefined;
|
|
881
|
+
const node = {
|
|
882
|
+
...n,
|
|
883
|
+
position: { x: abs.x, y: abs.y },
|
|
884
|
+
...(fitted ? { width: fitted.width, height: fitted.height } : {}),
|
|
885
|
+
};
|
|
886
|
+
return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// `groupBounds`, not `result.groupBounds`: the rebased one. `Rendered` readers
|
|
890
|
+
// all assume absolute.
|
|
891
|
+
return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds };
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Stamp each growing element's span onto its node, so its view can FLIP from the
|
|
896
|
+
* old rect to its own. `grows` is keyed by element id, flow-space.
|
|
897
|
+
*/
|
|
898
|
+
const injectGrow = (nodes: Node[], grows: Map<string, FrameGrow> | null): Node[] => {
|
|
899
|
+
if (!grows || grows.size === 0) return nodes;
|
|
900
|
+
return nodes.map((n) => {
|
|
901
|
+
const g = grows.get(n.id);
|
|
902
|
+
// A child is hidden until the morph settles, and its absolute position also
|
|
903
|
+
// moves with its parent — one transform on the parent covers both.
|
|
904
|
+
if (!g || n.parentId) return n;
|
|
905
|
+
return n.type === 'c4-group' || n.type === 'c4-node'
|
|
906
|
+
? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
|
|
907
|
+
: n;
|
|
908
|
+
});
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
const renderLayout = (r: Rendered, grows: Map<string, FrameGrow> | null) => {
|
|
912
|
+
setNodes(injectGrow(r.nodes, grows));
|
|
913
|
+
setRfEdges(r.edges);
|
|
914
|
+
setLabelPositions(r.labelPositions);
|
|
915
|
+
setReady(true);
|
|
916
|
+
afterLayout(grows);
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
// Render the current open state. Uses the prefetched layout when present
|
|
920
|
+
// (instant swap), else computes it now.
|
|
921
|
+
useEffect(() => {
|
|
922
|
+
let alive = true;
|
|
923
|
+
setReady(false);
|
|
924
|
+
setSelectedId(null);
|
|
925
|
+
const openId = model.openContainerId ?? null;
|
|
926
|
+
const prevOpen = prevOpenRef.current;
|
|
927
|
+
const inputs = buildInputs(openId);
|
|
928
|
+
|
|
929
|
+
// Only a real open-state change animates; the first paint is already at rest.
|
|
930
|
+
if (paintedRef.current && prevOpen !== openId) {
|
|
931
|
+
setSettled(false);
|
|
932
|
+
if (settleTimer.current) clearTimeout(settleTimer.current);
|
|
933
|
+
settleTimer.current = setTimeout(() => setSettled(true), MORPH_MS);
|
|
934
|
+
// Re-frame once the grow has landed, not while it is running. The drill-down
|
|
935
|
+
// more than doubles the boundary, so the extent the camera was fitted to no
|
|
936
|
+
// longer contains the content — but moving *during* the morph compounds with
|
|
937
|
+
// the FLIP glide, and every card then travels the sum of two animations
|
|
938
|
+
// instead of the one the FLIP specified (see `shouldRefitCamera`). Waiting
|
|
939
|
+
// out the morph is the honest way to get both.
|
|
940
|
+
if (cameraTimer.current) clearTimeout(cameraTimer.current);
|
|
941
|
+
cameraTimer.current = setTimeout(fitSystem, CAMERA_AFTER_MORPH_MS);
|
|
942
|
+
}
|
|
943
|
+
paintedRef.current = true;
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Every element whose rect changes between the previous and new layouts, so
|
|
947
|
+
* boundaries *and* cards grow/shrink and slide together. Empty when nothing
|
|
948
|
+
* changed (e.g. the initial paint).
|
|
949
|
+
*
|
|
950
|
+
* Frames alone are not enough: ELK re-lays out the whole diagram, so every
|
|
951
|
+
* root card moves too. Without a card in this set it keeps its new slot the
|
|
952
|
+
* instant the layout swaps — a teleport, which is the most visible part of a
|
|
953
|
+
* bad morph. Child nodes are excluded: they are hidden until the box settles,
|
|
954
|
+
* so there is nothing to animate, and a transform on them would only fight
|
|
955
|
+
* the parent's.
|
|
956
|
+
*/
|
|
957
|
+
const growsFor = (next: Rendered): Map<string, FrameGrow> | null => {
|
|
958
|
+
if (prevOpen === openId) return null;
|
|
959
|
+
const prevLayout = layoutCache.current.get(prevOpen ?? '');
|
|
960
|
+
if (!prevLayout) return null;
|
|
961
|
+
const grows = new Map<string, FrameGrow>();
|
|
962
|
+
const rootCardIds = (l: Rendered) =>
|
|
963
|
+
l.nodes.filter((n) => !n.parentId).map((n) => n.id);
|
|
964
|
+
const ids = new Set([
|
|
965
|
+
...prevLayout.groupBounds.keys(),
|
|
966
|
+
...next.groupBounds.keys(),
|
|
967
|
+
...rootCardIds(prevLayout),
|
|
968
|
+
...rootCardIds(next),
|
|
969
|
+
]);
|
|
970
|
+
for (const id of ids) {
|
|
971
|
+
const fromRect = containerRect(prevLayout, id);
|
|
972
|
+
const to = containerRect(next, id);
|
|
973
|
+
if (!fromRect || !to) continue;
|
|
974
|
+
if (
|
|
975
|
+
fromRect.x === to.x &&
|
|
976
|
+
fromRect.y === to.y &&
|
|
977
|
+
fromRect.width === to.width &&
|
|
978
|
+
fromRect.height === to.height
|
|
979
|
+
) {
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
grows.set(id, { from: fromRect, to });
|
|
983
|
+
}
|
|
984
|
+
return grows.size > 0 ? grows : null;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const cached = layoutCache.current.get(openId ?? '');
|
|
988
|
+
if (cached) {
|
|
989
|
+
prevOpenRef.current = openId;
|
|
990
|
+
renderLayout(cached, growsFor(cached));
|
|
991
|
+
return () => {
|
|
992
|
+
alive = false;
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
void layoutFor(openId, inputs)
|
|
996
|
+
.then((r) => {
|
|
997
|
+
if (!alive) return;
|
|
998
|
+
layoutCache.current.set(openId ?? '', r);
|
|
999
|
+
const span = growsFor(r);
|
|
1000
|
+
prevOpenRef.current = openId;
|
|
1001
|
+
renderLayout(r, span);
|
|
461
1002
|
})
|
|
462
1003
|
.catch((err) => {
|
|
463
1004
|
if (!alive) return;
|
|
464
1005
|
console.warn('[c4-graph] ELK layout failed, using grid:', err);
|
|
465
|
-
setNodes(rfNodes);
|
|
466
|
-
setRfEdges(rfEdges);
|
|
1006
|
+
setNodes(inputs.rfNodes.map((n) => ({ ...n, parentId: undefined })));
|
|
1007
|
+
setRfEdges(inputs.rfEdges);
|
|
467
1008
|
setReady(true);
|
|
468
|
-
|
|
1009
|
+
// A grid's geometry is arbitrary and nothing FLIPped from the previous
|
|
1010
|
+
// layout, so this commit genuinely needs a fit.
|
|
1011
|
+
afterLayout(null);
|
|
469
1012
|
});
|
|
470
|
-
|
|
471
1013
|
return () => {
|
|
472
1014
|
alive = false;
|
|
1015
|
+
if (settleTimer.current) {
|
|
1016
|
+
clearTimeout(settleTimer.current);
|
|
1017
|
+
settleTimer.current = null;
|
|
1018
|
+
}
|
|
1019
|
+
// Same for the re-frame: it outlives the effect that armed it, and firing
|
|
1020
|
+
// into an unmounted React Flow instance is a wasted camera move at best.
|
|
1021
|
+
if (cameraTimer.current) {
|
|
1022
|
+
clearTimeout(cameraTimer.current);
|
|
1023
|
+
cameraTimer.current = null;
|
|
1024
|
+
}
|
|
473
1025
|
};
|
|
474
1026
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
475
1027
|
}, [layoutKey]);
|
|
476
1028
|
|
|
1029
|
+
// After the first paint, prefetch the opened layout for every container, so a
|
|
1030
|
+
// click swaps to a cached result instead of waiting on ELK.
|
|
1031
|
+
useEffect(() => {
|
|
1032
|
+
if (model.view !== 'container') return;
|
|
1033
|
+
let alive = true;
|
|
1034
|
+
const containers = model.nodes.filter(
|
|
1035
|
+
(n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id),
|
|
1036
|
+
);
|
|
1037
|
+
void (async () => {
|
|
1038
|
+
for (const c of containers) {
|
|
1039
|
+
if (!alive) return;
|
|
1040
|
+
if (layoutCache.current.has(c.id)) continue;
|
|
1041
|
+
try {
|
|
1042
|
+
const inputs = buildInputs(c.id);
|
|
1043
|
+
const r = await layoutFor(c.id, inputs);
|
|
1044
|
+
if (!alive) return;
|
|
1045
|
+
layoutCache.current.set(c.id, r);
|
|
1046
|
+
} catch {
|
|
1047
|
+
// Prefetch is best-effort; a miss just falls back to compute-on-click.
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
})();
|
|
1051
|
+
return () => {
|
|
1052
|
+
alive = false;
|
|
1053
|
+
};
|
|
1054
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1055
|
+
}, [layoutKey, model.view]);
|
|
1056
|
+
|
|
477
1057
|
const selected = useMemo(
|
|
478
1058
|
() => model.nodes.find((n) => n.id === selectedId) ?? null,
|
|
479
1059
|
[model, selectedId],
|
|
@@ -497,31 +1077,47 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
497
1077
|
[model, selected],
|
|
498
1078
|
);
|
|
499
1079
|
|
|
1080
|
+
/** The container currently opened for drill-down, if any. */
|
|
1081
|
+
const openContainerId = model.openContainerId ?? null;
|
|
1082
|
+
|
|
500
1083
|
const displayNodes = useMemo(
|
|
501
1084
|
() =>
|
|
502
|
-
nodes.map((n) =>
|
|
503
|
-
n.type
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1085
|
+
nodes.map((n) => {
|
|
1086
|
+
if (n.type !== 'c4-node') return n;
|
|
1087
|
+
const element = (n.data as { element?: C4Element } | undefined)?.element;
|
|
1088
|
+
// The nested components are already positioned inside the grown card,
|
|
1089
|
+
// but they only *appear* once the grow settles — otherwise they are
|
|
1090
|
+
// squeezed and clipped mid-animation.
|
|
1091
|
+
const nested =
|
|
1092
|
+
element?.kind === 'component' &&
|
|
1093
|
+
openContainerId != null &&
|
|
1094
|
+
(element.container ?? element.parentId) === openContainerId;
|
|
1095
|
+
return {
|
|
1096
|
+
...n,
|
|
1097
|
+
...(nested ? { hidden: !settled } : {}),
|
|
1098
|
+
data: { ...n.data, selected: n.id === selectedId, settled },
|
|
1099
|
+
};
|
|
1100
|
+
}),
|
|
1101
|
+
[nodes, selectedId, settled, openContainerId],
|
|
508
1102
|
);
|
|
509
1103
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1104
|
+
/**
|
|
1105
|
+
* An edge's polyline is routed for the *settled* layout, so while the cards are
|
|
1106
|
+
* gliding between the two slots the old lines would visibly point at the wrong
|
|
1107
|
+
* boxes. Hiding them for the morph is honest and cheap; interpolating the
|
|
1108
|
+
* polylines to match is not.
|
|
1109
|
+
*/
|
|
1110
|
+
const displayEdges = useMemo(
|
|
1111
|
+
() => (settled ? rfEdges : rfEdges.map((e) => ({ ...e, hidden: true }))),
|
|
1112
|
+
[rfEdges, settled],
|
|
1113
|
+
);
|
|
518
1114
|
|
|
519
1115
|
return (
|
|
520
1116
|
<div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
|
|
521
1117
|
<GraphLayerStyle />
|
|
522
1118
|
<ReactFlow
|
|
523
1119
|
nodes={displayNodes}
|
|
524
|
-
edges={
|
|
1120
|
+
edges={displayEdges}
|
|
525
1121
|
nodeTypes={nodeTypes}
|
|
526
1122
|
edgeTypes={edgeTypes}
|
|
527
1123
|
className={GRAPH_CANVAS_CLASS}
|
|
@@ -544,7 +1140,11 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
544
1140
|
>
|
|
545
1141
|
<GraphChrome />
|
|
546
1142
|
</ReactFlow>
|
|
547
|
-
{
|
|
1143
|
+
{/* Gated on `settled`, like the edges themselves: a chip is anchored to the
|
|
1144
|
+
*settled* midpoint of its edge, so showing one while the cards glide leaves
|
|
1145
|
+
it pointing at the space between two layouts. It rejoins them, the nested
|
|
1146
|
+
components and the descriptions in one reveal. */}
|
|
1147
|
+
{ready && settled && labelPositions.size > 0 && (
|
|
548
1148
|
<C4EdgeLabels positions={labelPositions} edges={model.edges} />
|
|
549
1149
|
)}
|
|
550
1150
|
{!ready && (
|
|
@@ -564,29 +1164,6 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
564
1164
|
Laying out C4 view…
|
|
565
1165
|
</div>
|
|
566
1166
|
)}
|
|
567
|
-
{title && (
|
|
568
|
-
<div
|
|
569
|
-
style={{
|
|
570
|
-
position: 'absolute',
|
|
571
|
-
top: 12,
|
|
572
|
-
left: 12,
|
|
573
|
-
// Above the edge-label overlay (zIndex 5).
|
|
574
|
-
zIndex: 10,
|
|
575
|
-
fontSize: theme.fontSizes[2],
|
|
576
|
-
fontWeight: 600,
|
|
577
|
-
color: theme.colors.text,
|
|
578
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
579
|
-
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
580
|
-
borderRadius: 6,
|
|
581
|
-
padding: '4px 10px',
|
|
582
|
-
}}
|
|
583
|
-
>
|
|
584
|
-
{title}
|
|
585
|
-
<span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, marginLeft: 8 }}>
|
|
586
|
-
{model.view} view · {runtime} {model.view === 'container' ? 'containers' : 'components'} · {outside} outside
|
|
587
|
-
</span>
|
|
588
|
-
</div>
|
|
589
|
-
)}
|
|
590
1167
|
{selected && (
|
|
591
1168
|
<div
|
|
592
1169
|
style={{
|