@principal-ai/subsystems-react 0.35.7 → 0.36.1
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/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 +8 -8
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +19 -11
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
- package/src/subsystem/SubsystemAggregateGraph.tsx +229 -21
- package/src/subsystem/SubsystemComponentGraph.tsx +34 -16
- package/src/subsystem/graphChrome.tsx +20 -0
- package/src/subsystem/model.test.ts +105 -0
- package/src/subsystem/model.ts +119 -4
- package/src/subsystem/nodes.tsx +49 -64
- package/src/utils/elkLayout.test.ts +117 -3
- package/src/utils/elkLayout.ts +154 -82
- /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
|
@@ -663,6 +663,18 @@ export function moduleMinWidthForBadge(path: string): number {
|
|
|
663
663
|
);
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Min frame width so a **non-collapsing** boundary badge (process / package)
|
|
668
|
+
* fits inside with padding at both ends. Process badges are centred on the top
|
|
669
|
+
* edge and never collapse, so the full label must clear `MODULE_BADGE_INSET` on
|
|
670
|
+
* each side; ELK receives this as the frame's `minWidth`.
|
|
671
|
+
*/
|
|
672
|
+
export function boundaryMinWidthForBadge(label: string): number {
|
|
673
|
+
return Math.ceil(
|
|
674
|
+
moduleBadgeWidth(label) + MODULE_BADGE_INSET * 2 + MODULE_FRAME_BORDER,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
666
678
|
/** React Flow id for a package (repo) boundary group node. */
|
|
667
679
|
export function packageGroupNodeId(packageKey: string): string {
|
|
668
680
|
return `package:${packageKey}`;
|
|
@@ -824,6 +836,13 @@ export function buildBoundaryLayoutGroups(
|
|
|
824
836
|
// ("value already present"). Mixed-process modules (common in composed
|
|
825
837
|
// graphs, where models frame one file under different processes) never
|
|
826
838
|
// nest — without this claim they land in both frames.
|
|
839
|
+
//
|
|
840
|
+
// Singletons deliberately do NOT claim, `showSingletonFrames` or not: a
|
|
841
|
+
// one-member process spans exactly one package and a one-member module
|
|
842
|
+
// sits in exactly one process, so each always nests, and the enclosing
|
|
843
|
+
// frame's own `claimed`/`nestedModuleKeys` filter already excludes the
|
|
844
|
+
// leaf. Claiming as well would only strip those leaves from a package's
|
|
845
|
+
// direct leaves for no gain.
|
|
827
846
|
const claimedByModule = new Set<string>();
|
|
828
847
|
for (const r of modules) {
|
|
829
848
|
if (r.memberAliases.length >= 2) {
|
|
@@ -832,8 +851,7 @@ export function buildBoundaryLayoutGroups(
|
|
|
832
851
|
}
|
|
833
852
|
// Same rule one level up: a leaf owned by a multi-member process frame
|
|
834
853
|
// 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.)
|
|
854
|
+
// never claim — see above.)
|
|
837
855
|
const claimedByProcess = new Set<string>();
|
|
838
856
|
for (const r of processes) {
|
|
839
857
|
if (r.memberAliases.length >= 2) {
|
|
@@ -1046,6 +1064,87 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
|
|
|
1046
1064
|
'registers-into': 'dashed',
|
|
1047
1065
|
};
|
|
1048
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* Region colors for process boundaries in the aggregate graph. Kept separate
|
|
1069
|
+
* from MECHANISM_COLOR on purpose: that palette encodes edge semantics on thin
|
|
1070
|
+
* arrowed strokes, this one encodes containment on wide filled regions, and a
|
|
1071
|
+
* shared hue would make the two readings ambiguous.
|
|
1072
|
+
*/
|
|
1073
|
+
export const BOUNDARY_COLOR: readonly string[] = [
|
|
1074
|
+
'#6c9eff', // blue
|
|
1075
|
+
'#3fb8a0', // teal
|
|
1076
|
+
'#8fbf5f', // green
|
|
1077
|
+
'#d9a441', // amber
|
|
1078
|
+
'#d67ab1', // magenta
|
|
1079
|
+
'#9d7fe0', // violet
|
|
1080
|
+
'#e8705f', // coral
|
|
1081
|
+
'#4fb0d4', // sky
|
|
1082
|
+
'#c2a83c', // olive
|
|
1083
|
+
'#a876c8', // orchid
|
|
1084
|
+
'#d1487f', // crimson
|
|
1085
|
+
'#a3c94f', // lime
|
|
1086
|
+
];
|
|
1087
|
+
|
|
1088
|
+
/** Stable 32-bit hash (FNV-1a plus a final mix) so low bits still spread. */
|
|
1089
|
+
function hashBoundaryKey(key: string): number {
|
|
1090
|
+
let h = 0x811c9dc5;
|
|
1091
|
+
for (let i = 0; i < key.length; i++) {
|
|
1092
|
+
h ^= key.charCodeAt(i);
|
|
1093
|
+
h = Math.imul(h, 0x01000193);
|
|
1094
|
+
}
|
|
1095
|
+
h ^= h >>> 15;
|
|
1096
|
+
h = Math.imul(h, 0x2545f491);
|
|
1097
|
+
h ^= h >>> 13;
|
|
1098
|
+
return h >>> 0;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Assign each process boundary key a color from BOUNDARY_COLOR.
|
|
1103
|
+
*
|
|
1104
|
+
* A bare `hash % palette.length` collides constantly once a repo has more than
|
|
1105
|
+
* a handful of processes — unrelated boundaries end up the same hue, which
|
|
1106
|
+
* defeats the point of coding them at all. So the hash only picks the
|
|
1107
|
+
* *preferred* slot and collisions probe forward to the next free one, with
|
|
1108
|
+
* keys visited in sorted order to keep the result deterministic. Distinct
|
|
1109
|
+
* boundaries therefore never share a color unless the key count exceeds the
|
|
1110
|
+
* palette, and past that point the least-used hue wins rather than an
|
|
1111
|
+
* arbitrary one, so the busiest boundaries keep unique colors longest.
|
|
1112
|
+
*
|
|
1113
|
+
* Assignment depends on the key set, so filtering the graph can reshuffle
|
|
1114
|
+
* hues. That is the deliberate trade: the legend is always on screen, whereas
|
|
1115
|
+
* a stable-but-colliding mapping is unreadable.
|
|
1116
|
+
*/
|
|
1117
|
+
export function assignBoundaryColors(keys: readonly string[]): Map<string, string> {
|
|
1118
|
+
const out = new Map<string, string>();
|
|
1119
|
+
const owners = new Map<string, number>();
|
|
1120
|
+
for (const key of [...keys].sort()) {
|
|
1121
|
+
if (out.has(key)) continue;
|
|
1122
|
+
const start = hashBoundaryKey(key) % BOUNDARY_COLOR.length;
|
|
1123
|
+
let picked: string | undefined;
|
|
1124
|
+
for (let i = 0; i < BOUNDARY_COLOR.length; i++) {
|
|
1125
|
+
const candidate = BOUNDARY_COLOR[(start + i) % BOUNDARY_COLOR.length]!;
|
|
1126
|
+
if (owners.get(candidate)) continue;
|
|
1127
|
+
picked = candidate;
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
// Palette exhausted: share the least-used hue, ties going to the earlier
|
|
1131
|
+
// slot so the result stays deterministic.
|
|
1132
|
+
if (!picked) {
|
|
1133
|
+
picked = BOUNDARY_COLOR.reduce((a, b) =>
|
|
1134
|
+
(owners.get(a) ?? 0) <= (owners.get(b) ?? 0) ? a : b,
|
|
1135
|
+
)!;
|
|
1136
|
+
}
|
|
1137
|
+
owners.set(picked, (owners.get(picked) ?? 0) + 1);
|
|
1138
|
+
out.set(key, picked);
|
|
1139
|
+
}
|
|
1140
|
+
return out;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** Low-alpha fill for a boundary region, from its assigned color. */
|
|
1144
|
+
export function boundaryFill(color: string, alpha = '1f'): string {
|
|
1145
|
+
return `${color}${alpha}`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1049
1148
|
/** Mechanism → [description, verifiable-with-graphify]. Drives the "not
|
|
1050
1149
|
* directly verifiable" styling of edge labels. */
|
|
1051
1150
|
export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
|
|
@@ -1550,8 +1649,19 @@ export async function buildSubsystemGraph(
|
|
|
1550
1649
|
id: g.id,
|
|
1551
1650
|
memberIds: g.memberAliases,
|
|
1552
1651
|
parentId: g.parentId,
|
|
1553
|
-
|
|
1652
|
+
// Modules collapse a long path to `first/…/last`, so they only need
|
|
1653
|
+
// room for the collapsed badge. Processes/packages never collapse:
|
|
1654
|
+
// size the frame to hold the full centered label plus padding.
|
|
1655
|
+
minWidth:
|
|
1656
|
+
g.region.kind === 'module'
|
|
1657
|
+
? moduleMinWidthForBadge(g.region.label)
|
|
1658
|
+
: boundaryMinWidthForBadge(g.region.label),
|
|
1554
1659
|
})),
|
|
1660
|
+
// Honour `showSingletonFrames`: a one-member process or module is
|
|
1661
|
+
// still a real boundary. `buildBoundaryLayoutGroups` already made
|
|
1662
|
+
// those regions claim their members, so keeping them here cannot
|
|
1663
|
+
// double-parent a leaf.
|
|
1664
|
+
keepSingletonGroups: showSingletonFrames === true,
|
|
1555
1665
|
});
|
|
1556
1666
|
const builtGroupIds = new Set(result.groupBounds.keys());
|
|
1557
1667
|
// Parents before children — package, then process, then module frames.
|
|
@@ -1605,7 +1715,12 @@ export async function buildSubsystemGraph(
|
|
|
1605
1715
|
id: g.id,
|
|
1606
1716
|
type: 'subsystem-group',
|
|
1607
1717
|
position: { x: 0, y: 0 },
|
|
1608
|
-
width:
|
|
1718
|
+
width: Math.max(
|
|
1719
|
+
400,
|
|
1720
|
+
g.region.kind === 'module'
|
|
1721
|
+
? moduleMinWidthForBadge(g.region.label)
|
|
1722
|
+
: boundaryMinWidthForBadge(g.region.label),
|
|
1723
|
+
),
|
|
1609
1724
|
height: 300,
|
|
1610
1725
|
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
1611
1726
|
data: { region: g.region },
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
storageBadgeLabel,
|
|
28
28
|
storageBadgeColor,
|
|
29
29
|
deriveNameFromSymbol,
|
|
30
|
-
BADGE_EDGE_INSET,
|
|
31
30
|
nodeMinWidthForBadges,
|
|
32
31
|
MODULE_BADGE_INSET,
|
|
33
32
|
moduleBadgeLabel,
|
|
@@ -70,9 +69,6 @@ function breakWords(s: string): string {
|
|
|
70
69
|
export interface SubsystemGraphCallbacks {
|
|
71
70
|
/** Click a component — open its file/entry point. */
|
|
72
71
|
onSelect?: (componentAlias: string) => void;
|
|
73
|
-
/** Click the filename badge — open that component's file in the drawer
|
|
74
|
-
* (same path as clicking the file link in the declaration panel). */
|
|
75
|
-
onOpenFile?: (componentAlias: string) => void;
|
|
76
72
|
/** Click an edge (or its label) — select the relationship. */
|
|
77
73
|
onEdgeSelect?: (edgeId: string) => void;
|
|
78
74
|
/** Hover a component (null on leave) — associates it with the file tree. */
|
|
@@ -121,6 +117,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
121
117
|
const callbacks = useSubsystemCallbacks();
|
|
122
118
|
const { data, selected, width: nodeWidth, height: nodeHeight } = props;
|
|
123
119
|
const c = data.component;
|
|
120
|
+
const [hover, setHover] = useState(false);
|
|
124
121
|
// Construct owns node color, derived from the active Pierre syntax theme —
|
|
125
122
|
// the same palette the declaration panel and file drawer render with. Role
|
|
126
123
|
// shows as the hover badge, not as color — for now; a role glyph/accent may
|
|
@@ -129,7 +126,6 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
129
126
|
// Left badge: framework brand (e.g. React cyan) when the label is a
|
|
130
127
|
// framework stereotype; otherwise the same construct color as the border.
|
|
131
128
|
const badgeColor = constructBadgeColor(c) ?? color;
|
|
132
|
-
const [hover, setHover] = useState(false);
|
|
133
129
|
const configuredMax = callbacks.maxNodeWidth;
|
|
134
130
|
const maxWidth = configuredMax ?? 300;
|
|
135
131
|
// `symbol` is the source of truth; `name` is derived from it consistently.
|
|
@@ -145,6 +141,11 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
145
141
|
// Set while a file is open in the drawer: true → spotlight, false → dim,
|
|
146
142
|
// absent (no file open) → neutral.
|
|
147
143
|
const fileMatch = data.fileMatch as boolean | undefined;
|
|
144
|
+
// Node fill lifts on hover as the clickability affordance; badges share it
|
|
145
|
+
// so they read as tabs on the same surface rather than floating patches.
|
|
146
|
+
const nodeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
147
|
+
const hoverBg =
|
|
148
|
+
theme.colors.backgroundHover ?? theme.colors.backgroundTertiary ?? nodeBg;
|
|
148
149
|
// Border: proposed uses the goldenrod accent (dashed); construct color stays
|
|
149
150
|
// on the left badge. File-open spotlight still wins with primary.
|
|
150
151
|
const borderColor = fileMatch
|
|
@@ -159,6 +160,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
159
160
|
const isExternal = c.construct === 'external';
|
|
160
161
|
const nodeRadius = isExternal ? 0 : 8;
|
|
161
162
|
const badgeRadius = isExternal ? 0 : 4;
|
|
163
|
+
// The border thickens on selection / file-match. Absolutely-positioned
|
|
164
|
+
// badges are laid out from the padding box (inside the border), so a raw
|
|
165
|
+
// `top` / `left` would slide with the border. Anchor them to the border box
|
|
166
|
+
// instead, offsetting by the border width. Side badges sit flush with the
|
|
167
|
+
// node's left / right edge (`-borderW`); the top reference stays at -9.
|
|
168
|
+
const borderW = isSelected || fileMatch ? 4 : 2;
|
|
169
|
+
const badgeTop = -9 - borderW;
|
|
170
|
+
const badgeEdge = -borderW;
|
|
162
171
|
|
|
163
172
|
return (
|
|
164
173
|
<div
|
|
@@ -187,15 +196,15 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
187
196
|
maxWidth,
|
|
188
197
|
padding: '6px 10px',
|
|
189
198
|
borderRadius: nodeRadius,
|
|
190
|
-
background:
|
|
199
|
+
background: hover ? hoverBg : nodeBg,
|
|
191
200
|
// Selected / file-matched nodes get a thicker border. Proposed nodes
|
|
192
201
|
// use a dashed goldenrod border; left construct badge keeps construct color.
|
|
193
|
-
border: `${
|
|
202
|
+
border: `${borderW}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
|
|
194
203
|
boxShadow: fileMatch
|
|
195
204
|
? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
|
|
196
205
|
: '0 1px 4px rgba(0,0,0,0.25)',
|
|
197
206
|
opacity: fileMatch === false || data.dimmed === true ? 0.18 : 1,
|
|
198
|
-
transition: 'opacity 150ms ease',
|
|
207
|
+
transition: 'opacity 150ms ease, background-color 120ms ease',
|
|
199
208
|
cursor: 'pointer',
|
|
200
209
|
fontFamily: theme.fonts.body,
|
|
201
210
|
}}
|
|
@@ -207,8 +216,8 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
207
216
|
<div
|
|
208
217
|
style={{
|
|
209
218
|
position: 'absolute',
|
|
210
|
-
top:
|
|
211
|
-
left:
|
|
219
|
+
top: badgeTop,
|
|
220
|
+
left: badgeEdge,
|
|
212
221
|
zIndex: 1,
|
|
213
222
|
fontFamily: theme.fonts.monospace,
|
|
214
223
|
fontSize: theme.fontSizes[1],
|
|
@@ -217,7 +226,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
217
226
|
lineHeight: '17px',
|
|
218
227
|
whiteSpace: 'nowrap',
|
|
219
228
|
color: badgeColor,
|
|
220
|
-
background:
|
|
229
|
+
background: nodeBg,
|
|
221
230
|
border: `2px solid ${badgeColor}`,
|
|
222
231
|
borderRadius: badgeRadius,
|
|
223
232
|
padding: '2px 8px',
|
|
@@ -226,15 +235,18 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
226
235
|
{constructBadgeLabel(c)}
|
|
227
236
|
</div>
|
|
228
237
|
|
|
229
|
-
{/*
|
|
238
|
+
{/* Secondary badge cluster — proposed wins over role when both are set;
|
|
230
239
|
store storage (retention backing) sits beside, since it is
|
|
231
|
-
orthogonal to topology. Persistent; pointer-events pass through.
|
|
240
|
+
orthogonal to topology. Persistent; pointer-events pass through.
|
|
241
|
+
Sits astride the bottom edge, centered (mirroring the top construct
|
|
242
|
+
badge's straddle), so the top edge stays free for the construct tag. */}
|
|
232
243
|
{(topRightLabel != null || storageLabel != null) && (
|
|
233
244
|
<div
|
|
234
245
|
style={{
|
|
235
246
|
position: 'absolute',
|
|
236
|
-
|
|
237
|
-
|
|
247
|
+
bottom: badgeTop,
|
|
248
|
+
left: '50%',
|
|
249
|
+
transform: 'translateX(-50%)',
|
|
238
250
|
zIndex: 1,
|
|
239
251
|
display: 'flex',
|
|
240
252
|
gap: 6,
|
|
@@ -250,7 +262,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
250
262
|
textTransform: 'uppercase',
|
|
251
263
|
lineHeight: '17px',
|
|
252
264
|
color: storageColor,
|
|
253
|
-
background:
|
|
265
|
+
background: nodeBg,
|
|
254
266
|
border: `2px solid ${storageColor}`,
|
|
255
267
|
borderRadius: badgeRadius,
|
|
256
268
|
padding: '2px 8px',
|
|
@@ -268,7 +280,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
268
280
|
textTransform: 'uppercase',
|
|
269
281
|
lineHeight: '17px',
|
|
270
282
|
color: topRightColor,
|
|
271
|
-
background:
|
|
283
|
+
background: nodeBg,
|
|
272
284
|
border: `2px solid ${topRightColor}`,
|
|
273
285
|
borderRadius: badgeRadius,
|
|
274
286
|
padding: '2px 8px',
|
|
@@ -280,41 +292,6 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
280
292
|
</div>
|
|
281
293
|
)}
|
|
282
294
|
|
|
283
|
-
{/* Filename badge — a strip across the bottom border, shown on hover
|
|
284
|
-
and pinned while this node's file is open in the drawer (fileMatch).
|
|
285
|
-
Mirrors the construct/role tab badges; hidden for fileless nodes. */}
|
|
286
|
-
{(hover || fileMatch === true) && c.file ? (
|
|
287
|
-
<div
|
|
288
|
-
style={{
|
|
289
|
-
position: 'absolute',
|
|
290
|
-
bottom: -14,
|
|
291
|
-
left: '50%',
|
|
292
|
-
transform: 'translateX(-50%)',
|
|
293
|
-
zIndex: 1,
|
|
294
|
-
fontFamily: theme.fonts.monospace,
|
|
295
|
-
fontSize: theme.fontSizes[1],
|
|
296
|
-
letterSpacing: 0.5,
|
|
297
|
-
lineHeight: '17px',
|
|
298
|
-
whiteSpace: 'nowrap',
|
|
299
|
-
// filename stays neutral — the construct color belongs to the
|
|
300
|
-
// borders and badges, not the file name
|
|
301
|
-
color: theme.colors.text ?? theme.colors.textSecondary,
|
|
302
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
303
|
-
border: `2px solid ${color}`,
|
|
304
|
-
borderRadius: badgeRadius,
|
|
305
|
-
padding: '2px 8px',
|
|
306
|
-
cursor: 'pointer',
|
|
307
|
-
}}
|
|
308
|
-
onClick={(e) => {
|
|
309
|
-
// Open the file directly; don't also toggle node selection.
|
|
310
|
-
e.stopPropagation();
|
|
311
|
-
callbacks.onOpenFile?.(c.alias);
|
|
312
|
-
}}
|
|
313
|
-
>
|
|
314
|
-
{c.file.split('/').pop()}
|
|
315
|
-
</div>
|
|
316
|
-
) : null}
|
|
317
|
-
|
|
318
295
|
{/* Purpose is no longer shown as a hover tooltip below the node — it
|
|
319
296
|
lives in the declaration panel and the graphify detail payload. */}
|
|
320
297
|
|
|
@@ -405,6 +382,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
405
382
|
isModule && canToggle ? moduleBadgeHoverLabel(label, availableBadgeWidth) : label;
|
|
406
383
|
const frameRadius = isProcess ? 0 : 12;
|
|
407
384
|
const badgeRadius = isProcess ? 0 : 4;
|
|
385
|
+
const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
408
386
|
// Processes are deployment units — solid frame. Modules/packages stay dashed
|
|
409
387
|
// until selected.
|
|
410
388
|
const frameStyle = isProcess || selected ? 'solid' : 'dashed';
|
|
@@ -453,14 +431,15 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
453
431
|
} : undefined}
|
|
454
432
|
style={{
|
|
455
433
|
position: 'absolute',
|
|
456
|
-
// Process:
|
|
457
|
-
// frame border)
|
|
458
|
-
// Module/package
|
|
459
|
-
// Always set left/transform
|
|
460
|
-
// group DOM nodes and `undefined`
|
|
461
|
-
|
|
434
|
+
// Process: the tab sits flush over the frame's top border (`top:
|
|
435
|
+
// -2` — the frame border is 2px) and carries its own full border, so
|
|
436
|
+
// the two read as one piece. Module/package are centred astride the
|
|
437
|
+
// top edge like component badges. Always set left/transform
|
|
438
|
+
// explicitly — React Flow reuses group DOM nodes and `undefined`
|
|
439
|
+
// does not clear a prior value.
|
|
440
|
+
top: isProcess ? -2 : 0,
|
|
462
441
|
left: isProcess ? '50%' : 12,
|
|
463
|
-
transform: isProcess ? 'translateX(-50%)' : '
|
|
442
|
+
transform: isProcess ? 'translateX(-50%)' : 'translateY(-50%)',
|
|
464
443
|
zIndex: canToggle ? 10 : undefined,
|
|
465
444
|
// Width is explicit while collapsible so hover-reveal of more path
|
|
466
445
|
// text animates; the cap keeps both states inside the frame.
|
|
@@ -472,17 +451,23 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
472
451
|
: undefined,
|
|
473
452
|
transition: 'width 140ms ease, box-shadow 120ms ease',
|
|
474
453
|
cursor: canToggle ? 'pointer' : undefined,
|
|
475
|
-
|
|
454
|
+
// The trailing halo squares the rounded corners back off in the fill
|
|
455
|
+
// colour, so an edge crossing a corner can't show through the notch.
|
|
456
|
+
// Square (process) badges have no notch, and the halo would erase the
|
|
457
|
+
// frame border they now sit flush on, so they skip it.
|
|
458
|
+
boxShadow: isProcess
|
|
459
|
+
? undefined
|
|
460
|
+
: `${canToggle && hover ? `0 2px 10px ${color}55, ` : ''}0 0 0 1.5px ${badgeBg}`,
|
|
476
461
|
fontFamily: theme.fonts.monospace,
|
|
477
462
|
fontSize: theme.fontSizes[3],
|
|
478
463
|
fontWeight: 700,
|
|
479
464
|
letterSpacing: 0.6,
|
|
480
465
|
color,
|
|
481
|
-
background:
|
|
482
|
-
border: `
|
|
483
|
-
borderTopWidth:
|
|
466
|
+
background: badgeBg,
|
|
467
|
+
border: `2px solid ${color}`,
|
|
468
|
+
borderTopWidth: 2,
|
|
484
469
|
borderRadius: badgeRadius,
|
|
485
|
-
padding: '
|
|
470
|
+
padding: '3px 9px',
|
|
486
471
|
whiteSpace: 'nowrap',
|
|
487
472
|
overflow: 'hidden',
|
|
488
473
|
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', () => {
|