@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.
Files changed (36) hide show
  1. package/dist/subsystem/C4Graph.d.ts +8 -1
  2. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  3. package/dist/subsystem/C4Graph.js +470 -155
  4. package/dist/subsystem/C4Graph.js.map +1 -1
  5. package/dist/subsystem/C4NodeCard.d.ts +42 -1
  6. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  7. package/dist/subsystem/C4NodeCard.js +75 -20
  8. package/dist/subsystem/C4NodeCard.js.map +1 -1
  9. package/dist/subsystem/IssueList.d.ts.map +1 -1
  10. package/dist/subsystem/IssueList.js +13 -4
  11. package/dist/subsystem/IssueList.js.map +1 -1
  12. package/dist/subsystem/c4.d.ts +27 -3
  13. package/dist/subsystem/c4.d.ts.map +1 -1
  14. package/dist/subsystem/c4.js +48 -0
  15. package/dist/subsystem/c4.js.map +1 -1
  16. package/dist/subsystem/model.d.ts +35 -245
  17. package/dist/subsystem/model.d.ts.map +1 -1
  18. package/dist/subsystem/model.js +11 -29
  19. package/dist/subsystem/model.js.map +1 -1
  20. package/dist/utils/elkLayout.d.ts +26 -2
  21. package/dist/utils/elkLayout.d.ts.map +1 -1
  22. package/dist/utils/elkLayout.js +40 -8
  23. package/dist/utils/elkLayout.js.map +1 -1
  24. package/package.json +4 -3
  25. package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
  26. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
  27. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
  28. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +131 -0
  29. package/src/subsystem/C4Graph.tsx +578 -198
  30. package/src/subsystem/C4NodeCard.test.ts +6 -3
  31. package/src/subsystem/C4NodeCard.tsx +158 -49
  32. package/src/subsystem/IssueList.category.test.ts +39 -0
  33. package/src/subsystem/IssueList.tsx +14 -3
  34. package/src/subsystem/c4.ts +74 -3
  35. package/src/subsystem/model.ts +62 -305
  36. 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
- return (_jsx(C4NodeCard, { node: element, selected: selected, 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 } })] }) }) }));
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
- }, children: _jsx("span", { style: {
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
- 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 }) {
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
- useEffect(() => {
153
- let alive = true;
154
- setReady(false);
155
- 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) => {
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. Components are still carried on the model (see the side
159
- // panel), just not drawn here.
160
- const inView = (n) => model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
161
- // Layout against the true card size, so ELK's ports sit on the card's
162
- // border — an inflated box leaves the line starting short of the card edge.
163
- // The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
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
- ...(n.parentId ? { parentId: n.parentId } : {}),
175
- data: { element: n, selected: false },
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
- 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) => ({
179
336
  id: g.id,
180
337
  memberIds: g.memberIds,
181
338
  ...(g.parentId ? { parentId: g.parentId } : {}),
182
339
  }));
183
- // Band nodes so a flat layout still reads as C4 and frames can't interleave.
184
- //
185
- // Flat mode has no ELK parents, so without bands ELK mixes members of
186
- // different frames across the whole graph — their synthesized bounding
187
- // boxes then overlap. A partition per frame keeps each frame's members
188
- // contiguous; people lead, externals trail.
189
- //
190
- // Order (ascending = earlier in the layout direction): people, then each
191
- // frame in model order, then external systems.
192
- 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);
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
- const rfEdges = model.edges.map((e) => {
205
- // Colour keys off the protocol, not the trail verb: on a container
206
- // 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) => {
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
- 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, {
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
- // Draw the system frame as a decoration around the boxes rather than an
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
- .then((result) => {
244
- if (!alive)
245
- return;
246
- const positioned = new Map(result.nodes.map((n) => [n.id, n]));
247
- // Draw each edge along ELK's routed polyline and place its label from
248
- // the same points — one geometry for the line and the chip.
249
- const labelPositions = new Map();
250
- const withPaths = rfEdges.map((e) => {
251
- const pts = result.edgePathPoints?.get(e.id);
252
- if (!pts || pts.length === 0)
253
- return e;
254
- labelPositions.set(e.id, pointAlongPath(pts, 0.5));
255
- // Rounded corners on ELK's orthogonal route — gentler than raw right
256
- // angles, and still the exact polyline the label is placed on.
257
- return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
258
- });
259
- setLabelPositions(labelPositions);
260
- const builtGroups = new Set(result.groupBounds.keys());
261
- const groupById = new Map(model.groups.map((g) => [g.id, g]));
262
- const depthOf = (id) => {
263
- let d = 0;
264
- let g = groupById.get(id);
265
- while (g?.parentId) {
266
- d += 1;
267
- g = groupById.get(g.parentId);
268
- }
269
- 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
+ },
270
488
  };
271
- // Shell positions from flat synthesis are absolute. React Flow reads a
272
- // child's `position` as relative to its parent, so a shell nested in
273
- // another shell must be made relative to it.
274
- const shellAbs = new Map(result.groupBounds);
275
- const shellOrigin = (id) => {
276
- if (!id)
277
- return { x: 0, y: 0 };
278
- const b = shellAbs.get(id);
279
- 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 } : {}),
280
515
  };
281
- const shells = [...result.groupBounds.entries()]
282
- .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
283
- .map(([id, b]) => {
284
- const def = groupById.get(id);
285
- const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
286
- const origin = shellOrigin(parentId);
287
- return {
288
- id,
289
- type: 'c4-group',
290
- position: { x: b.x - origin.x, y: b.y - origin.y },
291
- width: Math.max(200, b.width),
292
- height: Math.max(120, b.height),
293
- draggable: false,
294
- selectable: false,
295
- ...(parentId ? { parentId } : {}),
296
- data: {
297
- label: def?.label ?? id,
298
- kind: def?.kind ?? 'container',
299
- color: def?.kind === 'system' ? (theme.colors.text ?? '#888') : (theme.colors.accent ?? theme.colors.info),
300
- },
301
- };
302
- });
303
- const leaves = rfNodes.map((n) => {
304
- const p = positioned.get(n.id);
305
- // Absolute flow position from the flat layout.
306
- const abs = p ? p.position : n.position;
307
- // Re-parent to the shell this node sits in, if any: a component's
308
- // container, or a container's system frame. React Flow positions a
309
- // child relative to its parent, so subtract the shell origin.
310
- let parentId;
311
- if (n.data && typeof n.data === 'object') {
312
- const modelNode = n.data.element;
313
- if (modelNode?.parentId && builtGroups.has(modelNode.parentId))
314
- 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;
315
581
  }
316
- const origin = shellOrigin(parentId);
317
- const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
318
- return (parentId ? { ...node, parentId } : { ...node, parentId: undefined });
319
- });
320
- setNodes([...shells, ...leaves]);
321
- setRfEdges(withPaths);
322
- setReady(true);
323
- 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);
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
- requestAnimationFrame(() => fitView({ padding: 0.15 }));
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
- const displayNodes = useMemo(() => nodes.map((n) => n.type === 'c4-node'
351
- ? { ...n, data: { ...n.data, selected: n.id === selectedId } }
352
- : n), [nodes, selectedId]);
353
- // What the current view draws: runtime units at container level, components
354
- // at component level. Mirrors the `inView` filter used for the nodes.
355
- const runtime = model.nodes.filter((n) => model.view === 'component' ? n.kind === 'component' : n.kind === 'container').length;
356
- 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]);
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" })), title && (_jsxs("div", { style: {
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',