@principal-ai/subsystems-react 0.53.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.
@@ -11,18 +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, LayoutGrid } from 'lucide-react';
17
+ import { Box, Minimize2 } from 'lucide-react';
18
18
  import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
19
19
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
20
- import { protocolColor } from './c4';
21
- import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } 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';
22
23
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
23
24
  function C4NodeView(props) {
24
- const { element, selected, componentCount } = props.data;
25
- return (_jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, 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
+ 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 } })] }) }) }));
26
38
  }
27
39
  /**
28
40
  * Edge-label overlay.
@@ -100,9 +112,28 @@ function C4GroupView(props) {
100
112
  const { theme } = useTheme();
101
113
  const color = props.data.color;
102
114
  const isSystem = props.data.kind === 'system';
103
- // A system frame is the box we draw; a container frame groups components
104
- // inside one. Boxes for each, so the badge says which level you are looking at.
105
- const FrameIcon = isSystem ? Box : LayoutGrid;
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;
106
137
  return (_jsx("div", { style: {
107
138
  width: '100%',
108
139
  height: '100%',
@@ -111,6 +142,9 @@ function C4GroupView(props) {
111
142
  border: `2px dashed ${color}`,
112
143
  background: 'transparent',
113
144
  pointerEvents: 'none',
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',
114
148
  }, children: _jsxs("span", { style: {
115
149
  position: 'absolute',
116
150
  top: 0,
@@ -134,7 +168,24 @@ function C4GroupView(props) {
134
168
  padding: '4px 10px',
135
169
  boxSizing: 'border-box',
136
170
  whiteSpace: 'nowrap',
137
- }, children: [_jsx(FrameIcon, { size: 13, strokeWidth: 2.25, "aria-hidden": true }), 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 }) }))] }) }));
138
189
  }
139
190
  const nodeTypes = {
140
191
  'c4-node': C4NodeView,
@@ -143,7 +194,27 @@ const nodeTypes = {
143
194
  const edgeTypes = {
144
195
  'c4-edge': C4EdgeView,
145
196
  };
146
- function Inner({ model, title, onSelectNode, gutter = 28 }) {
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 }) {
147
218
  const { theme } = useTheme();
148
219
  const { fitView } = useReactFlow();
149
220
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -152,22 +223,64 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
152
223
  const [ready, setReady] = useState(false);
153
224
  const [selectedId, setSelectedId] = useState(null);
154
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
+ };
155
261
  const layoutKey = useMemo(() => [
156
262
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
157
- ...model.groups.map((g) => `${g.id}\0${g.parentId ?? ''}\0${g.memberIds.length}`).sort(),
158
263
  ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
264
+ `open:${model.openContainerId ?? ''}`,
159
265
  ].join('\n'), [model]);
160
- useEffect(() => {
161
- let alive = true;
162
- setReady(false);
163
- setSelectedId(null);
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) => {
164
272
  // Draw only the elements for this view. A container diagram shows the
165
273
  // runtime units (containers, externals, people); components belong to the
166
- // component view. Components are still carried on the model (see the side
167
- // panel), just not drawn here.
168
- const inView = (n) => model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
169
- // How many components each container groups — computed once here because
170
- // the card only receives its own element, not the model.
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
+ };
171
284
  const childComponentCount = new Map();
172
285
  for (const n of model.nodes) {
173
286
  if (n.kind !== 'component')
@@ -176,193 +289,365 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
176
289
  if (parent)
177
290
  childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
178
291
  }
179
- // Layout against the true card size, so ELK's ports sit on the card's
180
- // border — an inflated box leaves the line starting short of the card edge.
181
- // The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
182
292
  const rfNodes = model.nodes.filter(inView).map((n, i) => {
183
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;
184
299
  return {
185
300
  id: n.id,
186
301
  type: 'c4-node',
187
- // Fallback grid pitch uses the container box — the largest, so the
188
- // pre-layout positions never overlap. ELK replaces them on success.
189
302
  position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
190
303
  width: size.width,
191
304
  height: size.height,
192
- ...(n.parentId ? { parentId: n.parentId } : {}),
193
- data: { element: n, selected: false, componentCount: childComponentCount.get(n.id) ?? 0 },
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
+ },
194
328
  };
195
329
  });
196
- const groups = model.groups.map((g) => ({
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) => ({
197
336
  id: g.id,
198
337
  memberIds: g.memberIds,
199
338
  ...(g.parentId ? { parentId: g.parentId } : {}),
200
339
  }));
201
- // Band nodes so a flat layout still reads as C4 and frames can't interleave.
202
- //
203
- // Flat mode has no ELK parents, so without bands ELK mixes members of
204
- // different frames across the whole graph — their synthesized bounding
205
- // boxes then overlap. A partition per frame keeps each frame's members
206
- // contiguous; people lead, externals trail.
207
- //
208
- // Order (ascending = earlier in the layout direction): people, then each
209
- // frame in model order, then external systems.
210
- const frameIds = model.groups.map((g) => g.id);
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);
211
364
  const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
212
365
  const partitionByNode = new Map(model.nodes.map((n) => {
213
366
  if (n.kind === 'person')
214
367
  return [n.id, 0];
215
368
  if (n.kind === 'external-system')
216
369
  return [n.id, frameIds.length + 1];
217
- // A node inside a frame shares its frame's band; a frame-less node
218
- // (a container at container view) gets its own by id order.
219
370
  const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
220
371
  return [n.id, 1 + band];
221
372
  }));
222
- const rfEdges = model.edges.map((e) => {
223
- // Colour keys off the protocol, not the trail verb: on a container
224
- // diagram the transport is what a reader distinguishes at a glance.
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) => {
225
383
  const color = protocolColor(e.protocol);
226
384
  return {
227
385
  id: e.id,
228
386
  type: 'c4-edge',
229
387
  source: e.source,
230
388
  target: e.target,
231
- // The reserved label box and the chip must agree on size, so ELK is
232
- // given the same text the chip draws (protocol first, then label). The
233
- // chip itself is the C4EdgeLabels overlay, not this field.
234
389
  label: e.protocol ?? e.label,
235
390
  markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
236
391
  data: { edge: e, color, path: '' },
237
392
  };
238
393
  });
239
- void computeElkLayout(rfNodes, rfEdges, {
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, {
240
408
  direction: 'RIGHT',
241
409
  preserveNodePositions: false,
242
410
  groups,
243
411
  keepSingletonGroups: true,
244
412
  edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
245
- // The gutter that used to inflate each node box is now real spacing, so
246
- // the ports still sit on the card edge.
247
413
  nodeSpacing: gutter * 2,
248
414
  edgeNodeSpacing: gutter,
249
- // Between-layer/between-partition gap. This is what keeps the external
250
- // band clear of the system frame's padding — ELK gives partitions zero
251
- // default separation. It stacks on top of ELK's reserved label layer, so
252
- // it is kept modest.
253
415
  interLayerSpacing: 40,
254
- // Draw the system frame as a decoration around the boxes rather than an
255
- // ELK parent. Nesting made in-frame edges inherit the frame's padding and
256
- // spacing, so they ran longer than a root-level edge touching the same
257
- // nodes; flattening gives every edge one spacing.
258
- flatGroups: true,
416
+ flatGroups: openId ? new Set(groups.map((g) => g.id).filter((id) => id !== openId)) : true,
259
417
  partitionByNode,
260
- })
261
- .then((result) => {
262
- if (!alive)
263
- return;
264
- const positioned = new Map(result.nodes.map((n) => [n.id, n]));
265
- // Draw each edge along ELK's routed polyline and place its label from
266
- // the same points — one geometry for the line and the chip.
267
- const labelPositions = new Map();
268
- const withPaths = rfEdges.map((e) => {
269
- const pts = result.edgePathPoints?.get(e.id);
270
- if (!pts || pts.length === 0)
271
- return e;
272
- labelPositions.set(e.id, pointAlongPath(pts, 0.5));
273
- // Rounded corners on ELK's orthogonal route — gentler than raw right
274
- // angles, and still the exact polyline the label is placed on.
275
- return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
276
- });
277
- setLabelPositions(labelPositions);
278
- const builtGroups = new Set(result.groupBounds.keys());
279
- const groupById = new Map(model.groups.map((g) => [g.id, g]));
280
- const depthOf = (id) => {
281
- let d = 0;
282
- let g = groupById.get(id);
283
- while (g?.parentId) {
284
- d += 1;
285
- g = groupById.get(g.parentId);
286
- }
287
- return d;
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
+ },
288
488
  };
289
- // Shell positions from flat synthesis are absolute. React Flow reads a
290
- // child's `position` as relative to its parent, so a shell nested in
291
- // another shell must be made relative to it.
292
- const shellAbs = new Map(result.groupBounds);
293
- const shellOrigin = (id) => {
294
- if (!id)
295
- return { x: 0, y: 0 };
296
- const b = shellAbs.get(id);
297
- return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
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 } : {}),
298
515
  };
299
- const shells = [...result.groupBounds.entries()]
300
- .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
301
- .map(([id, b]) => {
302
- const def = groupById.get(id);
303
- const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
304
- const origin = shellOrigin(parentId);
305
- // A container frame borrows its container node's border colour, so a
306
- // frame and the box it wraps read as the same thing. Falls back to
307
- // the accent when no element matches (e.g. a hand-authored group id).
308
- const element = model.nodes.find((n) => n.id === id);
309
- const frameColor = def?.kind === 'system'
310
- ? (theme.colors.text ?? '#888')
311
- : element
312
- ? nodeStyle(element, theme).color
313
- : (theme.colors.accent ?? theme.colors.info);
314
- return {
315
- id,
316
- type: 'c4-group',
317
- position: { x: b.x - origin.x, y: b.y - origin.y },
318
- width: Math.max(200, b.width),
319
- height: Math.max(120, b.height),
320
- draggable: false,
321
- selectable: false,
322
- ...(parentId ? { parentId } : {}),
323
- data: {
324
- label: def?.label ?? id,
325
- kind: def?.kind ?? 'container',
326
- color: frameColor,
327
- },
328
- };
329
- });
330
- const leaves = rfNodes.map((n) => {
331
- const p = positioned.get(n.id);
332
- // Absolute flow position from the flat layout.
333
- const abs = p ? p.position : n.position;
334
- // Re-parent to the shell this node sits in, if any: a component's
335
- // container, or a container's system frame. React Flow positions a
336
- // child relative to its parent, so subtract the shell origin.
337
- let parentId;
338
- if (n.data && typeof n.data === 'object') {
339
- const modelNode = n.data.element;
340
- if (modelNode?.parentId && builtGroups.has(modelNode.parentId))
341
- parentId = modelNode.parentId;
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;
342
581
  }
343
- const origin = shellOrigin(parentId);
344
- const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
345
- return (parentId ? { ...node, parentId } : { ...node, parentId: undefined });
346
- });
347
- setNodes([...shells, ...leaves]);
348
- setRfEdges(withPaths);
349
- setReady(true);
350
- requestAnimationFrame(() => fitView({ padding: 0.15 }));
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);
351
602
  })
352
603
  .catch((err) => {
353
604
  if (!alive)
354
605
  return;
355
606
  console.warn('[c4-graph] ELK layout failed, using grid:', err);
356
- setNodes(rfNodes);
357
- setRfEdges(rfEdges);
607
+ setNodes(inputs.rfNodes.map((n) => ({ ...n, parentId: undefined })));
608
+ setRfEdges(inputs.rfEdges);
358
609
  setReady(true);
359
- requestAnimationFrame(() => fitView({ padding: 0.15 }));
610
+ afterLayout();
360
611
  });
361
612
  return () => {
362
613
  alive = false;
614
+ if (settleTimer.current) {
615
+ clearTimeout(settleTimer.current);
616
+ settleTimer.current = null;
617
+ }
363
618
  };
364
619
  // eslint-disable-next-line react-hooks/exhaustive-deps
365
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]);
366
651
  const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
367
652
  /**
368
653
  * The components authored inside the selected element, if it is a container.
@@ -374,13 +659,24 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
374
659
  ? model.nodes.filter((n) => n.kind === 'component' &&
375
660
  (n.container === selected.id || n.parentId === selected.id))
376
661
  : [], [model, selected]);
377
- const displayNodes = useMemo(() => nodes.map((n) => n.type === 'c4-node'
378
- ? { ...n, data: { ...n.data, selected: n.id === selectedId } }
379
- : n), [nodes, selectedId]);
380
- // What the current view draws: runtime units at container level, components
381
- // at component level. Mirrors the `inView` filter used for the nodes.
382
- const runtime = model.nodes.filter((n) => model.view === 'component' ? n.kind === 'component' : n.kind === 'container').length;
383
- const outside = model.nodes.filter((n) => n.kind === 'external-system' || n.kind === 'person').length;
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]);
384
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) => {
385
681
  if (node.type !== 'c4-node')
386
682
  return;
@@ -404,20 +700,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
404
700
  color: muted,
405
701
  fontFamily: theme.fonts.monospace,
406
702
  fontSize: theme.fontSizes[0],
407
- }, children: "Laying out C4 view\u2026" })), title && (_jsxs("div", { style: {
408
- position: 'absolute',
409
- top: 12,
410
- left: 12,
411
- // Above the edge-label overlay (zIndex 5).
412
- zIndex: 10,
413
- fontSize: theme.fontSizes[2],
414
- fontWeight: 600,
415
- color: theme.colors.text,
416
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
417
- border: `1px solid ${theme.colors.border ?? '#333'}`,
418
- borderRadius: 6,
419
- padding: '4px 10px',
420
- }, 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: {
421
704
  position: 'absolute',
422
705
  top: 12,
423
706
  right: 12,