@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
- package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +9 -5
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.js +10 -5
- package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
- package/dist/subsystem/graphChrome.d.ts +15 -0
- package/dist/subsystem/graphChrome.d.ts.map +1 -1
- package/dist/subsystem/graphChrome.js +17 -0
- package/dist/subsystem/graphChrome.js.map +1 -1
- package/dist/subsystem/model.d.ts +26 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +90 -2
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +12 -9
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +45 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +114 -84
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/signature.test.ts +1 -1
- package/src/index.ts +1 -0
- package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
- package/src/subsystem/SubsystemAggregateGraph.tsx +191 -21
- package/src/subsystem/SubsystemComponentGraph.tsx +18 -4
- package/src/subsystem/SymbolInspectionCard.tsx +31 -12
- package/src/subsystem/graphChrome.tsx +20 -0
- package/src/subsystem/model.test.ts +91 -0
- package/src/subsystem/model.ts +94 -2
- package/src/subsystem/nodes.tsx +12 -9
- package/src/utils/elkLayout.test.ts +117 -3
- package/src/utils/elkLayout.ts +150 -81
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
- /package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.ts +0 -0
package/src/subsystem/model.ts
CHANGED
|
@@ -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 —
|
|
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.
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -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
|
|
459
|
-
// Always set left/transform
|
|
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:
|
|
462
|
+
top: 0,
|
|
462
463
|
left: isProcess ? '50%' : 12,
|
|
463
|
-
transform: isProcess ? 'translateX(-50%)' : '
|
|
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
|
-
|
|
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:
|
|
482
|
-
border: `
|
|
483
|
-
borderTopWidth: isProcess ? 0 :
|
|
484
|
+
background: badgeBg,
|
|
485
|
+
border: `2px solid ${color}`,
|
|
486
|
+
borderTopWidth: isProcess ? 0 : 2,
|
|
484
487
|
borderRadius: badgeRadius,
|
|
485
|
-
padding: '
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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', () => {
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -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
|
-
|
|
520
|
-
const
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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>();
|
|
File without changes
|
|
File without changes
|
|
File without changes
|