@principal-ai/subsystems-react 0.35.6 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
  6. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
  7. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
  8. package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
  9. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
  10. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  11. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  12. package/dist/subsystem/SubsystemComponentGraph.js +9 -5
  13. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  14. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
  15. package/dist/subsystem/SymbolInspectionCard.js +10 -5
  16. package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
  17. package/dist/subsystem/graphChrome.d.ts +15 -0
  18. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  19. package/dist/subsystem/graphChrome.js +17 -0
  20. package/dist/subsystem/graphChrome.js.map +1 -1
  21. package/dist/subsystem/model.d.ts +26 -0
  22. package/dist/subsystem/model.d.ts.map +1 -1
  23. package/dist/subsystem/model.js +90 -2
  24. package/dist/subsystem/model.js.map +1 -1
  25. package/dist/subsystem/nodes.d.ts.map +1 -1
  26. package/dist/subsystem/nodes.js +12 -9
  27. package/dist/subsystem/nodes.js.map +1 -1
  28. package/dist/utils/elkLayout.d.ts +45 -0
  29. package/dist/utils/elkLayout.d.ts.map +1 -1
  30. package/dist/utils/elkLayout.js +114 -84
  31. package/dist/utils/elkLayout.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/graphify/signature.test.ts +1 -1
  34. package/src/index.ts +1 -0
  35. package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
  36. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
  37. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
  38. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
  39. package/src/subsystem/SubsystemAggregateGraph.tsx +191 -21
  40. package/src/subsystem/SubsystemComponentGraph.tsx +18 -4
  41. package/src/subsystem/SymbolInspectionCard.tsx +31 -12
  42. package/src/subsystem/graphChrome.tsx +20 -0
  43. package/src/subsystem/model.test.ts +91 -0
  44. package/src/subsystem/model.ts +94 -2
  45. package/src/subsystem/nodes.tsx +12 -9
  46. package/src/utils/elkLayout.test.ts +117 -3
  47. package/src/utils/elkLayout.ts +150 -81
  48. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
  49. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
  50. /package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.ts +0 -0
@@ -824,6 +824,13 @@ export function buildBoundaryLayoutGroups(
824
824
  // ("value already present"). Mixed-process modules (common in composed
825
825
  // graphs, where models frame one file under different processes) never
826
826
  // nest — without this claim they land in both frames.
827
+ //
828
+ // Singletons deliberately do NOT claim, `showSingletonFrames` or not: a
829
+ // one-member process spans exactly one package and a one-member module
830
+ // sits in exactly one process, so each always nests, and the enclosing
831
+ // frame's own `claimed`/`nestedModuleKeys` filter already excludes the
832
+ // leaf. Claiming as well would only strip those leaves from a package's
833
+ // direct leaves for no gain.
827
834
  const claimedByModule = new Set<string>();
828
835
  for (const r of modules) {
829
836
  if (r.memberAliases.length >= 2) {
@@ -832,8 +839,7 @@ export function buildBoundaryLayoutGroups(
832
839
  }
833
840
  // Same rule one level up: a leaf owned by a multi-member process frame
834
841
  // must not also sit directly in a package frame. (Singleton processes
835
- // never claim — ELK skips them and promotes the member upward, so the
836
- // member has to stay reachable through its package or ungrouped.)
842
+ // never claim — see above.)
837
843
  const claimedByProcess = new Set<string>();
838
844
  for (const r of processes) {
839
845
  if (r.memberAliases.length >= 2) {
@@ -1046,6 +1052,87 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
1046
1052
  'registers-into': 'dashed',
1047
1053
  };
1048
1054
 
1055
+ /**
1056
+ * Region colors for process boundaries in the aggregate graph. Kept separate
1057
+ * from MECHANISM_COLOR on purpose: that palette encodes edge semantics on thin
1058
+ * arrowed strokes, this one encodes containment on wide filled regions, and a
1059
+ * shared hue would make the two readings ambiguous.
1060
+ */
1061
+ export const BOUNDARY_COLOR: readonly string[] = [
1062
+ '#6c9eff', // blue
1063
+ '#3fb8a0', // teal
1064
+ '#8fbf5f', // green
1065
+ '#d9a441', // amber
1066
+ '#d67ab1', // magenta
1067
+ '#9d7fe0', // violet
1068
+ '#e8705f', // coral
1069
+ '#4fb0d4', // sky
1070
+ '#c2a83c', // olive
1071
+ '#a876c8', // orchid
1072
+ '#d1487f', // crimson
1073
+ '#a3c94f', // lime
1074
+ ];
1075
+
1076
+ /** Stable 32-bit hash (FNV-1a plus a final mix) so low bits still spread. */
1077
+ function hashBoundaryKey(key: string): number {
1078
+ let h = 0x811c9dc5;
1079
+ for (let i = 0; i < key.length; i++) {
1080
+ h ^= key.charCodeAt(i);
1081
+ h = Math.imul(h, 0x01000193);
1082
+ }
1083
+ h ^= h >>> 15;
1084
+ h = Math.imul(h, 0x2545f491);
1085
+ h ^= h >>> 13;
1086
+ return h >>> 0;
1087
+ }
1088
+
1089
+ /**
1090
+ * Assign each process boundary key a color from BOUNDARY_COLOR.
1091
+ *
1092
+ * A bare `hash % palette.length` collides constantly once a repo has more than
1093
+ * a handful of processes — unrelated boundaries end up the same hue, which
1094
+ * defeats the point of coding them at all. So the hash only picks the
1095
+ * *preferred* slot and collisions probe forward to the next free one, with
1096
+ * keys visited in sorted order to keep the result deterministic. Distinct
1097
+ * boundaries therefore never share a color unless the key count exceeds the
1098
+ * palette, and past that point the least-used hue wins rather than an
1099
+ * arbitrary one, so the busiest boundaries keep unique colors longest.
1100
+ *
1101
+ * Assignment depends on the key set, so filtering the graph can reshuffle
1102
+ * hues. That is the deliberate trade: the legend is always on screen, whereas
1103
+ * a stable-but-colliding mapping is unreadable.
1104
+ */
1105
+ export function assignBoundaryColors(keys: readonly string[]): Map<string, string> {
1106
+ const out = new Map<string, string>();
1107
+ const owners = new Map<string, number>();
1108
+ for (const key of [...keys].sort()) {
1109
+ if (out.has(key)) continue;
1110
+ const start = hashBoundaryKey(key) % BOUNDARY_COLOR.length;
1111
+ let picked: string | undefined;
1112
+ for (let i = 0; i < BOUNDARY_COLOR.length; i++) {
1113
+ const candidate = BOUNDARY_COLOR[(start + i) % BOUNDARY_COLOR.length]!;
1114
+ if (owners.get(candidate)) continue;
1115
+ picked = candidate;
1116
+ break;
1117
+ }
1118
+ // Palette exhausted: share the least-used hue, ties going to the earlier
1119
+ // slot so the result stays deterministic.
1120
+ if (!picked) {
1121
+ picked = BOUNDARY_COLOR.reduce((a, b) =>
1122
+ (owners.get(a) ?? 0) <= (owners.get(b) ?? 0) ? a : b,
1123
+ )!;
1124
+ }
1125
+ owners.set(picked, (owners.get(picked) ?? 0) + 1);
1126
+ out.set(key, picked);
1127
+ }
1128
+ return out;
1129
+ }
1130
+
1131
+ /** Low-alpha fill for a boundary region, from its assigned color. */
1132
+ export function boundaryFill(color: string, alpha = '1f'): string {
1133
+ return `${color}${alpha}`;
1134
+ }
1135
+
1049
1136
  /** Mechanism → [description, verifiable-with-graphify]. Drives the "not
1050
1137
  * directly verifiable" styling of edge labels. */
1051
1138
  export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
@@ -1552,6 +1639,11 @@ export async function buildSubsystemGraph(
1552
1639
  parentId: g.parentId,
1553
1640
  minWidth: g.region.kind === 'module' ? moduleMinWidthForBadge(g.region.label) : undefined,
1554
1641
  })),
1642
+ // Honour `showSingletonFrames`: a one-member process or module is
1643
+ // still a real boundary. `buildBoundaryLayoutGroups` already made
1644
+ // those regions claim their members, so keeping them here cannot
1645
+ // double-parent a leaf.
1646
+ keepSingletonGroups: showSingletonFrames === true,
1555
1647
  });
1556
1648
  const builtGroupIds = new Set(result.groupBounds.keys());
1557
1649
  // Parents before children — package, then process, then module frames.
@@ -405,6 +405,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
405
405
  isModule && canToggle ? moduleBadgeHoverLabel(label, availableBadgeWidth) : label;
406
406
  const frameRadius = isProcess ? 0 : 12;
407
407
  const badgeRadius = isProcess ? 0 : 4;
408
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
408
409
  // Processes are deployment units — solid frame. Modules/packages stay dashed
409
410
  // until selected.
410
411
  const frameStyle = isProcess || selected ? 'solid' : 'dashed';
@@ -455,12 +456,12 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
455
456
  position: 'absolute',
456
457
  // Process: badge fill starts with the process fill (inside the
457
458
  // frame border); no top badge border so widths don't fight.
458
- // Module/package sit astride the top edge like component badges.
459
- // Always set left/transform/borderTop explicitly — React Flow reuses
459
+ // Module/package are centred astride the top edge like component
460
+ // badges. Always set left/transform explicitly — React Flow reuses
460
461
  // group DOM nodes and `undefined` does not clear a prior value.
461
- top: isProcess ? 0 : -19,
462
+ top: 0,
462
463
  left: isProcess ? '50%' : 12,
463
- transform: isProcess ? 'translateX(-50%)' : 'none',
464
+ transform: isProcess ? 'translateX(-50%)' : 'translateY(-50%)',
464
465
  zIndex: canToggle ? 10 : undefined,
465
466
  // Width is explicit while collapsible so hover-reveal of more path
466
467
  // text animates; the cap keeps both states inside the frame.
@@ -472,17 +473,19 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
472
473
  : undefined,
473
474
  transition: 'width 140ms ease, box-shadow 120ms ease',
474
475
  cursor: canToggle ? 'pointer' : undefined,
475
- boxShadow: canToggle && hover ? `0 2px 10px ${color}55` : undefined,
476
+ // The trailing halo squares the rounded corners back off in the fill
477
+ // colour, so an edge crossing a corner can't show through the notch.
478
+ boxShadow: `${canToggle && hover ? `0 2px 10px ${color}55, ` : ''}0 0 0 1.5px ${badgeBg}`,
476
479
  fontFamily: theme.fonts.monospace,
477
480
  fontSize: theme.fontSizes[3],
478
481
  fontWeight: 700,
479
482
  letterSpacing: 0.6,
480
483
  color,
481
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
482
- border: `1px solid ${color}`,
483
- borderTopWidth: isProcess ? 0 : 1,
484
+ background: badgeBg,
485
+ border: `2px solid ${color}`,
486
+ borderTopWidth: isProcess ? 0 : 2,
484
487
  borderRadius: badgeRadius,
485
- padding: '1px 7px',
488
+ padding: '3px 9px',
486
489
  whiteSpace: 'nowrap',
487
490
  overflow: 'hidden',
488
491
  textOverflow: 'ellipsis',
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Tests for ELK Layout Utility
3
3
  *
4
- * Tests the pure helper functions that don't require the ELK runtime.
5
- * The computeElkLayout function is tested via Storybook visual tests
6
- * since ELK requires a web worker environment.
4
+ * Tests the pure helper functions that don't require the ELK runtime, plus
5
+ * the compound-group planner. computeElkLayout itself needs a web worker
6
+ * (elkjs constructs one), so it is covered by Storybook visual tests and by
7
+ * testing planCompoundGroups — the part that decides which frames exist.
7
8
  */
8
9
 
9
10
  import { describe, test, expect } from 'bun:test';
@@ -11,9 +12,122 @@ import {
11
12
  pointsToPath,
12
13
  pointsToSmoothPath,
13
14
  calculatePathMidpoint,
15
+ planCompoundGroups,
14
16
  type Point,
15
17
  } from './elkLayout';
16
18
 
19
+ describe('planCompoundGroups', () => {
20
+ test('drops a single-leaf group by default and promotes its member', () => {
21
+ const plan = planCompoundGroups(
22
+ [{ id: 'proc', memberIds: ['solo'] }],
23
+ ['solo'],
24
+ );
25
+ expect(plan.built.map((g) => g.id)).toEqual([]);
26
+ expect(plan.skipped).toEqual(['proc']);
27
+ });
28
+
29
+ test('keeps a single-leaf group when the caller opts in', () => {
30
+ const plan = planCompoundGroups(
31
+ [{ id: 'proc', memberIds: ['solo'] }],
32
+ ['solo'],
33
+ true,
34
+ );
35
+ expect(plan.skipped).toEqual([]);
36
+ expect(plan.built).toEqual([{ id: 'proc', childIds: ['solo'], minWidth: undefined }]);
37
+ });
38
+
39
+ test('keeps a multi-leaf group in both modes', () => {
40
+ const defs = [{ id: 'proc', memberIds: ['a', 'b'] }];
41
+ for (const keep of [false, true]) {
42
+ const plan = planCompoundGroups(defs, ['a', 'b'], keep);
43
+ expect(plan.built.map((g) => g.id)).toEqual(['proc']);
44
+ expect(plan.skipped).toEqual([]);
45
+ }
46
+ });
47
+
48
+ test('always drops an empty group even when singletons are kept', () => {
49
+ const plan = planCompoundGroups([{ id: 'proc', memberIds: [] }], ['a'], true);
50
+ expect(plan.built).toEqual([]);
51
+ expect(plan.skipped).toEqual(['proc']);
52
+ });
53
+
54
+ test('counts leaves through nested groups', () => {
55
+ // process → module(one leaf) is still a single leaf, so both drop by
56
+ // default even though the process lists one member id.
57
+ const defs = [
58
+ { id: 'mod', memberIds: ['a'] },
59
+ { id: 'proc', memberIds: ['mod'] },
60
+ ];
61
+ expect(planCompoundGroups(defs, ['a']).skipped).toEqual(['mod', 'proc']);
62
+ expect(planCompoundGroups(defs, ['a'], true).built.map((g) => g.id)).toEqual([
63
+ 'mod',
64
+ 'proc',
65
+ ]);
66
+ });
67
+
68
+ test('a two-leaf module keeps its process alive under the default', () => {
69
+ const defs = [
70
+ { id: 'mod', memberIds: ['a', 'b'] },
71
+ { id: 'proc', memberIds: ['mod'] },
72
+ ];
73
+ const plan = planCompoundGroups(defs, ['a', 'b']);
74
+ expect(plan.built.map((g) => g.id)).toEqual(['mod', 'proc']);
75
+ expect(plan.built[1]!.childIds).toEqual(['mod']);
76
+ });
77
+
78
+ test('promotes a dropped group member into its built parent', () => {
79
+ // proc contains a singleton module (dropped) plus a bare leaf, so proc
80
+ // still has two leaves and survives; the module's leaf is promoted in.
81
+ const defs = [
82
+ { id: 'mod', memberIds: ['a'] },
83
+ { id: 'proc', memberIds: ['mod', 'b'] },
84
+ ];
85
+ const plan = planCompoundGroups(defs, ['a', 'b']);
86
+ expect(plan.skipped).toEqual(['mod']);
87
+ expect(plan.built.map((g) => g.id)).toEqual(['proc']);
88
+ expect(plan.built[0]!.childIds.sort()).toEqual(['a', 'b']);
89
+ });
90
+
91
+ test('builds children before parents', () => {
92
+ const defs = [
93
+ { id: 'pkg', memberIds: ['procA', 'procB'] },
94
+ { id: 'procA', memberIds: ['a1', 'a2'] },
95
+ { id: 'procB', memberIds: ['b1', 'b2'] },
96
+ ];
97
+ const plan = planCompoundGroups(defs, ['a1', 'a2', 'b1', 'b2']);
98
+ const order = plan.built.map((g) => g.id);
99
+ expect(order.indexOf('procA')).toBeLessThan(order.indexOf('pkg'));
100
+ expect(order.indexOf('procB')).toBeLessThan(order.indexOf('pkg'));
101
+ });
102
+
103
+ test('drops groups caught in a cycle instead of looping forever', () => {
104
+ const defs = [
105
+ { id: 'x', memberIds: ['y'] },
106
+ { id: 'y', memberIds: ['x'] },
107
+ ];
108
+ const plan = planCompoundGroups(defs, ['a']);
109
+ expect(plan.built).toEqual([]);
110
+ expect(plan.skipped.sort()).toEqual(['x', 'y']);
111
+ });
112
+
113
+ test('ignores member ids that match no leaf or group', () => {
114
+ const plan = planCompoundGroups(
115
+ [{ id: 'proc', memberIds: ['a', 'ghost'] }],
116
+ ['a'],
117
+ true,
118
+ );
119
+ expect(plan.built[0]!.childIds).toEqual(['a']);
120
+ });
121
+
122
+ test('carries minWidth through to the plan', () => {
123
+ const plan = planCompoundGroups(
124
+ [{ id: 'mod', memberIds: ['a', 'b'], minWidth: 220 }],
125
+ ['a', 'b'],
126
+ );
127
+ expect(plan.built[0]!.minWidth).toBe(220);
128
+ });
129
+ });
130
+
17
131
  describe('elkLayout helper functions', () => {
18
132
  describe('pointsToPath', () => {
19
133
  test('should return empty string for empty array', () => {
@@ -80,6 +80,16 @@ export interface ElkLayoutOptions {
80
80
  * Members reference their immediate parent via React Flow `parentId`.
81
81
  */
82
82
  groups?: Array<{ id: string; memberIds: string[]; parentId?: string; minWidth?: number }>;
83
+
84
+ /**
85
+ * Keep groups that hold a single leaf instead of dropping them and
86
+ * promoting their member to the parent. A one-child group is still a real
87
+ * boundary — a process with a single module, or a process whose components
88
+ * carry no module — so callers that own the grouping semantics opt in here.
89
+ * Callers must ensure each leaf appears in at most one group, or ELK throws
90
+ * on the duplicate. @default false
91
+ */
92
+ keepSingletonGroups?: boolean;
83
93
  }
84
94
 
85
95
  /** Result of ELK layout computation */
@@ -341,6 +351,126 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
341
351
  return baseOptions;
342
352
  }
343
353
 
354
+ /** A group definition accepted by {@link planCompoundGroups}. */
355
+ export interface CompoundGroupDef {
356
+ id: string;
357
+ memberIds: string[];
358
+ minWidth?: number;
359
+ }
360
+
361
+ /** Which groups ELK builds, and which it drops. */
362
+ export interface CompoundGroupPlan {
363
+ /**
364
+ * Built groups in build order — a group's children always appear before it,
365
+ * so callers can map ids to shells in one forward pass.
366
+ */
367
+ built: Array<{ id: string; childIds: string[]; minWidth?: number }>;
368
+ /**
369
+ * Dropped groups. Their members are promoted into the nearest built
370
+ * ancestor, so callers must clear those members' `parentId`.
371
+ */
372
+ skipped: string[];
373
+ }
374
+
375
+ /**
376
+ * Decide which compound groups survive, independently of the ELK runtime.
377
+ *
378
+ * Groups resolve bottom-up: a group is ready once every member is a leaf or an
379
+ * already-resolved group. A group is dropped when it would hold nothing, or —
380
+ * unless `keepSingletonGroups` — when it holds a single leaf, since a frame
381
+ * wrapped around one box reads worse than the box alone. Callers that keep
382
+ * singletons own the grouping semantics and must guarantee each leaf appears
383
+ * in at most one group, or ELK throws on the duplicate.
384
+ *
385
+ * Pure so the skip rules are testable without an ELK worker.
386
+ */
387
+ export function planCompoundGroups(
388
+ groupDefs: CompoundGroupDef[],
389
+ leafIds: Iterable<string>,
390
+ keepSingletonGroups = false,
391
+ ): CompoundGroupPlan {
392
+ const leaves = new Set(leafIds);
393
+ const byId = new Map(groupDefs.map((g) => [g.id, g]));
394
+ const built: CompoundGroupPlan['built'] = [];
395
+ const skipped = new Set<string>();
396
+ const pending = [...groupDefs];
397
+
398
+ /** Leaves below a group, counting through nested groups. */
399
+ const leafDescendantCount = (memberIds: string[]): number => {
400
+ let n = 0;
401
+ for (const mid of memberIds) {
402
+ if (leaves.has(mid)) n += 1;
403
+ else {
404
+ const g = byId.get(mid);
405
+ if (g) n += leafDescendantCount(g.memberIds);
406
+ }
407
+ }
408
+ return n;
409
+ };
410
+
411
+ while (pending.length > 0) {
412
+ let progress = false;
413
+ for (let i = pending.length - 1; i >= 0; i--) {
414
+ const g = pending[i]!;
415
+ const childIds: string[] = [];
416
+ let ready = true;
417
+ for (const mid of g.memberIds) {
418
+ if (leaves.has(mid)) {
419
+ childIds.push(mid);
420
+ continue;
421
+ }
422
+ if (skipped.has(mid)) {
423
+ // Promote a dropped group's members into this parent.
424
+ const dropped = byId.get(mid);
425
+ if (!dropped) {
426
+ ready = false;
427
+ break;
428
+ }
429
+ for (const sm of dropped.memberIds) {
430
+ if (leaves.has(sm) || built.some((b) => b.id === sm)) childIds.push(sm);
431
+ else if (!skipped.has(sm)) {
432
+ ready = false;
433
+ break;
434
+ }
435
+ }
436
+ if (!ready) break;
437
+ continue;
438
+ }
439
+ if (built.some((b) => b.id === mid)) {
440
+ childIds.push(mid);
441
+ continue;
442
+ }
443
+ if (byId.has(mid)) {
444
+ ready = false;
445
+ break;
446
+ }
447
+ // Unknown id — ignored (external stubs etc. may be absent).
448
+ }
449
+ if (!ready) continue;
450
+
451
+ pending.splice(i, 1);
452
+ progress = true;
453
+
454
+ if (
455
+ childIds.length < 1 ||
456
+ (!keepSingletonGroups && leafDescendantCount(g.memberIds) < 2)
457
+ ) {
458
+ skipped.add(g.id);
459
+ continue;
460
+ }
461
+
462
+ built.push({ id: g.id, childIds, minWidth: g.minWidth });
463
+ }
464
+ if (!progress) {
465
+ // Cycle or unresolved refs — leave the rest unbuilt.
466
+ for (const g of pending) skipped.add(g.id);
467
+ break;
468
+ }
469
+ }
470
+
471
+ return { built, skipped: [...skipped] };
472
+ }
473
+
344
474
  /**
345
475
  * Compute ELK layout for nodes and edges
346
476
  *
@@ -354,7 +484,7 @@ export async function computeElkLayout(
354
484
  edges: Edge[],
355
485
  options: ElkLayoutOptions = {}
356
486
  ): Promise<ElkLayoutResult> {
357
- const { preserveNodePositions = true } = options;
487
+ const { preserveNodePositions = true, keepSingletonGroups = false } = options;
358
488
  const edgeLabels = options.edgeLabels;
359
489
  const direction = options.direction ?? 'RIGHT';
360
490
 
@@ -516,89 +646,28 @@ export async function computeElkLayout(
516
646
  'elk.spacing.nodeNode': '40',
517
647
  };
518
648
 
519
- /** Descendant leaf count for singleton checks (nested groups count through). */
520
- const leafDescendantCount = (memberIds: string[]): number => {
521
- let n = 0;
522
- for (const mid of memberIds) {
523
- if (elkById.has(mid)) n += 1;
524
- else {
525
- const g = groupById.get(mid);
526
- if (g) n += leafDescendantCount(g.memberIds);
527
- }
528
- }
529
- return n;
530
- };
531
-
649
+ const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
650
+ const skippedGroups = new Set(plan.skipped);
532
651
  const builtGroups = new Map<string, ElkNode>();
533
- const skippedGroups = new Set<string>();
534
- const pending = [...groupDefs];
535
- // Build bottom-up: a group is ready when every member is a leaf or an
536
- // already-built / skipped group. Skipped groups promote their members.
537
- while (pending.length > 0) {
538
- let progress = false;
539
- for (let i = pending.length - 1; i >= 0; i--) {
540
- const g = pending[i]!;
541
- const childNodes: ElkNode[] = [];
542
- let ready = true;
543
- for (const mid of g.memberIds) {
544
- if (elkById.has(mid)) {
545
- childNodes.push(elkById.get(mid)!);
546
- continue;
547
- }
548
- if (skippedGroups.has(mid)) {
549
- // Promote skipped group's members into this parent.
550
- const skipped = groupById.get(mid);
551
- if (!skipped) {
552
- ready = false;
553
- break;
554
- }
555
- for (const sm of skipped.memberIds) {
556
- if (elkById.has(sm)) childNodes.push(elkById.get(sm)!);
557
- else if (builtGroups.has(sm)) childNodes.push(builtGroups.get(sm)!);
558
- else if (!skippedGroups.has(sm)) {
559
- ready = false;
560
- break;
561
- }
562
- }
563
- if (!ready) break;
564
- continue;
565
- }
566
- if (builtGroups.has(mid)) {
567
- childNodes.push(builtGroups.get(mid)!);
568
- continue;
569
- }
570
- if (groupById.has(mid)) {
571
- ready = false;
572
- break;
573
- }
574
- // Unknown id — ignore (external stubs etc. may be absent).
575
- }
576
- if (!ready) continue;
577
-
578
- pending.splice(i, 1);
579
- progress = true;
580
-
581
- // Skip frames with fewer than 2 leaf descendants — promote children up.
582
- if (leafDescendantCount(g.memberIds) < 2 || childNodes.length < 1) {
583
- skippedGroups.add(g.id);
584
- continue;
652
+ for (const g of plan.built) {
653
+ const children: ElkNode[] = [];
654
+ for (const cid of g.childIds) {
655
+ const leaf = elkById.get(cid);
656
+ if (leaf) children.push(leaf);
657
+ else {
658
+ const nested = builtGroups.get(cid);
659
+ if (nested) children.push(nested);
585
660
  }
586
-
587
- builtGroups.set(g.id, {
588
- id: g.id,
589
- children: childNodes,
590
- layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
591
- ...compoundLayoutOptions,
592
- 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
593
- 'elk.nodeSize.minimum': `(${g.minWidth},0)`,
594
- },
595
- });
596
- }
597
- if (!progress) {
598
- // Cycle or unresolved refs — leave remaining groups unbuilt.
599
- for (const g of pending) skippedGroups.add(g.id);
600
- break;
601
661
  }
662
+ builtGroups.set(g.id, {
663
+ id: g.id,
664
+ children,
665
+ layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
666
+ ...compoundLayoutOptions,
667
+ 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
668
+ 'elk.nodeSize.minimum': `(${g.minWidth},0)`,
669
+ },
670
+ });
602
671
  }
603
672
 
604
673
  const groupedLeafIds = new Set<string>();