@principal-ai/subsystems-react 0.53.1 → 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.
@@ -11,7 +11,7 @@
11
11
  * drawers, no trail playback.
12
12
  */
13
13
 
14
- import { useEffect, useMemo, useRef, useState } from 'react';
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
  import {
16
16
  Edge,
17
17
  EdgeProps,
@@ -27,10 +27,10 @@ import {
27
27
  } from '@xyflow/react';
28
28
  import { useTheme } from '@principal-ade/industry-theme';
29
29
  import { Box, Minimize2 } from 'lucide-react';
30
- import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
31
- import type { CompoundGroupDef } from '../utils/elkLayout';
30
+ import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
31
+ import type { CompoundGroupDef, ElkLayoutResult } from '../utils/elkLayout';
32
32
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
33
- import { deriveC4Groups, protocolColor } from './c4';
33
+ import { deriveC4Groups, protocolColor, type C4Group } from './c4';
34
34
  import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
35
35
  import { TechMark, technologyBrand } from './techIcons';
36
36
  import type { TechBrand } from './techIcons';
@@ -71,11 +71,22 @@ function C4NodeView(
71
71
  * hidden for the whole animation and only returns once the box settles.
72
72
  */
73
73
  settled?: boolean;
74
+ /** FLIP: absolute flow rects — where this card was, and where it now is. */
75
+ growFrom?: FlowRect;
76
+ frameRect?: FlowRect;
74
77
  }>
75
78
  >,
76
79
  ) {
77
- const { element, selected, componentCount, onExpand, open, settled } = props.data;
80
+ const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } =
81
+ props.data;
78
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);
79
90
  // The card takes its box from the React Flow node rather than from its own
80
91
  // `nodeSize`, so on the opened container ELK's fitted size drives it — and
81
92
  // because the element persists across the swap, the card's own width/height
@@ -86,11 +97,16 @@ function C4NodeView(
86
97
  // card must not compete with it.
87
98
  return (
88
99
  <div
89
- style={
90
- grown
100
+ style={{
101
+ ...(grown
91
102
  ? { position: 'absolute', top: 0, left: 0, width: props.width, height: props.height }
92
- : undefined
93
- }
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
+ }}
94
110
  >
95
111
  <C4NodeCard
96
112
  node={element}
@@ -99,7 +115,7 @@ function C4NodeView(
99
115
  onExpand={onExpand}
100
116
  width={props.width}
101
117
  height={props.height}
102
- showDescription={settled !== false}
118
+ showDescription={showDescriptionFor(open, settled)}
103
119
  handles={
104
120
  <>
105
121
  <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
@@ -242,20 +258,12 @@ function C4GroupView(
242
258
  const brand = props.data.brand;
243
259
  const { growFrom, frameRect } = props.data;
244
260
 
245
- // FLIP the real frame: start it transformed back onto the box's rect, then
246
- // release to identity so it grows into place. `transform-origin: top left`
247
- // makes the scale anchor match the absolute rects.
248
- const [grown, setGrown] = useState(!growFrom || !frameRect);
249
- useEffect(() => {
250
- if (!growFrom || !frameRect) return;
251
- setGrown(false);
252
- const raf = requestAnimationFrame(() => setGrown(true));
253
- return () => cancelAnimationFrame(raf);
254
- }, [growFrom, frameRect]);
255
- const dx = growFrom && frameRect ? growFrom.x - frameRect.x : 0;
256
- const dy = growFrom && frameRect ? growFrom.y - frameRect.y : 0;
257
- const sx = growFrom && frameRect && frameRect.width ? growFrom.width / frameRect.width : 1;
258
- const sy = growFrom && frameRect && frameRect.height ? growFrom.height / frameRect.height : 1;
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);
259
267
 
260
268
  // A container frame shows its technology mark (React/Bun/Node…) so the
261
269
  // boundary says what it is built with, same as the node it wraps. The system
@@ -264,16 +272,27 @@ function C4GroupView(
264
272
  return (
265
273
  <div
266
274
  style={{
267
- width: '100%',
268
- height: '100%',
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(', '),
269
289
  boxSizing: 'border-box',
270
290
  borderRadius: 12,
271
291
  border: `2px dashed ${color}`,
272
292
  background: 'transparent',
273
293
  pointerEvents: 'none',
274
294
  transformOrigin: 'top left',
275
- transform: grown ? 'none' : `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
276
- transition: grown ? `transform ${MORPH_MS}ms ease-out` : 'none',
295
+ transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
277
296
  }}
278
297
  >
279
298
  <span
@@ -351,6 +370,16 @@ const edgeTypes = {
351
370
  /** Drill-down animation durations, in ms. */
352
371
  const MORPH_MS = 420;
353
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;
354
383
 
355
384
  /** A fully laid-out open state: the RF nodes/edges and label anchors. */
356
385
  interface Rendered {
@@ -366,6 +395,132 @@ interface FlowRect { x: number; y: number; width: number; height: number }
366
395
  /** A frame's growth for the drill-down morph: its flow rect before → after. */
367
396
  interface FrameGrow { from: FlowRect; to: FlowRect }
368
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
+
369
524
  /** The container's rect in a laid-out state: its box node, or its frame bounds. */
370
525
  function containerRect(layout: Rendered | undefined, id: string): FlowRect | null {
371
526
  if (!layout) return null;
@@ -381,6 +536,41 @@ function containerRect(layout: Rendered | undefined, id: string): FlowRect | nul
381
536
  };
382
537
  }
383
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
+
384
574
  function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
385
575
  const { theme } = useTheme();
386
576
  const { fitView } = useReactFlow();
@@ -397,9 +587,10 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
397
587
  /** Prefetched layouts, keyed by open-container id ('' = closed). */
398
588
  const layoutCache = useRef(new Map<string, Rendered>());
399
589
  /**
400
- * False while the drill-down grow/shrink is in flight. It gates both halves of
401
- * the reveal: the opened container's components stay hidden, and every card
402
- * drops its description — returning only once the box settles.
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.
403
594
  *
404
595
  * Driven by one timer keyed to MORPH_MS rather than `transitionend`: the grow
405
596
  * is the card's own width/height transition *and* the system frame's
@@ -411,22 +602,31 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
411
602
  const paintedRef = useRef(false);
412
603
  /** The in-flight settle timer; cancelled if the open state changes again. */
413
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);
414
607
  /** The open id rendered last, so a change can morph between the two layouts. */
415
608
  const prevOpenRef = useRef<string | null>(null);
416
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
+
417
619
  // Fit the camera AFTER the new nodes are committed (when `ready` flips true),
418
- // so `fitView` measures the real geometry. Fit the SYSTEM frame explicitly:
419
- // on a drill-down the drawn nodes are just the opened container's contents,
420
- // so fitting them would zoom to the container, not the whole system.
620
+ // so `fitView` measures the real geometry.
421
621
  useEffect(() => {
422
622
  if (!ready || !pendingFitRef.current) return;
423
623
  pendingFitRef.current = false;
424
- fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
425
- }, [ready, model.system.id, fitView]);
624
+ fitSystem();
625
+ }, [ready, fitSystem]);
426
626
 
427
627
  /** Called once the new layout is committed, from the layout effect. */
428
- const afterLayout = () => {
429
- pendingFitRef.current = true;
628
+ const afterLayout = (grows: Map<string, FrameGrow> | null) => {
629
+ pendingFitRef.current = shouldRefitCamera(grows);
430
630
  };
431
631
 
432
632
  const layoutKey = useMemo(
@@ -500,44 +700,11 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
500
700
  };
501
701
  });
502
702
 
503
- // Every frame this layout draws: the system boundary, plus a container frame per
504
- // container whose components are in view (the component view leans on these).
505
- // The *opened* container's frame is dropped — it is drawn as a grown card
506
- // instead, as an ELK parent below.
507
- const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter(
508
- (g) => g.id !== openId,
509
- );
510
- const groups: CompoundGroupDef[] = frames.map((g) => ({
511
- id: g.id,
512
- memberIds: g.memberIds,
513
- ...(g.parentId ? { parentId: g.parentId } : {}),
514
- }));
515
-
516
- // Opened: the container becomes a real ELK parent so it is sized to hold
517
- // its components — the "layout B" the closed layout transitions to. Only in
518
- // the container view: that is where a container is a card, and where the
519
- // drill-down is offered.
520
- const openMembers =
521
- model.view === 'container' && openId
522
- ? model.nodes
523
- .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
524
- .map((n) => n.id)
525
- : [];
526
- if (openId && openMembers.length > 0) {
527
- groups.push({
528
- id: openId,
529
- memberIds: openMembers,
530
- // Never narrower than the closed card, so opening only ever grows the box.
531
- minWidth: NODE_W,
532
- // Room for the container's own label slot + technology row.
533
- padTop: OPEN_HEADER_PAD,
534
- });
535
- }
703
+ const { frames, groups } = c4GroupDefs(model, openId);
536
704
 
537
705
  // Bands come from the *frames* only. The opened container is an ELK parent,
538
706
  // not a band — treating it as one would push it into its own layer, away from
539
- // the containers it belongs beside (and, the system frame being flat, drag
540
- // that frame's union across the gap).
707
+ // the containers it belongs beside.
541
708
  const frameIds = frames.map((g) => g.id);
542
709
  const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
543
710
  const partitionByNode = new Map<string, number>(
@@ -548,11 +715,6 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
548
715
  return [n.id, 1 + band];
549
716
  }),
550
717
  );
551
- // The compound parent needs its band stated explicitly — partitioning is
552
- // activated on the root, and an unset parent falls to partition 0 (the
553
- // people band), which would land the opened container outside the frame.
554
- const openGroup = openId ? groups.find((g) => g.id === openId) : undefined;
555
- if (openId && openGroup) openGroup.partition = partitionByNode.get(openId) ?? 1;
556
718
 
557
719
  const drawnIds = new Set(rfNodes.map((n) => n.id));
558
720
  const rfEdges: Edge[] = model.edges
@@ -582,11 +744,22 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
582
744
  inputs: ReturnType<typeof buildInputs>,
583
745
  ): Promise<Rendered> => {
584
746
  const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
585
- // Flatten every frame *except* the opened container. The system frame is
586
- // decorative and flattens (one spacing, no nested edge runs); the opened
587
- // container has to be a real ELK parent so ELK sizes it to hold its nested
588
- // components — that fitted box is "layout B". Both passes are precomputed,
589
- // so the drill-down is a swap between two ELK results, not a reflow at click.
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.
590
763
  const result = await computeElkLayout(rfNodes, rfEdges, {
591
764
  direction: 'RIGHT',
592
765
  preserveNodePositions: false,
@@ -596,7 +769,7 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
596
769
  nodeSpacing: gutter * 2,
597
770
  edgeNodeSpacing: gutter,
598
771
  interLayerSpacing: 40,
599
- flatGroups: openId ? new Set(groups.map((g) => g.id).filter((id) => id !== openId)) : true,
772
+ flatGroups: model.view === 'component' ? true : new Set<string>(),
600
773
  partitionByNode,
601
774
  });
602
775
 
@@ -609,12 +782,15 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
609
782
  return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
610
783
  });
611
784
 
612
- const builtGroups = new Set(result.groupBounds.keys());
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());
613
789
  // The opened container comes back from ELK as a parent, so it lands in
614
790
  // `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
615
791
  // from the shells; keep it in `groupBounds` so nested children can take
616
792
  // their coordinates relative to it.
617
- const shellBounds = new Map(result.groupBounds);
793
+ const shellBounds = new Map(groupBounds);
618
794
  if (openId) shellBounds.delete(openId);
619
795
 
620
796
  const groupById = new Map(frames.map((g) => [g.id, g]));
@@ -628,22 +804,26 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
628
804
  return d;
629
805
  };
630
806
 
631
- const shellAbs = result.groupBounds;
632
- const shellOrigin = (id: string | undefined): { x: number; y: number } => {
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 } => {
633
812
  if (!id) return { x: 0, y: 0 };
634
- const b = shellAbs.get(id);
635
- return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
813
+ const r = absBoundsOf(id);
814
+ return r ? { x: r.x, y: r.y } : { x: 0, y: 0 };
636
815
  };
637
816
 
638
817
  // Only the system frame is emitted today (the drill-down's container frames
639
818
  // became grown cards), but the mapping stays keyed off the derived group so a
640
819
  // container frame can come back without reshaping this.
641
- const shells: Node[] = [...shellBounds.entries()]
642
- .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
643
- .map(([id, b]) => {
820
+ const shells: Node[] = [...shellBounds.keys()]
821
+ .sort((a, b) => depthOf(a) - depthOf(b))
822
+ .map((id) => {
644
823
  const def = groupById.get(id);
645
824
  const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
646
- const origin = shellOrigin(parentId);
825
+ const origin = parentOriginOf(parentId);
826
+ const b = absBoundsOf(id) ?? { x: 0, y: 0, width: 0, height: 0 };
647
827
  const element = model.nodes.find((n) => n.id === id);
648
828
  const frameColor =
649
829
  def?.kind === 'system'
@@ -675,8 +855,6 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
675
855
  });
676
856
 
677
857
  const leaves = rfNodes.map((n) => {
678
- const p = positioned.get(n.id);
679
- const abs = p ? p.position : n.position;
680
858
  let parentId: string | undefined;
681
859
  if (n.data && typeof n.data === 'object') {
682
860
  const modelNode = (n.data as { element?: C4Element }).element;
@@ -691,30 +869,40 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
691
869
  const candidate = nested ?? modelNode?.parentId;
692
870
  if (candidate && builtGroups.has(candidate)) parentId = candidate;
693
871
  }
694
- const origin = shellOrigin(parentId);
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;
695
878
  // The opened container is an ELK parent, so its box is whatever ELK fitted
696
879
  // to the nested components — `nodeSize` only knows the closed card.
697
- const fitted = openId === n.id ? result.groupBounds.get(openId) : undefined;
880
+ const fitted = openId === n.id ? groupBounds.get(openId) : undefined;
698
881
  const node = {
699
882
  ...n,
700
- position: { x: abs.x - origin.x, y: abs.y - origin.y },
883
+ position: { x: abs.x, y: abs.y },
701
884
  ...(fitted ? { width: fitted.width, height: fitted.height } : {}),
702
885
  };
703
886
  return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
704
887
  });
705
888
 
706
- return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds: result.groupBounds };
889
+ // `groupBounds`, not `result.groupBounds`: the rebased one. `Rendered` readers
890
+ // all assume absolute.
891
+ return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds };
707
892
  };
708
893
 
709
894
  /**
710
- * Stamp each growing frame's span onto its shell node, so its view can FLIP
711
- * from the old rect to its own. `grows` is keyed by frame id, flow-space.
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.
712
897
  */
713
898
  const injectGrow = (nodes: Node[], grows: Map<string, FrameGrow> | null): Node[] => {
714
899
  if (!grows || grows.size === 0) return nodes;
715
900
  return nodes.map((n) => {
716
901
  const g = grows.get(n.id);
717
- return n.type === 'c4-group' && g
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'
718
906
  ? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
719
907
  : n;
720
908
  });
@@ -725,7 +913,7 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
725
913
  setRfEdges(r.edges);
726
914
  setLabelPositions(r.labelPositions);
727
915
  setReady(true);
728
- afterLayout();
916
+ afterLayout(grows);
729
917
  };
730
918
 
731
919
  // Render the current open state. Uses the prefetched layout when present
@@ -743,20 +931,42 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
743
931
  setSettled(false);
744
932
  if (settleTimer.current) clearTimeout(settleTimer.current);
745
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);
746
942
  }
747
943
  paintedRef.current = true;
748
944
 
749
945
  /**
750
- * Every frame whose rect changes between the previous and new layouts, so
751
- * all boundaries can grow/shrink together (container frames *and* the
752
- * system frame). Empty when nothing changed (e.g. the initial paint).
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.
753
956
  */
754
957
  const growsFor = (next: Rendered): Map<string, FrameGrow> | null => {
755
958
  if (prevOpen === openId) return null;
756
959
  const prevLayout = layoutCache.current.get(prevOpen ?? '');
757
960
  if (!prevLayout) return null;
758
961
  const grows = new Map<string, FrameGrow>();
759
- const ids = new Set([...prevLayout.groupBounds.keys(), ...next.groupBounds.keys()]);
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
+ ]);
760
970
  for (const id of ids) {
761
971
  const fromRect = containerRect(prevLayout, id);
762
972
  const to = containerRect(next, id);
@@ -796,7 +1006,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
796
1006
  setNodes(inputs.rfNodes.map((n) => ({ ...n, parentId: undefined })));
797
1007
  setRfEdges(inputs.rfEdges);
798
1008
  setReady(true);
799
- afterLayout();
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);
800
1012
  });
801
1013
  return () => {
802
1014
  alive = false;
@@ -804,6 +1016,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
804
1016
  clearTimeout(settleTimer.current);
805
1017
  settleTimer.current = null;
806
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
+ }
807
1025
  };
808
1026
  // eslint-disable-next-line react-hooks/exhaustive-deps
809
1027
  }, [layoutKey]);
@@ -883,12 +1101,23 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
883
1101
  [nodes, selectedId, settled, openContainerId],
884
1102
  );
885
1103
 
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
+ );
1114
+
886
1115
  return (
887
1116
  <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
888
1117
  <GraphLayerStyle />
889
1118
  <ReactFlow
890
1119
  nodes={displayNodes}
891
- edges={rfEdges}
1120
+ edges={displayEdges}
892
1121
  nodeTypes={nodeTypes}
893
1122
  edgeTypes={edgeTypes}
894
1123
  className={GRAPH_CANVAS_CLASS}
@@ -911,7 +1140,11 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
911
1140
  >
912
1141
  <GraphChrome />
913
1142
  </ReactFlow>
914
- {ready && labelPositions.size > 0 && (
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 && (
915
1148
  <C4EdgeLabels positions={labelPositions} edges={model.edges} />
916
1149
  )}
917
1150
  {!ready && (