@principal-ai/subsystems-react 0.53.4 → 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 +144 -11
- package/dist/subsystem/C4NodeCard.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 +9 -4
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -1
- package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +53 -22
- package/src/subsystem/C4Graph.groups.test.ts +202 -12
- package/src/subsystem/C4Graph.tsx +297 -107
- package/src/subsystem/C4NodeCard.tsx +162 -11
- package/src/subsystem/c4.ts +50 -11
- package/src/utils/elkLayout.test.ts +23 -0
- package/src/utils/elkLayout.ts +34 -5
|
@@ -18,9 +18,10 @@ import { Box, Minimize2 } from 'lucide-react';
|
|
|
18
18
|
import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
19
19
|
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
|
|
20
20
|
import { deriveC4Groups, protocolColor } from './c4';
|
|
21
|
-
import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
|
|
21
|
+
import { C4NodeCard, NODE_H, NODE_W, OPEN_FOOTER_PAD, OPEN_HEADER_PAD, OPEN_SIDE_PAD, nodeSize, nodeStyle, } from './C4NodeCard';
|
|
22
22
|
import { TechMark, technologyBrand } from './techIcons';
|
|
23
23
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
24
|
+
import { openContainerSet, openStateKey } from './c4';
|
|
24
25
|
function C4NodeView(props) {
|
|
25
26
|
const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } = props.data;
|
|
26
27
|
const grown = open === true;
|
|
@@ -294,13 +295,26 @@ export function rebaseGroupBoundsAbsolute(result) {
|
|
|
294
295
|
* the frame is the origin its contents are laid out from — as a sibling, or as a
|
|
295
296
|
* flat union, the frame has no stable corner and the whole boundary slides as
|
|
296
297
|
* ELK re-places its members.
|
|
298
|
+
*
|
|
299
|
+
* Each opened container becomes its own ELK parent, so any number of them can be
|
|
300
|
+
* open at once. They are emitted in sorted order (the order `openContainerSet`
|
|
301
|
+
* gives) purely so the group list — and therefore the layout — is deterministic
|
|
302
|
+
* for a given set.
|
|
297
303
|
*/
|
|
298
|
-
export function c4GroupDefs(model,
|
|
304
|
+
export function c4GroupDefs(model, openIds) {
|
|
305
|
+
// Normalized here rather than trusted from the caller. This function's output is
|
|
306
|
+
// cached under the open *set*, so a repeated id would emit the same group twice
|
|
307
|
+
// (ELK rejects a duplicate id) and a different order would produce a different
|
|
308
|
+
// group list for a diagram that did not change. An explicit argument wins over
|
|
309
|
+
// the model's own field, so a caller driving a state the model does not describe
|
|
310
|
+
// gets exactly that state.
|
|
311
|
+
const resolved = openContainerSet(openIds !== undefined ? { openContainerIds: openIds } : model);
|
|
312
|
+
const opened = new Set(resolved);
|
|
299
313
|
// Every frame this layout draws: the system boundary, plus a container frame per
|
|
300
314
|
// container whose components are in view (the component view leans on these).
|
|
301
|
-
//
|
|
315
|
+
// Each *opened* container's frame is dropped — it is drawn as a grown card
|
|
302
316
|
// instead, as an ELK parent below.
|
|
303
|
-
const frames = deriveC4Groups({ ...model,
|
|
317
|
+
const frames = deriveC4Groups({ ...model, openContainerIds: resolved }).filter((g) => !opened.has(g.id));
|
|
304
318
|
const groups = frames.map((g) => ({
|
|
305
319
|
id: g.id,
|
|
306
320
|
memberIds: g.memberIds,
|
|
@@ -310,27 +324,36 @@ export function c4GroupDefs(model, openId) {
|
|
|
310
324
|
// components — the "layout B" the closed layout transitions to. Only in the
|
|
311
325
|
// container view: that is where a container is a card, and where the drill-down
|
|
312
326
|
// is offered.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
327
|
+
if (model.view === 'container') {
|
|
328
|
+
for (const openId of resolved) {
|
|
329
|
+
const openMembers = model.nodes
|
|
330
|
+
.filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
|
|
331
|
+
.map((n) => n.id);
|
|
332
|
+
if (openMembers.length === 0)
|
|
333
|
+
continue;
|
|
334
|
+
groups.push({
|
|
335
|
+
id: openId,
|
|
336
|
+
memberIds: openMembers,
|
|
337
|
+
// INSIDE the system frame, not beside it. The boundary *is* the system, so
|
|
338
|
+
// a sibling here would draw the opened container outside the very boundary
|
|
339
|
+
// it belongs to.
|
|
340
|
+
parentId: model.system.id,
|
|
341
|
+
// Never narrower than the closed card, so opening only ever grows the box.
|
|
342
|
+
minWidth: NODE_W,
|
|
343
|
+
// Room for the container's own label slot + technology row.
|
|
344
|
+
padTop: OPEN_HEADER_PAD,
|
|
345
|
+
// Room for the collapse affordance, which is absolutely positioned and so
|
|
346
|
+
// takes no space in the card's flow. Without it the fitted height stops at
|
|
347
|
+
// the last component row and that row is drawn through the button.
|
|
348
|
+
padBottom: OPEN_FOOTER_PAD,
|
|
349
|
+
// Breathing room at the sides, so the nested cards read as held inside the
|
|
350
|
+
// container rather than flush with the chrome above them.
|
|
351
|
+
padX: OPEN_SIDE_PAD,
|
|
352
|
+
// Drawn as a card, laid out as a group — both are needed, and only ELK
|
|
353
|
+
// knows which one a bare member id means.
|
|
354
|
+
drawnAsCard: true,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
334
357
|
}
|
|
335
358
|
return { frames, groups };
|
|
336
359
|
}
|
|
@@ -386,7 +409,27 @@ function useFlip(from, to) {
|
|
|
386
409
|
dy: from && to ? from.y - to.y : 0,
|
|
387
410
|
};
|
|
388
411
|
}
|
|
389
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Whether a nested component is withheld while the diagram is mid-morph.
|
|
414
|
+
*
|
|
415
|
+
* Only for a component whose *own* container is opening or closing right now.
|
|
416
|
+
* A container that was already open is not mid-grow — it is gliding to a new slot
|
|
417
|
+
* because ELK re-flowed the diagram around a different change, and React Flow
|
|
418
|
+
* carries its children along with it. Blanking its components anyway is what
|
|
419
|
+
* makes a multi-container drill-down read as a reset on every click, which is the
|
|
420
|
+
* opposite of the state it is meant to keep.
|
|
421
|
+
*
|
|
422
|
+
* `openIds` and `morphTargets` are sets because both are read once per node per
|
|
423
|
+
* render; a list would be a scan per node.
|
|
424
|
+
*/
|
|
425
|
+
export function hidesComponentDuringMorph(owner, openIds, morphTargets, settled) {
|
|
426
|
+
// Settled: nothing is withheld. No owner: not nested under a container at all.
|
|
427
|
+
// Not open: its container is a closed card, so there is nothing to sit inside.
|
|
428
|
+
if (settled || !owner || !openIds.has(owner))
|
|
429
|
+
return false;
|
|
430
|
+
return morphTargets.has(owner);
|
|
431
|
+
}
|
|
432
|
+
function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }) {
|
|
390
433
|
const { theme } = useTheme();
|
|
391
434
|
const { fitView } = useReactFlow();
|
|
392
435
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -397,8 +440,35 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
397
440
|
const [labelPositions, setLabelPositions] = useState(() => new Map());
|
|
398
441
|
/** Set by the layout effect; consumed by the `ready` effect to run the fit. */
|
|
399
442
|
const pendingFitRef = useRef(false);
|
|
400
|
-
/**
|
|
443
|
+
/**
|
|
444
|
+
* Prefetched layouts, keyed by {@link openStateKey} of the open set ('' =
|
|
445
|
+
* closed).
|
|
446
|
+
*
|
|
447
|
+
* Capped because the key space grew: with one container open at a time there
|
|
448
|
+
* were exactly N+1 states, but a drill-down that keeps containers open reaches
|
|
449
|
+
* a different set on every click, and the prefetch fans out one step further
|
|
450
|
+
* from each. Map preserves insertion order, so the first key is the oldest —
|
|
451
|
+
* evicting it drops a layout that is furthest from the current state, which is
|
|
452
|
+
* the one least likely to be needed next.
|
|
453
|
+
*/
|
|
401
454
|
const layoutCache = useRef(new Map());
|
|
455
|
+
/** How many layouts to keep. Sized for a large diagram, not a typical one. */
|
|
456
|
+
const LAYOUT_CACHE_MAX = 64;
|
|
457
|
+
const cacheLayout = (key, layout) => {
|
|
458
|
+
const cache = layoutCache.current;
|
|
459
|
+
// Re-insert so a cache hit counts as recent use and survives eviction.
|
|
460
|
+
cache.delete(key);
|
|
461
|
+
cache.set(key, layout);
|
|
462
|
+
for (const oldest of cache.keys()) {
|
|
463
|
+
if (cache.size <= LAYOUT_CACHE_MAX)
|
|
464
|
+
break;
|
|
465
|
+
// Never evict what is on screen: a re-layout of the current state mid-morph
|
|
466
|
+
// would swap the geometry out from under the transition.
|
|
467
|
+
if (oldest === openStateKey(openIds))
|
|
468
|
+
continue;
|
|
469
|
+
cache.delete(oldest);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
402
472
|
/**
|
|
403
473
|
* False while the drill-down grow/shrink is in flight. It gates the whole reveal:
|
|
404
474
|
* the opened container's components stay hidden, every card drops its
|
|
@@ -417,16 +487,65 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
417
487
|
const settleTimer = useRef(null);
|
|
418
488
|
/** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
|
|
419
489
|
const cameraTimer = useRef(null);
|
|
420
|
-
/** The open id rendered last, so a change can morph between the two layouts. */
|
|
421
|
-
const prevOpenRef = useRef(null);
|
|
422
490
|
/**
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
491
|
+
* The open set rendered last, so a change can morph between the two layouts.
|
|
492
|
+
* The array, not just its key, because the morph needs to know *which*
|
|
493
|
+
* containers changed — see `morphTargetsRef`.
|
|
494
|
+
*/
|
|
495
|
+
const prevOpenRef = useRef([]);
|
|
496
|
+
/**
|
|
497
|
+
* Containers whose open state is changing *right now* — the symmetric
|
|
498
|
+
* difference of the previous and next open sets.
|
|
499
|
+
*
|
|
500
|
+
* This is what keeps an already-open container from blinking when a second one
|
|
501
|
+
* is opened. The grow is gated per container, not globally: ELK re-lays out the
|
|
502
|
+
* whole diagram, so every root card glides, but only the one being opened or
|
|
503
|
+
* closed has components that are genuinely arriving or leaving. Hiding every
|
|
504
|
+
* nested component for the length of the morph would make each open look like a
|
|
505
|
+
* wholesale reset of the drill-down, which is the opposite of the state the
|
|
506
|
+
* drill-down is supposed to be keeping.
|
|
507
|
+
*/
|
|
508
|
+
const morphTargetsRef = useRef(new Set());
|
|
509
|
+
/**
|
|
510
|
+
* Frame the whole diagram: the system boundary *and* everything drawn outside
|
|
511
|
+
* it.
|
|
512
|
+
*
|
|
513
|
+
* Named explicitly rather than left to the drawn nodes, because on a drill-down
|
|
514
|
+
* those include the opened container's components — fitting all of them would
|
|
515
|
+
* zoom past the boundary into one container's contents. But naming only the
|
|
516
|
+
* boundary is the other half of the mistake: externals and people sit outside
|
|
517
|
+
* it by definition, so fitting the frame alone crops exactly the things the
|
|
518
|
+
* boundary is drawn in contrast to.
|
|
426
519
|
*/
|
|
427
520
|
const fitSystem = useCallback(() => {
|
|
428
|
-
|
|
429
|
-
|
|
521
|
+
const outside = model.nodes
|
|
522
|
+
.filter((n) => n.kind === 'external-system' || n.kind === 'person')
|
|
523
|
+
.map((n) => ({ id: n.id }));
|
|
524
|
+
fitView({
|
|
525
|
+
padding: 0.15,
|
|
526
|
+
duration: CAMERA_MS,
|
|
527
|
+
maxZoom: 1,
|
|
528
|
+
nodes: [{ id: model.system.id }, ...outside],
|
|
529
|
+
});
|
|
530
|
+
}, [fitView, model.system.id, model.nodes]);
|
|
531
|
+
/**
|
|
532
|
+
* The one drill-down verb. `onToggleContainer` says which container changed, so
|
|
533
|
+
* the host can add or remove exactly that one; the deprecated pair carries no
|
|
534
|
+
* id on the close side, so it can only close everything — correct for the
|
|
535
|
+
* single-container drill-down it was written for, and the reason it is not the
|
|
536
|
+
* preferred spelling.
|
|
537
|
+
*/
|
|
538
|
+
const canToggle = !!(onToggleContainer || onOpenContainer);
|
|
539
|
+
const toggle = useCallback((id) => {
|
|
540
|
+
if (onToggleContainer) {
|
|
541
|
+
onToggleContainer(id);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (openContainerSet(model).includes(id))
|
|
545
|
+
onCloseContainer?.();
|
|
546
|
+
else
|
|
547
|
+
onOpenContainer?.(id);
|
|
548
|
+
}, [onToggleContainer, onOpenContainer, onCloseContainer, model]);
|
|
430
549
|
// Fit the camera AFTER the new nodes are committed (when `ready` flips true),
|
|
431
550
|
// so `fitView` measures the real geometry.
|
|
432
551
|
useEffect(() => {
|
|
@@ -439,17 +558,24 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
439
558
|
const afterLayout = (grows) => {
|
|
440
559
|
pendingFitRef.current = shouldRefitCamera(grows);
|
|
441
560
|
};
|
|
561
|
+
// Normalized once and shared by everything downstream — the cache key, the
|
|
562
|
+
// membership tests, and the prefetch. Derived per model rather than inlined,
|
|
563
|
+
// because a permutation of the same set is the same diagram and must not read
|
|
564
|
+
// as a change.
|
|
565
|
+
const openIds = useMemo(() => openContainerSet(model), [model]);
|
|
566
|
+
const openKey = openStateKey(openIds);
|
|
442
567
|
const layoutKey = useMemo(() => [
|
|
443
568
|
...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
|
|
444
569
|
...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
|
|
445
|
-
`open:${
|
|
446
|
-
].join('\n'), [model]);
|
|
570
|
+
`open:${openKey}`,
|
|
571
|
+
].join('\n'), [model, openKey]);
|
|
447
572
|
/**
|
|
448
573
|
* Build the React Flow inputs for a given open state — everything ELK needs,
|
|
449
|
-
* independent of whether a layout has been run yet. Pure over `
|
|
574
|
+
* independent of whether a layout has been run yet. Pure over `openIds`, so it
|
|
450
575
|
* serves both the rendered state and the background prefetch.
|
|
451
576
|
*/
|
|
452
|
-
const buildInputs = (
|
|
577
|
+
const buildInputs = (openIds) => {
|
|
578
|
+
const opened = new Set(openIds);
|
|
453
579
|
// Draw only the elements for this view. A container diagram shows the
|
|
454
580
|
// runtime units (containers, externals, people); components belong to the
|
|
455
581
|
// component view — or to a container that has been opened.
|
|
@@ -457,7 +583,7 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
457
583
|
if (model.view === 'component')
|
|
458
584
|
return n.kind !== 'container';
|
|
459
585
|
if (n.kind === 'component')
|
|
460
|
-
return (n.container ?? n.parentId
|
|
586
|
+
return opened.has(n.container ?? n.parentId ?? '');
|
|
461
587
|
// A container is always drawn — it is a card either way. Opened, it grows
|
|
462
588
|
// and nests its components; closed, it is the bare box.
|
|
463
589
|
return true;
|
|
@@ -472,10 +598,14 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
472
598
|
}
|
|
473
599
|
const rfNodes = model.nodes.filter(inView).map((n, i) => {
|
|
474
600
|
const size = nodeSize(n);
|
|
475
|
-
// A component of
|
|
476
|
-
// so edges between components can be drawn later.
|
|
477
|
-
// authored parentId.
|
|
478
|
-
|
|
601
|
+
// A component of an opened container nests under *its own* container (a
|
|
602
|
+
// React Flow child), so edges between components can be drawn later.
|
|
603
|
+
// Everything else keeps its authored parentId. The parent is this
|
|
604
|
+
// component's own container, not "the" open one — with several open at once
|
|
605
|
+
// there is no single open one, and nesting everything under the first would
|
|
606
|
+
// put one container's components inside the other.
|
|
607
|
+
const owner = n.kind === 'component' ? (n.container ?? n.parentId) : undefined;
|
|
608
|
+
const nested = owner && opened.has(owner) ? owner : undefined;
|
|
479
609
|
const parentId = nested ?? n.parentId;
|
|
480
610
|
return {
|
|
481
611
|
id: n.id,
|
|
@@ -494,21 +624,13 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
494
624
|
componentCount: childComponentCount.get(n.id) ?? 0,
|
|
495
625
|
// A container with components opens; the same affordance closes it
|
|
496
626
|
// again, so the toggle lives in one place.
|
|
497
|
-
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 &&
|
|
498
|
-
? {
|
|
499
|
-
onExpand: () => {
|
|
500
|
-
if (n.id === openId)
|
|
501
|
-
onCloseContainer?.();
|
|
502
|
-
else
|
|
503
|
-
onOpenContainer?.(n.id);
|
|
504
|
-
},
|
|
505
|
-
open: n.id === openId,
|
|
506
|
-
}
|
|
627
|
+
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
|
|
628
|
+
? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
|
|
507
629
|
: {}),
|
|
508
630
|
},
|
|
509
631
|
};
|
|
510
632
|
});
|
|
511
|
-
const { frames, groups } = c4GroupDefs(model,
|
|
633
|
+
const { frames, groups } = c4GroupDefs(model, openIds);
|
|
512
634
|
// Bands come from the *frames* only. The opened container is an ELK parent,
|
|
513
635
|
// not a band — treating it as one would push it into its own layer, away from
|
|
514
636
|
// the containers it belongs beside.
|
|
@@ -543,8 +665,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
543
665
|
* Run ELK for one open state and turn the result into React Flow nodes /
|
|
544
666
|
* edges. The heavy step the prefetch caches.
|
|
545
667
|
*/
|
|
546
|
-
const layoutFor = async (
|
|
668
|
+
const layoutFor = async (openIds, inputs) => {
|
|
547
669
|
const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
|
|
670
|
+
const opened = new Set(openIds);
|
|
548
671
|
// The system boundary is a REAL ELK parent: its containers lay out *inside* it,
|
|
549
672
|
// so ELK owns both the frame's origin and its size. That is what holds the
|
|
550
673
|
// boundary's top-left corner steady between the two layouts, which is the whole
|
|
@@ -586,12 +709,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
586
709
|
// reads it as absolute, so normalize it once at the boundary.
|
|
587
710
|
const groupBounds = rebaseGroupBoundsAbsolute(result);
|
|
588
711
|
const builtGroups = new Set(groupBounds.keys());
|
|
589
|
-
// The opened
|
|
590
|
-
// `groupBounds` — but
|
|
591
|
-
// from the shells; keep
|
|
592
|
-
// their coordinates relative to
|
|
712
|
+
// The opened containers come back from ELK as parents, so they land in
|
|
713
|
+
// `groupBounds` — but each is drawn as a grown *card*, not a frame. Drop them
|
|
714
|
+
// all from the shells; keep them in `groupBounds` so their nested children can
|
|
715
|
+
// take their coordinates relative to them.
|
|
593
716
|
const shellBounds = new Map(groupBounds);
|
|
594
|
-
|
|
717
|
+
for (const openId of openIds)
|
|
595
718
|
shellBounds.delete(openId);
|
|
596
719
|
const groupById = new Map(frames.map((g) => [g.id, g]));
|
|
597
720
|
const depthOf = (id) => {
|
|
@@ -645,8 +768,8 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
645
768
|
...(def?.kind !== 'system' && element
|
|
646
769
|
? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
|
|
647
770
|
: {}),
|
|
648
|
-
...(def?.kind === 'container' &&
|
|
649
|
-
? { onCollapse: () =>
|
|
771
|
+
...(def?.kind === 'container' && opened.has(id)
|
|
772
|
+
? { onCollapse: () => toggle(id) }
|
|
650
773
|
: {}),
|
|
651
774
|
},
|
|
652
775
|
};
|
|
@@ -655,13 +778,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
655
778
|
let parentId;
|
|
656
779
|
if (n.data && typeof n.data === 'object') {
|
|
657
780
|
const modelNode = n.data.element;
|
|
658
|
-
//
|
|
659
|
-
// containers and out-of-boundary nodes in the same layout must
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
: undefined;
|
|
781
|
+
// A component nests under its own container when that container is open —
|
|
782
|
+
// the sibling containers and out-of-boundary nodes in the same layout must
|
|
783
|
+
// not. Reads the component's own owner rather than a single open id,
|
|
784
|
+
// because several containers can be open at once.
|
|
785
|
+
const owner = modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
|
|
786
|
+
const nested = owner && opened.has(owner) ? owner : undefined;
|
|
665
787
|
const candidate = nested ?? modelNode?.parentId;
|
|
666
788
|
if (candidate && builtGroups.has(candidate))
|
|
667
789
|
parentId = candidate;
|
|
@@ -671,9 +793,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
671
793
|
const abs = flowPositionOf(result, n.id, parentId) ??
|
|
672
794
|
positioned.get(n.id)?.position ??
|
|
673
795
|
n.position;
|
|
674
|
-
//
|
|
796
|
+
// An opened container is an ELK parent, so its box is whatever ELK fitted
|
|
675
797
|
// to the nested components — `nodeSize` only knows the closed card.
|
|
676
|
-
const fitted =
|
|
798
|
+
const fitted = opened.has(n.id) ? groupBounds.get(n.id) : undefined;
|
|
677
799
|
const node = {
|
|
678
800
|
...n,
|
|
679
801
|
position: { x: abs.x, y: abs.y },
|
|
@@ -716,15 +838,31 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
716
838
|
let alive = true;
|
|
717
839
|
setReady(false);
|
|
718
840
|
setSelectedId(null);
|
|
719
|
-
const
|
|
841
|
+
const openKeyNow = openStateKey(openIds);
|
|
720
842
|
const prevOpen = prevOpenRef.current;
|
|
721
|
-
const inputs = buildInputs(
|
|
843
|
+
const inputs = buildInputs(openIds);
|
|
722
844
|
// Only a real open-state change animates; the first paint is already at rest.
|
|
723
|
-
if (paintedRef.current && prevOpen !==
|
|
845
|
+
if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
|
|
846
|
+
// The containers in one set and not the other are the ones whose components
|
|
847
|
+
// actually arrive or leave. Recorded as a ref rather than state: it is read
|
|
848
|
+
// during the next render (to decide what to hide) but must not itself
|
|
849
|
+
// schedule a render.
|
|
850
|
+
const before = new Set(prevOpen);
|
|
851
|
+
const after = new Set(openIds);
|
|
852
|
+
morphTargetsRef.current = new Set([
|
|
853
|
+
...prevOpen.filter((id) => !after.has(id)),
|
|
854
|
+
...openIds.filter((id) => !before.has(id)),
|
|
855
|
+
]);
|
|
724
856
|
setSettled(false);
|
|
725
857
|
if (settleTimer.current)
|
|
726
858
|
clearTimeout(settleTimer.current);
|
|
727
|
-
settleTimer.current = setTimeout(() =>
|
|
859
|
+
settleTimer.current = setTimeout(() => {
|
|
860
|
+
setSettled(true);
|
|
861
|
+
// The morph is over. Clearing the targets matters: an unrelated re-render
|
|
862
|
+
// afterwards must not find a container still marked mid-morph and hide
|
|
863
|
+
// components that are merely sitting still.
|
|
864
|
+
morphTargetsRef.current = new Set();
|
|
865
|
+
}, MORPH_MS);
|
|
728
866
|
// Re-frame once the grow has landed, not while it is running. The drill-down
|
|
729
867
|
// more than doubles the boundary, so the extent the camera was fitted to no
|
|
730
868
|
// longer contains the content — but moving *during* the morph compounds with
|
|
@@ -744,14 +882,13 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
744
882
|
* Frames alone are not enough: ELK re-lays out the whole diagram, so every
|
|
745
883
|
* root card moves too. Without a card in this set it keeps its new slot the
|
|
746
884
|
* instant the layout swaps — a teleport, which is the most visible part of a
|
|
747
|
-
* bad morph. Child nodes are excluded:
|
|
748
|
-
*
|
|
749
|
-
* the parent's.
|
|
885
|
+
* bad morph. Child nodes are excluded: a transform on them would only fight
|
|
886
|
+
* the parent's, which carries the same movement.
|
|
750
887
|
*/
|
|
751
888
|
const growsFor = (next) => {
|
|
752
|
-
if (prevOpen ===
|
|
889
|
+
if (openStateKey(prevOpen) === openKeyNow)
|
|
753
890
|
return null;
|
|
754
|
-
const prevLayout = layoutCache.current.get(prevOpen
|
|
891
|
+
const prevLayout = layoutCache.current.get(openStateKey(prevOpen));
|
|
755
892
|
if (!prevLayout)
|
|
756
893
|
return null;
|
|
757
894
|
const grows = new Map();
|
|
@@ -777,21 +914,21 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
777
914
|
}
|
|
778
915
|
return grows.size > 0 ? grows : null;
|
|
779
916
|
};
|
|
780
|
-
const cached = layoutCache.current.get(
|
|
917
|
+
const cached = layoutCache.current.get(openKeyNow);
|
|
781
918
|
if (cached) {
|
|
782
|
-
prevOpenRef.current =
|
|
919
|
+
prevOpenRef.current = openIds;
|
|
783
920
|
renderLayout(cached, growsFor(cached));
|
|
784
921
|
return () => {
|
|
785
922
|
alive = false;
|
|
786
923
|
};
|
|
787
924
|
}
|
|
788
|
-
void layoutFor(
|
|
925
|
+
void layoutFor(openIds, inputs)
|
|
789
926
|
.then((r) => {
|
|
790
927
|
if (!alive)
|
|
791
928
|
return;
|
|
792
|
-
|
|
929
|
+
cacheLayout(openKeyNow, r);
|
|
793
930
|
const span = growsFor(r);
|
|
794
|
-
prevOpenRef.current =
|
|
931
|
+
prevOpenRef.current = openIds;
|
|
795
932
|
renderLayout(r, span);
|
|
796
933
|
})
|
|
797
934
|
.catch((err) => {
|
|
@@ -826,19 +963,32 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
826
963
|
if (model.view !== 'container')
|
|
827
964
|
return;
|
|
828
965
|
let alive = true;
|
|
829
|
-
const
|
|
966
|
+
const openable = model.nodes.filter((n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id));
|
|
967
|
+
// One click ahead, and only one: opening `c` next means the current set plus
|
|
968
|
+
// `c`. From the closed diagram that is just "open c", so the same rule covers
|
|
969
|
+
// the first click on any container and every one after it — no separate path
|
|
970
|
+
// for "nothing is open yet".
|
|
971
|
+
//
|
|
972
|
+
// Deliberately not the full power set. With N containers that would be 2^N
|
|
973
|
+
// ELK runs and most of them unreachable; N-1 runs per visited state keeps the
|
|
974
|
+
// next click instant, which is the only reason this exists. The cache is
|
|
975
|
+
// capped below because the reachable states now grow with the states visited
|
|
976
|
+
// rather than staying at N.
|
|
977
|
+
const targets = openable
|
|
978
|
+
.filter((c) => !openIds.includes(c.id))
|
|
979
|
+
.map((c) => [...openIds, c.id].sort());
|
|
830
980
|
void (async () => {
|
|
831
|
-
for (const
|
|
981
|
+
for (const target of targets) {
|
|
832
982
|
if (!alive)
|
|
833
983
|
return;
|
|
834
|
-
|
|
984
|
+
const key = openStateKey(target);
|
|
985
|
+
if (layoutCache.current.has(key))
|
|
835
986
|
continue;
|
|
836
987
|
try {
|
|
837
|
-
const
|
|
838
|
-
const r = await layoutFor(c.id, inputs);
|
|
988
|
+
const r = await layoutFor(target, buildInputs(target));
|
|
839
989
|
if (!alive)
|
|
840
990
|
return;
|
|
841
|
-
|
|
991
|
+
cacheLayout(key, r);
|
|
842
992
|
}
|
|
843
993
|
catch {
|
|
844
994
|
// Prefetch is best-effort; a miss just falls back to compute-on-click.
|
|
@@ -861,24 +1011,26 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
|
|
|
861
1011
|
? model.nodes.filter((n) => n.kind === 'component' &&
|
|
862
1012
|
(n.container === selected.id || n.parentId === selected.id))
|
|
863
1013
|
: [], [model, selected]);
|
|
864
|
-
/** The
|
|
865
|
-
const
|
|
1014
|
+
/** The containers open for drill-down, normalized. */
|
|
1015
|
+
const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
|
|
866
1016
|
const displayNodes = useMemo(() => nodes.map((n) => {
|
|
867
1017
|
if (n.type !== 'c4-node')
|
|
868
1018
|
return n;
|
|
869
1019
|
const element = n.data?.element;
|
|
870
|
-
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
const
|
|
874
|
-
openContainerId != null &&
|
|
875
|
-
(element.container ?? element.parentId) === openContainerId;
|
|
1020
|
+
const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
|
|
1021
|
+
// `hidesComponentDuringMorph` owns the rule; see there for why it is
|
|
1022
|
+
// per-container rather than global.
|
|
1023
|
+
const hidden = hidesComponentDuringMorph(owner, openContainerIds, morphTargetsRef.current, settled);
|
|
876
1024
|
return {
|
|
877
1025
|
...n,
|
|
878
|
-
...(
|
|
1026
|
+
...(hidden ? { hidden: true } : {}),
|
|
879
1027
|
data: { ...n.data, selected: n.id === selectedId, settled },
|
|
880
1028
|
};
|
|
881
|
-
}),
|
|
1029
|
+
}),
|
|
1030
|
+
// `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
|
|
1031
|
+
// it, because the two always change together — targets are written in the same
|
|
1032
|
+
// tick that clears `settled`, and cleared in the same tick that restores it.
|
|
1033
|
+
[nodes, selectedId, settled, openContainerIds]);
|
|
882
1034
|
/**
|
|
883
1035
|
* An edge's polyline is routed for the *settled* layout, so while the cards are
|
|
884
1036
|
* gliding between the two slots the old lines would visibly point at the wrong
|