@principal-ai/subsystems-react 0.53.3 → 0.53.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +42 -5
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +253 -101
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +55 -2
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +145 -35
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +10 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +13 -5
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +2 -2
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +39 -9
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +28 -6
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +19 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +37 -10
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -1
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +1 -1
- package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +117 -16
- package/src/subsystem/C4Graph.groups.test.ts +202 -12
- package/src/subsystem/C4Graph.tsx +297 -107
- package/src/subsystem/C4NodeCard.tsx +162 -34
- package/src/subsystem/IssueList.tsx +30 -5
- package/src/subsystem/SubsystemComponentGraph.tsx +11 -1
- package/src/subsystem/c4.ts +50 -11
- package/src/utils/elkLayout.test.ts +23 -0
- package/src/utils/elkLayout.ts +63 -11
|
@@ -31,22 +31,50 @@ import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, poi
|
|
|
31
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
33
|
import { deriveC4Groups, protocolColor, type C4Group } from './c4';
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
C4NodeCard,
|
|
36
|
+
NODE_H,
|
|
37
|
+
NODE_W,
|
|
38
|
+
OPEN_FOOTER_PAD,
|
|
39
|
+
OPEN_HEADER_PAD,
|
|
40
|
+
OPEN_SIDE_PAD,
|
|
41
|
+
nodeSize,
|
|
42
|
+
nodeStyle,
|
|
43
|
+
} from './C4NodeCard';
|
|
35
44
|
import { TechMark, technologyBrand } from './techIcons';
|
|
36
45
|
import type { TechBrand } from './techIcons';
|
|
37
46
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
38
47
|
import type { C4Element, C4Model } from './c4';
|
|
48
|
+
import { openContainerSet, openStateKey } from './c4';
|
|
39
49
|
|
|
40
50
|
export interface C4GraphProps {
|
|
41
51
|
model: C4Model;
|
|
42
52
|
onSelectNode?: (id: string | null) => void;
|
|
43
53
|
/**
|
|
44
|
-
* Drill-down
|
|
45
|
-
*
|
|
46
|
-
*
|
|
54
|
+
* Drill-down: add `id` to `model.openContainerIds`, or remove it if it is
|
|
55
|
+
* already open. One verb for both directions, because with several containers
|
|
56
|
+
* open at once "close" is ambiguous — the affordance on an open card has to
|
|
57
|
+
* close *that* card without disturbing the others.
|
|
58
|
+
*
|
|
59
|
+
* This is the verb to use. The two below remain for callers written against
|
|
60
|
+
* the single-container drill-down, and are only consulted when this is absent.
|
|
61
|
+
*/
|
|
62
|
+
onToggleContainer?: (id: string) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Drill-down. Called from the expand affordance on a *closed* container card;
|
|
65
|
+
* the host adds `id` to `model.openContainerIds` to swap the box for a grown
|
|
66
|
+
* card with its components inside.
|
|
67
|
+
*
|
|
68
|
+
* @deprecated Prefer {@link C4GraphProps.onToggleContainer}, which also covers
|
|
69
|
+
* the open case.
|
|
47
70
|
*/
|
|
48
71
|
onOpenContainer?: (id: string) => void;
|
|
49
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Called from the collapse affordance on an opened container card. Closes
|
|
74
|
+
* everything when several containers are open, because it carries no id.
|
|
75
|
+
*
|
|
76
|
+
* @deprecated Prefer {@link C4GraphProps.onToggleContainer}, which closes one.
|
|
77
|
+
*/
|
|
50
78
|
onCloseContainer?: () => void;
|
|
51
79
|
/**
|
|
52
80
|
* Breathing room around each card, in flow px, applied as ELK spacing (not by
|
|
@@ -472,17 +500,32 @@ export function rebaseGroupBoundsAbsolute(
|
|
|
472
500
|
* the frame is the origin its contents are laid out from — as a sibling, or as a
|
|
473
501
|
* flat union, the frame has no stable corner and the whole boundary slides as
|
|
474
502
|
* ELK re-places its members.
|
|
503
|
+
*
|
|
504
|
+
* Each opened container becomes its own ELK parent, so any number of them can be
|
|
505
|
+
* open at once. They are emitted in sorted order (the order `openContainerSet`
|
|
506
|
+
* gives) purely so the group list — and therefore the layout — is deterministic
|
|
507
|
+
* for a given set.
|
|
475
508
|
*/
|
|
476
509
|
export function c4GroupDefs(
|
|
477
510
|
model: C4Model,
|
|
478
|
-
|
|
511
|
+
openIds?: readonly string[],
|
|
479
512
|
): { frames: C4Group[]; groups: CompoundGroupDef[] } {
|
|
513
|
+
// Normalized here rather than trusted from the caller. This function's output is
|
|
514
|
+
// cached under the open *set*, so a repeated id would emit the same group twice
|
|
515
|
+
// (ELK rejects a duplicate id) and a different order would produce a different
|
|
516
|
+
// group list for a diagram that did not change. An explicit argument wins over
|
|
517
|
+
// the model's own field, so a caller driving a state the model does not describe
|
|
518
|
+
// gets exactly that state.
|
|
519
|
+
const resolved = openContainerSet(
|
|
520
|
+
openIds !== undefined ? { openContainerIds: openIds } : model,
|
|
521
|
+
);
|
|
522
|
+
const opened = new Set(resolved);
|
|
480
523
|
// Every frame this layout draws: the system boundary, plus a container frame per
|
|
481
524
|
// container whose components are in view (the component view leans on these).
|
|
482
|
-
//
|
|
525
|
+
// Each *opened* container's frame is dropped — it is drawn as a grown card
|
|
483
526
|
// instead, as an ELK parent below.
|
|
484
|
-
const frames = deriveC4Groups({ ...model,
|
|
485
|
-
(g) => g.id
|
|
527
|
+
const frames = deriveC4Groups({ ...model, openContainerIds: resolved }).filter(
|
|
528
|
+
(g) => !opened.has(g.id),
|
|
486
529
|
);
|
|
487
530
|
const groups: CompoundGroupDef[] = frames.map((g) => ({
|
|
488
531
|
id: g.id,
|
|
@@ -494,28 +537,35 @@ export function c4GroupDefs(
|
|
|
494
537
|
// components — the "layout B" the closed layout transitions to. Only in the
|
|
495
538
|
// container view: that is where a container is a card, and where the drill-down
|
|
496
539
|
// is offered.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
540
|
+
if (model.view === 'container') {
|
|
541
|
+
for (const openId of resolved) {
|
|
542
|
+
const openMembers = model.nodes
|
|
543
|
+
.filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
|
|
544
|
+
.map((n) => n.id);
|
|
545
|
+
if (openMembers.length === 0) continue;
|
|
546
|
+
groups.push({
|
|
547
|
+
id: openId,
|
|
548
|
+
memberIds: openMembers,
|
|
549
|
+
// INSIDE the system frame, not beside it. The boundary *is* the system, so
|
|
550
|
+
// a sibling here would draw the opened container outside the very boundary
|
|
551
|
+
// it belongs to.
|
|
552
|
+
parentId: model.system.id,
|
|
553
|
+
// Never narrower than the closed card, so opening only ever grows the box.
|
|
554
|
+
minWidth: NODE_W,
|
|
555
|
+
// Room for the container's own label slot + technology row.
|
|
556
|
+
padTop: OPEN_HEADER_PAD,
|
|
557
|
+
// Room for the collapse affordance, which is absolutely positioned and so
|
|
558
|
+
// takes no space in the card's flow. Without it the fitted height stops at
|
|
559
|
+
// the last component row and that row is drawn through the button.
|
|
560
|
+
padBottom: OPEN_FOOTER_PAD,
|
|
561
|
+
// Breathing room at the sides, so the nested cards read as held inside the
|
|
562
|
+
// container rather than flush with the chrome above them.
|
|
563
|
+
padX: OPEN_SIDE_PAD,
|
|
564
|
+
// Drawn as a card, laid out as a group — both are needed, and only ELK
|
|
565
|
+
// knows which one a bare member id means.
|
|
566
|
+
drawnAsCard: true,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
519
569
|
}
|
|
520
570
|
|
|
521
571
|
return { frames, groups };
|
|
@@ -571,7 +621,32 @@ function useFlip(from: FlowRect | undefined, to: FlowRect | undefined) {
|
|
|
571
621
|
};
|
|
572
622
|
}
|
|
573
623
|
|
|
574
|
-
|
|
624
|
+
/**
|
|
625
|
+
* Whether a nested component is withheld while the diagram is mid-morph.
|
|
626
|
+
*
|
|
627
|
+
* Only for a component whose *own* container is opening or closing right now.
|
|
628
|
+
* A container that was already open is not mid-grow — it is gliding to a new slot
|
|
629
|
+
* because ELK re-flowed the diagram around a different change, and React Flow
|
|
630
|
+
* carries its children along with it. Blanking its components anyway is what
|
|
631
|
+
* makes a multi-container drill-down read as a reset on every click, which is the
|
|
632
|
+
* opposite of the state it is meant to keep.
|
|
633
|
+
*
|
|
634
|
+
* `openIds` and `morphTargets` are sets because both are read once per node per
|
|
635
|
+
* render; a list would be a scan per node.
|
|
636
|
+
*/
|
|
637
|
+
export function hidesComponentDuringMorph(
|
|
638
|
+
owner: string | undefined,
|
|
639
|
+
openIds: ReadonlySet<string>,
|
|
640
|
+
morphTargets: ReadonlySet<string>,
|
|
641
|
+
settled: boolean,
|
|
642
|
+
): boolean {
|
|
643
|
+
// Settled: nothing is withheld. No owner: not nested under a container at all.
|
|
644
|
+
// Not open: its container is a closed card, so there is nothing to sit inside.
|
|
645
|
+
if (settled || !owner || !openIds.has(owner)) return false;
|
|
646
|
+
return morphTargets.has(owner);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
|
|
575
650
|
const { theme } = useTheme();
|
|
576
651
|
const { fitView } = useReactFlow();
|
|
577
652
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -584,8 +659,33 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
584
659
|
);
|
|
585
660
|
/** Set by the layout effect; consumed by the `ready` effect to run the fit. */
|
|
586
661
|
const pendingFitRef = useRef(false);
|
|
587
|
-
/**
|
|
662
|
+
/**
|
|
663
|
+
* Prefetched layouts, keyed by {@link openStateKey} of the open set ('' =
|
|
664
|
+
* closed).
|
|
665
|
+
*
|
|
666
|
+
* Capped because the key space grew: with one container open at a time there
|
|
667
|
+
* were exactly N+1 states, but a drill-down that keeps containers open reaches
|
|
668
|
+
* a different set on every click, and the prefetch fans out one step further
|
|
669
|
+
* from each. Map preserves insertion order, so the first key is the oldest —
|
|
670
|
+
* evicting it drops a layout that is furthest from the current state, which is
|
|
671
|
+
* the one least likely to be needed next.
|
|
672
|
+
*/
|
|
588
673
|
const layoutCache = useRef(new Map<string, Rendered>());
|
|
674
|
+
/** How many layouts to keep. Sized for a large diagram, not a typical one. */
|
|
675
|
+
const LAYOUT_CACHE_MAX = 64;
|
|
676
|
+
const cacheLayout = (key: string, layout: Rendered) => {
|
|
677
|
+
const cache = layoutCache.current;
|
|
678
|
+
// Re-insert so a cache hit counts as recent use and survives eviction.
|
|
679
|
+
cache.delete(key);
|
|
680
|
+
cache.set(key, layout);
|
|
681
|
+
for (const oldest of cache.keys()) {
|
|
682
|
+
if (cache.size <= LAYOUT_CACHE_MAX) break;
|
|
683
|
+
// Never evict what is on screen: a re-layout of the current state mid-morph
|
|
684
|
+
// would swap the geometry out from under the transition.
|
|
685
|
+
if (oldest === openStateKey(openIds)) continue;
|
|
686
|
+
cache.delete(oldest);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
589
689
|
/**
|
|
590
690
|
* False while the drill-down grow/shrink is in flight. It gates the whole reveal:
|
|
591
691
|
* the opened container's components stay hidden, every card drops its
|
|
@@ -604,17 +704,68 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
604
704
|
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
605
705
|
/** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
|
|
606
706
|
const cameraTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
607
|
-
/**
|
|
608
|
-
|
|
707
|
+
/**
|
|
708
|
+
* The open set rendered last, so a change can morph between the two layouts.
|
|
709
|
+
* The array, not just its key, because the morph needs to know *which*
|
|
710
|
+
* containers changed — see `morphTargetsRef`.
|
|
711
|
+
*/
|
|
712
|
+
const prevOpenRef = useRef<string[]>([]);
|
|
713
|
+
/**
|
|
714
|
+
* Containers whose open state is changing *right now* — the symmetric
|
|
715
|
+
* difference of the previous and next open sets.
|
|
716
|
+
*
|
|
717
|
+
* This is what keeps an already-open container from blinking when a second one
|
|
718
|
+
* is opened. The grow is gated per container, not globally: ELK re-lays out the
|
|
719
|
+
* whole diagram, so every root card glides, but only the one being opened or
|
|
720
|
+
* closed has components that are genuinely arriving or leaving. Hiding every
|
|
721
|
+
* nested component for the length of the morph would make each open look like a
|
|
722
|
+
* wholesale reset of the drill-down, which is the opposite of the state the
|
|
723
|
+
* drill-down is supposed to be keeping.
|
|
724
|
+
*/
|
|
725
|
+
const morphTargetsRef = useRef<Set<string>>(new Set());
|
|
609
726
|
|
|
610
727
|
/**
|
|
611
|
-
* Frame the whole
|
|
612
|
-
*
|
|
613
|
-
*
|
|
728
|
+
* Frame the whole diagram: the system boundary *and* everything drawn outside
|
|
729
|
+
* it.
|
|
730
|
+
*
|
|
731
|
+
* Named explicitly rather than left to the drawn nodes, because on a drill-down
|
|
732
|
+
* those include the opened container's components — fitting all of them would
|
|
733
|
+
* zoom past the boundary into one container's contents. But naming only the
|
|
734
|
+
* boundary is the other half of the mistake: externals and people sit outside
|
|
735
|
+
* it by definition, so fitting the frame alone crops exactly the things the
|
|
736
|
+
* boundary is drawn in contrast to.
|
|
614
737
|
*/
|
|
615
738
|
const fitSystem = useCallback(() => {
|
|
616
|
-
|
|
617
|
-
|
|
739
|
+
const outside = model.nodes
|
|
740
|
+
.filter((n) => n.kind === 'external-system' || n.kind === 'person')
|
|
741
|
+
.map((n) => ({ id: n.id }));
|
|
742
|
+
fitView({
|
|
743
|
+
padding: 0.15,
|
|
744
|
+
duration: CAMERA_MS,
|
|
745
|
+
maxZoom: 1,
|
|
746
|
+
nodes: [{ id: model.system.id }, ...outside],
|
|
747
|
+
});
|
|
748
|
+
}, [fitView, model.system.id, model.nodes]);
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The one drill-down verb. `onToggleContainer` says which container changed, so
|
|
752
|
+
* the host can add or remove exactly that one; the deprecated pair carries no
|
|
753
|
+
* id on the close side, so it can only close everything — correct for the
|
|
754
|
+
* single-container drill-down it was written for, and the reason it is not the
|
|
755
|
+
* preferred spelling.
|
|
756
|
+
*/
|
|
757
|
+
const canToggle = !!(onToggleContainer || onOpenContainer);
|
|
758
|
+
const toggle = useCallback(
|
|
759
|
+
(id: string) => {
|
|
760
|
+
if (onToggleContainer) {
|
|
761
|
+
onToggleContainer(id);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (openContainerSet(model).includes(id)) onCloseContainer?.();
|
|
765
|
+
else onOpenContainer?.(id);
|
|
766
|
+
},
|
|
767
|
+
[onToggleContainer, onOpenContainer, onCloseContainer, model],
|
|
768
|
+
);
|
|
618
769
|
|
|
619
770
|
// Fit the camera AFTER the new nodes are committed (when `ready` flips true),
|
|
620
771
|
// so `fitView` measures the real geometry.
|
|
@@ -629,28 +780,36 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
629
780
|
pendingFitRef.current = shouldRefitCamera(grows);
|
|
630
781
|
};
|
|
631
782
|
|
|
783
|
+
// Normalized once and shared by everything downstream — the cache key, the
|
|
784
|
+
// membership tests, and the prefetch. Derived per model rather than inlined,
|
|
785
|
+
// because a permutation of the same set is the same diagram and must not read
|
|
786
|
+
// as a change.
|
|
787
|
+
const openIds = useMemo(() => openContainerSet(model), [model]);
|
|
788
|
+
const openKey = openStateKey(openIds);
|
|
789
|
+
|
|
632
790
|
const layoutKey = useMemo(
|
|
633
791
|
() =>
|
|
634
792
|
[
|
|
635
793
|
...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
|
|
636
794
|
...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
|
|
637
|
-
`open:${
|
|
795
|
+
`open:${openKey}`,
|
|
638
796
|
].join('\n'),
|
|
639
|
-
[model],
|
|
797
|
+
[model, openKey],
|
|
640
798
|
);
|
|
641
799
|
|
|
642
800
|
/**
|
|
643
801
|
* Build the React Flow inputs for a given open state — everything ELK needs,
|
|
644
|
-
* independent of whether a layout has been run yet. Pure over `
|
|
802
|
+
* independent of whether a layout has been run yet. Pure over `openIds`, so it
|
|
645
803
|
* serves both the rendered state and the background prefetch.
|
|
646
804
|
*/
|
|
647
|
-
const buildInputs = (
|
|
805
|
+
const buildInputs = (openIds: readonly string[]) => {
|
|
806
|
+
const opened = new Set(openIds);
|
|
648
807
|
// Draw only the elements for this view. A container diagram shows the
|
|
649
808
|
// runtime units (containers, externals, people); components belong to the
|
|
650
809
|
// component view — or to a container that has been opened.
|
|
651
810
|
const inView = (n: C4Element): boolean => {
|
|
652
811
|
if (model.view === 'component') return n.kind !== 'container';
|
|
653
|
-
if (n.kind === 'component') return (n.container ?? n.parentId
|
|
812
|
+
if (n.kind === 'component') return opened.has(n.container ?? n.parentId ?? '');
|
|
654
813
|
// A container is always drawn — it is a card either way. Opened, it grows
|
|
655
814
|
// and nests its components; closed, it is the bare box.
|
|
656
815
|
return true;
|
|
@@ -665,10 +824,14 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
665
824
|
|
|
666
825
|
const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
|
|
667
826
|
const size = nodeSize(n);
|
|
668
|
-
// A component of
|
|
669
|
-
// so edges between components can be drawn later.
|
|
670
|
-
// authored parentId.
|
|
671
|
-
|
|
827
|
+
// A component of an opened container nests under *its own* container (a
|
|
828
|
+
// React Flow child), so edges between components can be drawn later.
|
|
829
|
+
// Everything else keeps its authored parentId. The parent is this
|
|
830
|
+
// component's own container, not "the" open one — with several open at once
|
|
831
|
+
// there is no single open one, and nesting everything under the first would
|
|
832
|
+
// put one container's components inside the other.
|
|
833
|
+
const owner = n.kind === 'component' ? (n.container ?? n.parentId) : undefined;
|
|
834
|
+
const nested = owner && opened.has(owner) ? owner : undefined;
|
|
672
835
|
const parentId = nested ?? n.parentId;
|
|
673
836
|
return {
|
|
674
837
|
id: n.id,
|
|
@@ -687,20 +850,14 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
687
850
|
componentCount: childComponentCount.get(n.id) ?? 0,
|
|
688
851
|
// A container with components opens; the same affordance closes it
|
|
689
852
|
// again, so the toggle lives in one place.
|
|
690
|
-
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 &&
|
|
691
|
-
? {
|
|
692
|
-
onExpand: () => {
|
|
693
|
-
if (n.id === openId) onCloseContainer?.();
|
|
694
|
-
else onOpenContainer?.(n.id);
|
|
695
|
-
},
|
|
696
|
-
open: n.id === openId,
|
|
697
|
-
}
|
|
853
|
+
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
|
|
854
|
+
? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
|
|
698
855
|
: {}),
|
|
699
856
|
},
|
|
700
857
|
};
|
|
701
858
|
});
|
|
702
859
|
|
|
703
|
-
const { frames, groups } = c4GroupDefs(model,
|
|
860
|
+
const { frames, groups } = c4GroupDefs(model, openIds);
|
|
704
861
|
|
|
705
862
|
// Bands come from the *frames* only. The opened container is an ELK parent,
|
|
706
863
|
// not a band — treating it as one would push it into its own layer, away from
|
|
@@ -740,10 +897,11 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
740
897
|
* edges. The heavy step the prefetch caches.
|
|
741
898
|
*/
|
|
742
899
|
const layoutFor = async (
|
|
743
|
-
|
|
900
|
+
openIds: readonly string[],
|
|
744
901
|
inputs: ReturnType<typeof buildInputs>,
|
|
745
902
|
): Promise<Rendered> => {
|
|
746
903
|
const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
|
|
904
|
+
const opened = new Set(openIds);
|
|
747
905
|
// The system boundary is a REAL ELK parent: its containers lay out *inside* it,
|
|
748
906
|
// so ELK owns both the frame's origin and its size. That is what holds the
|
|
749
907
|
// boundary's top-left corner steady between the two layouts, which is the whole
|
|
@@ -786,12 +944,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
786
944
|
// reads it as absolute, so normalize it once at the boundary.
|
|
787
945
|
const groupBounds = rebaseGroupBoundsAbsolute(result);
|
|
788
946
|
const builtGroups = new Set(groupBounds.keys());
|
|
789
|
-
// The opened
|
|
790
|
-
// `groupBounds` — but
|
|
791
|
-
// from the shells; keep
|
|
792
|
-
// their coordinates relative to
|
|
947
|
+
// The opened containers come back from ELK as parents, so they land in
|
|
948
|
+
// `groupBounds` — but each is drawn as a grown *card*, not a frame. Drop them
|
|
949
|
+
// all from the shells; keep them in `groupBounds` so their nested children can
|
|
950
|
+
// take their coordinates relative to them.
|
|
793
951
|
const shellBounds = new Map(groupBounds);
|
|
794
|
-
|
|
952
|
+
for (const openId of openIds) shellBounds.delete(openId);
|
|
795
953
|
|
|
796
954
|
const groupById = new Map(frames.map((g) => [g.id, g]));
|
|
797
955
|
const depthOf = (id: string): number => {
|
|
@@ -847,8 +1005,8 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
847
1005
|
...(def?.kind !== 'system' && element
|
|
848
1006
|
? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
|
|
849
1007
|
: {}),
|
|
850
|
-
...(def?.kind === 'container' &&
|
|
851
|
-
? { onCollapse: () =>
|
|
1008
|
+
...(def?.kind === 'container' && opened.has(id)
|
|
1009
|
+
? { onCollapse: () => toggle(id) }
|
|
852
1010
|
: {}),
|
|
853
1011
|
},
|
|
854
1012
|
};
|
|
@@ -858,14 +1016,13 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
858
1016
|
let parentId: string | undefined;
|
|
859
1017
|
if (n.data && typeof n.data === 'object') {
|
|
860
1018
|
const modelNode = (n.data as { element?: C4Element }).element;
|
|
861
|
-
//
|
|
862
|
-
// containers and out-of-boundary nodes in the same layout must
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
(modelNode.container ?? modelNode.parentId)
|
|
867
|
-
|
|
868
|
-
: undefined;
|
|
1019
|
+
// A component nests under its own container when that container is open —
|
|
1020
|
+
// the sibling containers and out-of-boundary nodes in the same layout must
|
|
1021
|
+
// not. Reads the component's own owner rather than a single open id,
|
|
1022
|
+
// because several containers can be open at once.
|
|
1023
|
+
const owner =
|
|
1024
|
+
modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
|
|
1025
|
+
const nested = owner && opened.has(owner) ? owner : undefined;
|
|
869
1026
|
const candidate = nested ?? modelNode?.parentId;
|
|
870
1027
|
if (candidate && builtGroups.has(candidate)) parentId = candidate;
|
|
871
1028
|
}
|
|
@@ -875,9 +1032,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
875
1032
|
flowPositionOf(result, n.id, parentId) ??
|
|
876
1033
|
positioned.get(n.id)?.position ??
|
|
877
1034
|
n.position;
|
|
878
|
-
//
|
|
1035
|
+
// An opened container is an ELK parent, so its box is whatever ELK fitted
|
|
879
1036
|
// to the nested components — `nodeSize` only knows the closed card.
|
|
880
|
-
const fitted =
|
|
1037
|
+
const fitted = opened.has(n.id) ? groupBounds.get(n.id) : undefined;
|
|
881
1038
|
const node = {
|
|
882
1039
|
...n,
|
|
883
1040
|
position: { x: abs.x, y: abs.y },
|
|
@@ -922,15 +1079,31 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
922
1079
|
let alive = true;
|
|
923
1080
|
setReady(false);
|
|
924
1081
|
setSelectedId(null);
|
|
925
|
-
const
|
|
1082
|
+
const openKeyNow = openStateKey(openIds);
|
|
926
1083
|
const prevOpen = prevOpenRef.current;
|
|
927
|
-
const inputs = buildInputs(
|
|
1084
|
+
const inputs = buildInputs(openIds);
|
|
928
1085
|
|
|
929
1086
|
// Only a real open-state change animates; the first paint is already at rest.
|
|
930
|
-
if (paintedRef.current && prevOpen !==
|
|
1087
|
+
if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
|
|
1088
|
+
// The containers in one set and not the other are the ones whose components
|
|
1089
|
+
// actually arrive or leave. Recorded as a ref rather than state: it is read
|
|
1090
|
+
// during the next render (to decide what to hide) but must not itself
|
|
1091
|
+
// schedule a render.
|
|
1092
|
+
const before = new Set(prevOpen);
|
|
1093
|
+
const after = new Set(openIds);
|
|
1094
|
+
morphTargetsRef.current = new Set([
|
|
1095
|
+
...prevOpen.filter((id) => !after.has(id)),
|
|
1096
|
+
...openIds.filter((id) => !before.has(id)),
|
|
1097
|
+
]);
|
|
931
1098
|
setSettled(false);
|
|
932
1099
|
if (settleTimer.current) clearTimeout(settleTimer.current);
|
|
933
|
-
settleTimer.current = setTimeout(() =>
|
|
1100
|
+
settleTimer.current = setTimeout(() => {
|
|
1101
|
+
setSettled(true);
|
|
1102
|
+
// The morph is over. Clearing the targets matters: an unrelated re-render
|
|
1103
|
+
// afterwards must not find a container still marked mid-morph and hide
|
|
1104
|
+
// components that are merely sitting still.
|
|
1105
|
+
morphTargetsRef.current = new Set();
|
|
1106
|
+
}, MORPH_MS);
|
|
934
1107
|
// Re-frame once the grow has landed, not while it is running. The drill-down
|
|
935
1108
|
// more than doubles the boundary, so the extent the camera was fitted to no
|
|
936
1109
|
// longer contains the content — but moving *during* the morph compounds with
|
|
@@ -950,13 +1123,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
950
1123
|
* Frames alone are not enough: ELK re-lays out the whole diagram, so every
|
|
951
1124
|
* root card moves too. Without a card in this set it keeps its new slot the
|
|
952
1125
|
* instant the layout swaps — a teleport, which is the most visible part of a
|
|
953
|
-
* bad morph. Child nodes are excluded:
|
|
954
|
-
*
|
|
955
|
-
* the parent's.
|
|
1126
|
+
* bad morph. Child nodes are excluded: a transform on them would only fight
|
|
1127
|
+
* the parent's, which carries the same movement.
|
|
956
1128
|
*/
|
|
957
1129
|
const growsFor = (next: Rendered): Map<string, FrameGrow> | null => {
|
|
958
|
-
if (prevOpen ===
|
|
959
|
-
const prevLayout = layoutCache.current.get(prevOpen
|
|
1130
|
+
if (openStateKey(prevOpen) === openKeyNow) return null;
|
|
1131
|
+
const prevLayout = layoutCache.current.get(openStateKey(prevOpen));
|
|
960
1132
|
if (!prevLayout) return null;
|
|
961
1133
|
const grows = new Map<string, FrameGrow>();
|
|
962
1134
|
const rootCardIds = (l: Rendered) =>
|
|
@@ -984,20 +1156,20 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
984
1156
|
return grows.size > 0 ? grows : null;
|
|
985
1157
|
};
|
|
986
1158
|
|
|
987
|
-
const cached = layoutCache.current.get(
|
|
1159
|
+
const cached = layoutCache.current.get(openKeyNow);
|
|
988
1160
|
if (cached) {
|
|
989
|
-
prevOpenRef.current =
|
|
1161
|
+
prevOpenRef.current = openIds;
|
|
990
1162
|
renderLayout(cached, growsFor(cached));
|
|
991
1163
|
return () => {
|
|
992
1164
|
alive = false;
|
|
993
1165
|
};
|
|
994
1166
|
}
|
|
995
|
-
void layoutFor(
|
|
1167
|
+
void layoutFor(openIds, inputs)
|
|
996
1168
|
.then((r) => {
|
|
997
1169
|
if (!alive) return;
|
|
998
|
-
|
|
1170
|
+
cacheLayout(openKeyNow, r);
|
|
999
1171
|
const span = growsFor(r);
|
|
1000
|
-
prevOpenRef.current =
|
|
1172
|
+
prevOpenRef.current = openIds;
|
|
1001
1173
|
renderLayout(r, span);
|
|
1002
1174
|
})
|
|
1003
1175
|
.catch((err) => {
|
|
@@ -1031,18 +1203,31 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
1031
1203
|
useEffect(() => {
|
|
1032
1204
|
if (model.view !== 'container') return;
|
|
1033
1205
|
let alive = true;
|
|
1034
|
-
const
|
|
1206
|
+
const openable = model.nodes.filter(
|
|
1035
1207
|
(n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id),
|
|
1036
1208
|
);
|
|
1209
|
+
// One click ahead, and only one: opening `c` next means the current set plus
|
|
1210
|
+
// `c`. From the closed diagram that is just "open c", so the same rule covers
|
|
1211
|
+
// the first click on any container and every one after it — no separate path
|
|
1212
|
+
// for "nothing is open yet".
|
|
1213
|
+
//
|
|
1214
|
+
// Deliberately not the full power set. With N containers that would be 2^N
|
|
1215
|
+
// ELK runs and most of them unreachable; N-1 runs per visited state keeps the
|
|
1216
|
+
// next click instant, which is the only reason this exists. The cache is
|
|
1217
|
+
// capped below because the reachable states now grow with the states visited
|
|
1218
|
+
// rather than staying at N.
|
|
1219
|
+
const targets = openable
|
|
1220
|
+
.filter((c) => !openIds.includes(c.id))
|
|
1221
|
+
.map((c) => [...openIds, c.id].sort());
|
|
1037
1222
|
void (async () => {
|
|
1038
|
-
for (const
|
|
1223
|
+
for (const target of targets) {
|
|
1039
1224
|
if (!alive) return;
|
|
1040
|
-
|
|
1225
|
+
const key = openStateKey(target);
|
|
1226
|
+
if (layoutCache.current.has(key)) continue;
|
|
1041
1227
|
try {
|
|
1042
|
-
const
|
|
1043
|
-
const r = await layoutFor(c.id, inputs);
|
|
1228
|
+
const r = await layoutFor(target, buildInputs(target));
|
|
1044
1229
|
if (!alive) return;
|
|
1045
|
-
|
|
1230
|
+
cacheLayout(key, r);
|
|
1046
1231
|
} catch {
|
|
1047
1232
|
// Prefetch is best-effort; a miss just falls back to compute-on-click.
|
|
1048
1233
|
}
|
|
@@ -1077,28 +1262,33 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
1077
1262
|
[model, selected],
|
|
1078
1263
|
);
|
|
1079
1264
|
|
|
1080
|
-
/** The
|
|
1081
|
-
const
|
|
1265
|
+
/** The containers open for drill-down, normalized. */
|
|
1266
|
+
const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
|
|
1082
1267
|
|
|
1083
1268
|
const displayNodes = useMemo(
|
|
1084
1269
|
() =>
|
|
1085
1270
|
nodes.map((n) => {
|
|
1086
1271
|
if (n.type !== 'c4-node') return n;
|
|
1087
1272
|
const element = (n.data as { element?: C4Element } | undefined)?.element;
|
|
1088
|
-
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1273
|
+
const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
|
|
1274
|
+
// `hidesComponentDuringMorph` owns the rule; see there for why it is
|
|
1275
|
+
// per-container rather than global.
|
|
1276
|
+
const hidden = hidesComponentDuringMorph(
|
|
1277
|
+
owner,
|
|
1278
|
+
openContainerIds,
|
|
1279
|
+
morphTargetsRef.current,
|
|
1280
|
+
settled,
|
|
1281
|
+
);
|
|
1095
1282
|
return {
|
|
1096
1283
|
...n,
|
|
1097
|
-
...(
|
|
1284
|
+
...(hidden ? { hidden: true } : {}),
|
|
1098
1285
|
data: { ...n.data, selected: n.id === selectedId, settled },
|
|
1099
1286
|
};
|
|
1100
1287
|
}),
|
|
1101
|
-
|
|
1288
|
+
// `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
|
|
1289
|
+
// it, because the two always change together — targets are written in the same
|
|
1290
|
+
// tick that clears `settled`, and cleared in the same tick that restores it.
|
|
1291
|
+
[nodes, selectedId, settled, openContainerIds],
|
|
1102
1292
|
);
|
|
1103
1293
|
|
|
1104
1294
|
/**
|