@principal-ai/subsystems-react 0.53.4 → 0.53.6
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 +19 -12
- 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 +44 -13
|
@@ -75,16 +75,127 @@ export function nodeSize(node: C4Element): { width: number; height: number } {
|
|
|
75
75
|
*/
|
|
76
76
|
const LABEL_SLOT_H = 40;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The technology row's height, pinned for the same reason as the label slot: the
|
|
80
|
+
* row is a line of text plus an optional 16px brand mark, so its natural height
|
|
81
|
+
* depends on which of those are present. Pinning it makes the card's chrome a
|
|
82
|
+
* known quantity, which is what lets `descriptionLineBudget` below say how many
|
|
83
|
+
* description lines fit — and lets the footer pad and the header pad agree about
|
|
84
|
+
* where the card's content actually ends.
|
|
85
|
+
*/
|
|
86
|
+
const TECH_SLOT_H = 18;
|
|
87
|
+
|
|
88
|
+
/** The card's own vertical padding (`padding: '10px 12px'`). */
|
|
89
|
+
const CARD_PAD_Y = 10;
|
|
90
|
+
|
|
91
|
+
/** The card's flex `gap`, between each of its rows. */
|
|
92
|
+
const CARD_GAP = 3;
|
|
93
|
+
|
|
94
|
+
/** The description's line height, as a multiple of its font size. */
|
|
95
|
+
const DESC_LINE_HEIGHT = 1.35;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Breathing room between a grown container's own chrome and the components nested
|
|
99
|
+
* inside it.
|
|
100
|
+
*
|
|
101
|
+
* The description is hidden while grown, so the components land where the
|
|
102
|
+
* description would have been — immediately under the technology row. Without
|
|
103
|
+
* this the gap is the width of one row gap, which reads as the components being
|
|
104
|
+
* crowded against the header while the sides and footer sit `OPEN_SIDE_PAD` away.
|
|
105
|
+
* This is not chrome: nothing is drawn in it.
|
|
106
|
+
*/
|
|
107
|
+
const OPEN_HEADER_AIR = 16;
|
|
108
|
+
|
|
78
109
|
/**
|
|
79
110
|
* Space a *grown* container reserves above its nested components: the card's own
|
|
80
|
-
*
|
|
111
|
+
* padding, its label slot, its technology row, the gaps between them, and a little
|
|
81
112
|
* air. The description is hidden while grown, so it is not part of this.
|
|
82
113
|
*
|
|
114
|
+
* Derived from the same constants the card is built from, rather than written as
|
|
115
|
+
* a literal, so it cannot drift from `descriptionLineBudget` — which subtracts
|
|
116
|
+
* exactly this chrome (and no air) to decide how much description a closed card
|
|
117
|
+
* can show. Those two disagreeing is how a card ends up taller than the box ELK
|
|
118
|
+
* laid out for it.
|
|
119
|
+
*
|
|
83
120
|
* The graph hands this to ELK as the compound parent's top padding rather than
|
|
84
121
|
* nudging the children down itself — so the parent's size and the children's
|
|
85
122
|
* placement are still one ELK fit, not our arithmetic layered on top of it.
|
|
86
123
|
*/
|
|
87
|
-
export const OPEN_HEADER_PAD =
|
|
124
|
+
export const OPEN_HEADER_PAD =
|
|
125
|
+
CARD_PAD_Y * 2 + LABEL_SLOT_H + CARD_GAP + TECH_SLOT_H + CARD_GAP + OPEN_HEADER_AIR;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* How many description lines fit in a card of this height before the drawn box
|
|
129
|
+
* would grow past the box the layout reserved for it.
|
|
130
|
+
*
|
|
131
|
+
* A card's height is a contract with the layout: ELK sized the node, the system
|
|
132
|
+
* frame and every edge against it. The description is the one part of a card whose
|
|
133
|
+
* length the layout cannot know, so it is clamped to what fits rather than allowed
|
|
134
|
+
* to push the border out past the frame drawn around it. Height is a *minimum* on
|
|
135
|
+
* the card, so without this a long description silently grows the drawn box while
|
|
136
|
+
* ELK — and therefore the boundary and the edge endpoints — keeps the old size.
|
|
137
|
+
*
|
|
138
|
+
* `hasTechRow` matters because an empty technology row is zero-height: a person
|
|
139
|
+
* card has one more line of room than a container. The row is pinned to
|
|
140
|
+
* `TECH_SLOT_H` when it has content, so this is exact rather than approximate.
|
|
141
|
+
*/
|
|
142
|
+
export function descriptionLineBudget(opts: {
|
|
143
|
+
reservedHeight: number;
|
|
144
|
+
lineHeight: number;
|
|
145
|
+
/** The card's border, which sits above the content and varies by kind. */
|
|
146
|
+
borderWidth: number;
|
|
147
|
+
hasTechRow: boolean;
|
|
148
|
+
/** Whether a collapse affordance is drawn at the bottom-right. */
|
|
149
|
+
reservesFooter: boolean;
|
|
150
|
+
}): number {
|
|
151
|
+
const { reservedHeight, lineHeight, borderWidth, hasTechRow, reservesFooter } = opts;
|
|
152
|
+
if (lineHeight <= 0) return 0;
|
|
153
|
+
const topChrome =
|
|
154
|
+
borderWidth + CARD_PAD_Y + LABEL_SLOT_H + CARD_GAP + (hasTechRow ? TECH_SLOT_H : 0) + CARD_GAP;
|
|
155
|
+
// Reserving the footer is not the same as the card's own bottom padding. The
|
|
156
|
+
// toggle is `position: absolute` inset from the *padding* box, so it reaches
|
|
157
|
+
// `inset + size` up from the content box's bottom edge — and a description that
|
|
158
|
+
// only cleared the padding would still have its last line drawn through the
|
|
159
|
+
// button. Measured: the last line lands in the bottom ~26px band, and the toggle
|
|
160
|
+
// occupies the bottom 28 of it.
|
|
161
|
+
const bottomChrome = reservesFooter
|
|
162
|
+
? Math.max(CARD_PAD_Y, TOGGLE_INSET + TOGGLE_SIZE)
|
|
163
|
+
: CARD_PAD_Y;
|
|
164
|
+
return Math.max(0, Math.floor((reservedHeight - topChrome - bottomChrome) / lineHeight));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The drill-down affordance, in the card's own box. Extracted from the button's
|
|
169
|
+
* style so the reserved footer below cannot drift from the thing it is reserving
|
|
170
|
+
* for — the two numbers are the same number.
|
|
171
|
+
*/
|
|
172
|
+
const TOGGLE_SIZE = 22;
|
|
173
|
+
const TOGGLE_INSET = 6;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Space a *grown* container reserves below its nested components, so the last row
|
|
177
|
+
* does not sit under the collapse affordance.
|
|
178
|
+
*
|
|
179
|
+
* The toggle is absolutely positioned, so it takes no space in the card's flow and
|
|
180
|
+
* ELK knows nothing about it: without this the parent is fitted to its children
|
|
181
|
+
* alone and the final row is drawn straight through the button. Reserving the
|
|
182
|
+
* toggle's own reach plus a little air keeps that an ELK fit too, rather than
|
|
183
|
+
* something we nudge afterwards.
|
|
184
|
+
*/
|
|
185
|
+
export const OPEN_FOOTER_PAD = TOGGLE_INSET + TOGGLE_SIZE + 8;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Space a *grown* container reserves to the left and right of its nested
|
|
189
|
+
* components.
|
|
190
|
+
*
|
|
191
|
+
* Deliberately the footer's number rather than its own: the nested cards should
|
|
192
|
+
* sit in one uniform inset, not a wider one at the bottom than at the sides. Both
|
|
193
|
+
* are wider than the card's own 12px text inset — the label and technology row are
|
|
194
|
+
* the container's chrome and sit flush with its padding, while the nested cards
|
|
195
|
+
* are separate boxes it contains, and at the same inset they read as part of that
|
|
196
|
+
* chrome rather than held inside it.
|
|
197
|
+
*/
|
|
198
|
+
export const OPEN_SIDE_PAD = OPEN_FOOTER_PAD;
|
|
88
199
|
|
|
89
200
|
type Theme = ReturnType<typeof useTheme>['theme'];
|
|
90
201
|
|
|
@@ -318,6 +429,22 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
318
429
|
const subtitle = nodeSubtitle(node);
|
|
319
430
|
|
|
320
431
|
const size = nodeSize(node);
|
|
432
|
+
// The height the layout reserved for this card — what the system frame and the
|
|
433
|
+
// edge endpoints were placed against. Everything below sizes to this, not to
|
|
434
|
+
// whatever the content would like.
|
|
435
|
+
const reservedHeight = height ?? size.height;
|
|
436
|
+
// A container always has a technology row (at minimum the member count); a
|
|
437
|
+
// person never does.
|
|
438
|
+
const hasTechRow = !!topLabel || node.kind === 'container';
|
|
439
|
+
const descLineHeightPx = theme.fontSizes[0] * DESC_LINE_HEIGHT;
|
|
440
|
+
const descLines = descriptionLineBudget({
|
|
441
|
+
reservedHeight,
|
|
442
|
+
lineHeight: descLineHeightPx,
|
|
443
|
+
borderWidth: style.width,
|
|
444
|
+
hasTechRow,
|
|
445
|
+
// The affordance is drawn exactly when the card can be expanded or collapsed.
|
|
446
|
+
reservesFooter: !!onExpand,
|
|
447
|
+
});
|
|
321
448
|
const centered = shape.align === 'center';
|
|
322
449
|
// The pill's inset is a separate concern from alignment, and a centred
|
|
323
450
|
// component must not inherit it: 30px of each side would leave a 160-wide box
|
|
@@ -331,8 +458,15 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
331
458
|
style={{
|
|
332
459
|
position: 'relative',
|
|
333
460
|
width: width ?? size.width,
|
|
334
|
-
|
|
335
|
-
|
|
461
|
+
// A hard height, not a minimum. The card *is* the box the layout reserved
|
|
462
|
+
// — the system frame is fitted around it and every edge endpoint is placed
|
|
463
|
+
// against it — so it must not be able to grow itself. `overflow: hidden`
|
|
464
|
+
// makes that structural: if the content ever exceeds the box, it is clipped
|
|
465
|
+
// rather than drawn outside the boundary. The description is clamped above
|
|
466
|
+
// so that clipping is never what you actually see.
|
|
467
|
+
height: reservedHeight,
|
|
468
|
+
transition: 'width 420ms ease-out, height 420ms ease-out',
|
|
469
|
+
overflow: 'hidden',
|
|
336
470
|
boxSizing: 'border-box',
|
|
337
471
|
display: 'flex',
|
|
338
472
|
flexDirection: 'column',
|
|
@@ -340,7 +474,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
340
474
|
// marker is conditional), and centring pushes the tag to a different
|
|
341
475
|
// height on every card. In a graph grid that makes the row unscannable.
|
|
342
476
|
justifyContent: 'flex-start',
|
|
343
|
-
gap:
|
|
477
|
+
gap: CARD_GAP,
|
|
344
478
|
padding: pillInset ? '10px 30px' : '10px 12px',
|
|
345
479
|
borderRadius: shape.radius,
|
|
346
480
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
@@ -362,6 +496,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
362
496
|
justifyContent: centered ? 'center' : 'space-between',
|
|
363
497
|
gap: 6,
|
|
364
498
|
height: LABEL_SLOT_H,
|
|
499
|
+
// Without this the slot shrinks when the content exceeds the card's fixed
|
|
500
|
+
// height, and `descriptionLineBudget`'s chrome stops being the chrome —
|
|
501
|
+
// measured at 39.11px instead of 40 the moment the description ran long.
|
|
502
|
+
flexShrink: 0,
|
|
365
503
|
minWidth: 0,
|
|
366
504
|
}}
|
|
367
505
|
>
|
|
@@ -414,6 +552,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
414
552
|
letterSpacing: 0.5,
|
|
415
553
|
textTransform: 'uppercase',
|
|
416
554
|
minWidth: 0,
|
|
555
|
+
// Pinned, like the label slot above it: an empty row would otherwise be
|
|
556
|
+
// zero-height and the description budget would have to guess which cards
|
|
557
|
+
// have a technology row. Empty stays zero — a person has no technology.
|
|
558
|
+
...(hasTechRow ? { height: TECH_SLOT_H, flexShrink: 0 } : {}),
|
|
417
559
|
}}
|
|
418
560
|
>
|
|
419
561
|
{topLabel && (
|
|
@@ -447,16 +589,25 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
447
589
|
</span>
|
|
448
590
|
)}
|
|
449
591
|
</span>
|
|
450
|
-
{showDescription && subtitle && (
|
|
592
|
+
{showDescription && subtitle && descLines > 0 && (
|
|
451
593
|
<span
|
|
452
594
|
style={{
|
|
453
595
|
fontFamily: theme.fonts.monospace,
|
|
454
596
|
fontSize: theme.fontSizes[0],
|
|
455
597
|
color: muted,
|
|
456
|
-
lineHeight:
|
|
598
|
+
lineHeight: DESC_LINE_HEIGHT,
|
|
457
599
|
whiteSpace: 'normal',
|
|
458
600
|
overflowWrap: 'anywhere',
|
|
459
601
|
textAlign: centered ? 'center' : 'left',
|
|
602
|
+
// Clamped to the room the layout left for it. The card's height is a
|
|
603
|
+
// contract with ELK — the boundary and every edge endpoint are placed
|
|
604
|
+
// against it — so the description cannot be allowed to grow the box.
|
|
605
|
+
// Same mechanism the label uses above; the count is derived from the
|
|
606
|
+
// reserved height rather than tuned by hand.
|
|
607
|
+
display: '-webkit-box',
|
|
608
|
+
WebkitLineClamp: descLines,
|
|
609
|
+
WebkitBoxOrient: 'vertical',
|
|
610
|
+
overflow: 'hidden',
|
|
460
611
|
}}
|
|
461
612
|
>
|
|
462
613
|
{subtitle}
|
|
@@ -475,13 +626,13 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
475
626
|
aria-label="Show components"
|
|
476
627
|
style={{
|
|
477
628
|
position: 'absolute',
|
|
478
|
-
right:
|
|
479
|
-
bottom:
|
|
629
|
+
right: TOGGLE_INSET,
|
|
630
|
+
bottom: TOGGLE_INSET,
|
|
480
631
|
display: 'inline-flex',
|
|
481
632
|
alignItems: 'center',
|
|
482
633
|
justifyContent: 'center',
|
|
483
|
-
width:
|
|
484
|
-
height:
|
|
634
|
+
width: TOGGLE_SIZE,
|
|
635
|
+
height: TOGGLE_SIZE,
|
|
485
636
|
padding: 0,
|
|
486
637
|
borderRadius: 4,
|
|
487
638
|
border: `1px solid ${style.color}`,
|
package/src/subsystem/c4.ts
CHANGED
|
@@ -170,23 +170,23 @@ export interface C4Group {
|
|
|
170
170
|
* Derive the frames to draw from the elements and the current scope.
|
|
171
171
|
*
|
|
172
172
|
* A container becomes a frame whenever its components are on screen — in the
|
|
173
|
-
* component view, or when it is the opened
|
|
174
|
-
* whichever containers are drawn. No frame is authored; this is the view
|
|
175
|
-
* element set, so a container's identity (id, label, technology, colour)
|
|
176
|
-
* only on the element.
|
|
173
|
+
* component view, or when it is one of the opened containers. The system frame
|
|
174
|
+
* wraps whichever containers are drawn. No frame is authored; this is the view
|
|
175
|
+
* of the element set, so a container's identity (id, label, technology, colour)
|
|
176
|
+
* lives only on the element.
|
|
177
177
|
*/
|
|
178
178
|
export function deriveC4Groups(
|
|
179
|
-
model: Pick<C4Model, 'view' | 'system' | 'nodes'
|
|
179
|
+
model: Pick<C4Model, 'view' | 'system' | 'nodes'> & OpenState,
|
|
180
180
|
): C4Group[] {
|
|
181
181
|
const groups: C4Group[] = [];
|
|
182
182
|
const containers = model.nodes.filter((n): n is C4Container => n.kind === 'container');
|
|
183
183
|
const components = model.nodes.filter((n): n is C4Component => n.kind === 'component');
|
|
184
184
|
|
|
185
|
-
const opened = model
|
|
185
|
+
const opened = new Set(openContainerSet(model));
|
|
186
186
|
// Component view shows every container's components; container view shows a
|
|
187
187
|
// container's components only when it is opened.
|
|
188
188
|
const framing = (containerId: string): boolean =>
|
|
189
|
-
model.view === 'component' || opened
|
|
189
|
+
model.view === 'component' || opened.has(containerId);
|
|
190
190
|
|
|
191
191
|
const framedContainers: string[] = [];
|
|
192
192
|
for (const c of containers) {
|
|
@@ -294,7 +294,7 @@ export function protocolColor(protocol: string | undefined): string {
|
|
|
294
294
|
* A drawable C4 projection. Authored: the boxes and lines, nothing drawn for
|
|
295
295
|
* you. Frames are derived by the renderer from the elements' `parentId` chains
|
|
296
296
|
* — a container is a frame when its components are being shown (component view,
|
|
297
|
-
* or opened via `
|
|
297
|
+
* or opened via `openContainerIds`), never a separately-authored thing.
|
|
298
298
|
*/
|
|
299
299
|
export interface C4Model {
|
|
300
300
|
view: C4View;
|
|
@@ -302,13 +302,52 @@ export interface C4Model {
|
|
|
302
302
|
nodes: C4Element[];
|
|
303
303
|
edges: C4Edge[];
|
|
304
304
|
/**
|
|
305
|
-
* Drill-down scope: the
|
|
306
|
-
*
|
|
307
|
-
* components are hidden. The renderer derives the frames either way.
|
|
305
|
+
* Drill-down scope: the containers whose components are shown nested inside
|
|
306
|
+
* them as a grown card. Absent/empty → every container is drawn as a plain box
|
|
307
|
+
* and all components are hidden. The renderer derives the frames either way.
|
|
308
|
+
*
|
|
309
|
+
* Several may be open at once: each is an independent ELK parent, and opening
|
|
310
|
+
* one never closes another. Order is not significant — {@link openContainerSet}
|
|
311
|
+
* normalizes it, so two permutations of the same set are one layout.
|
|
312
|
+
*/
|
|
313
|
+
openContainerIds?: readonly string[];
|
|
314
|
+
/**
|
|
315
|
+
* Single-container shorthand for {@link C4Model.openContainerIds}, folded into
|
|
316
|
+
* it by {@link openContainerSet}. Kept because `C4Model` is a published type
|
|
317
|
+
* and removing the field would break every caller at once; prefer the plural,
|
|
318
|
+
* which is what the drill-down actually means now.
|
|
319
|
+
*
|
|
320
|
+
* @deprecated Use {@link C4Model.openContainerIds}.
|
|
308
321
|
*/
|
|
309
322
|
openContainerId?: string | null;
|
|
310
323
|
}
|
|
311
324
|
|
|
325
|
+
/** Anything that can say which containers are open, however it says it. */
|
|
326
|
+
type OpenState = Pick<C4Model, 'openContainerId' | 'openContainerIds'>;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The open containers as a deduplicated, **sorted** list.
|
|
330
|
+
*
|
|
331
|
+
* Sorted on purpose: this doubles as the layout cache key, and two callers that
|
|
332
|
+
* open the same containers in a different order are in the same state — one
|
|
333
|
+
* layout, not two. A plain `Set` iteration order would follow insertion, so
|
|
334
|
+
* `[a, b]` and `[b, a]` would each compute their own ELK run for a diagram that
|
|
335
|
+
* did not change.
|
|
336
|
+
*/
|
|
337
|
+
export function openContainerSet(state: OpenState): string[] {
|
|
338
|
+
const ids = new Set<string>(state.openContainerIds ?? []);
|
|
339
|
+
if (state.openContainerId) ids.add(state.openContainerId);
|
|
340
|
+
return [...ids].sort();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Cache key for one open state. `''` is the closed diagram, which is why it is a
|
|
345
|
+
* join and not a count.
|
|
346
|
+
*/
|
|
347
|
+
export function openStateKey(openIds: readonly string[]): string {
|
|
348
|
+
return openIds.join(',');
|
|
349
|
+
}
|
|
350
|
+
|
|
312
351
|
/**
|
|
313
352
|
* `owner/name` from a purl, trimmed of scheme and fragment. Used to label the
|
|
314
353
|
* system frame when a repo key is known.
|
|
@@ -171,6 +171,29 @@ describe('planCompoundGroups', () => {
|
|
|
171
171
|
expect(plan.skipped).toEqual(['proc']);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
test('carries the parent chrome padding through, all three pads', () => {
|
|
175
|
+
// `computeElkLayout` turns these into ELK's `elk.padding` on the parent. If
|
|
176
|
+
// they stop here the parent is fitted to its children alone, and chrome drawn
|
|
177
|
+
// around the content — the grown container's label slot, its collapse
|
|
178
|
+
// affordance, the inset that separates nested cards from its edges — is lost.
|
|
179
|
+
const plan = planCompoundGroups(
|
|
180
|
+
[{ id: 'host', memberIds: ['a', 'b'], padTop: 84, padBottom: 36, padX: 36 }],
|
|
181
|
+
['a', 'b'],
|
|
182
|
+
);
|
|
183
|
+
expect(plan.built[0]!.padTop).toBe(84);
|
|
184
|
+
expect(plan.built[0]!.padBottom).toBe(36);
|
|
185
|
+
expect(plan.built[0]!.padX).toBe(36);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('leaves the padding unset when the group asks for none', () => {
|
|
189
|
+
// Unset, not zero: `computeElkLayout` falls back to the default 12px, so a
|
|
190
|
+
// group that never asked for chrome keeps the padding it always had.
|
|
191
|
+
const plan = planCompoundGroups([{ id: 'proc', memberIds: ['a', 'b'] }], ['a', 'b']);
|
|
192
|
+
expect(plan.built[0]!.padTop).toBeUndefined();
|
|
193
|
+
expect(plan.built[0]!.padBottom).toBeUndefined();
|
|
194
|
+
expect(plan.built[0]!.padX).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
174
197
|
test('counts leaves through nested groups', () => {
|
|
175
198
|
// process → module(one leaf) is still a single leaf, so both drop by
|
|
176
199
|
// default even though the process lists one member id.
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -593,6 +593,23 @@ export interface CompoundGroupDef {
|
|
|
593
593
|
* partition set on the parent itself is ignored.
|
|
594
594
|
*/
|
|
595
595
|
padTop?: number;
|
|
596
|
+
/**
|
|
597
|
+
* Space reserved below the last child, in px — the `padTop` of the other end.
|
|
598
|
+
* Set it when the parent draws chrome at the bottom that its children must clear,
|
|
599
|
+
* e.g. the drill-down's absolutely-positioned collapse affordance, which takes
|
|
600
|
+
* no space in flow and so is invisible to ELK unless it is reserved here.
|
|
601
|
+
*
|
|
602
|
+
* Falls back to the same default padding as the other three sides.
|
|
603
|
+
*/
|
|
604
|
+
padBottom?: number;
|
|
605
|
+
/**
|
|
606
|
+
* Space reserved to the left *and* right of the children, in px. One number for
|
|
607
|
+
* both sides because a parent that insets its contents does so symmetrically —
|
|
608
|
+
* there is no chrome that lives on one side only.
|
|
609
|
+
*
|
|
610
|
+
* Falls back to the default padding.
|
|
611
|
+
*/
|
|
612
|
+
padX?: number;
|
|
596
613
|
}
|
|
597
614
|
|
|
598
615
|
/** Which groups ELK builds, and which it drops. */
|
|
@@ -601,7 +618,14 @@ export interface CompoundGroupPlan {
|
|
|
601
618
|
* Built groups in build order — a group's children always appear before it,
|
|
602
619
|
* so callers can map ids to shells in one forward pass.
|
|
603
620
|
*/
|
|
604
|
-
built: Array<{
|
|
621
|
+
built: Array<{
|
|
622
|
+
id: string;
|
|
623
|
+
childIds: string[];
|
|
624
|
+
minWidth?: number;
|
|
625
|
+
padTop?: number;
|
|
626
|
+
padBottom?: number;
|
|
627
|
+
padX?: number;
|
|
628
|
+
}>;
|
|
605
629
|
/**
|
|
606
630
|
* Dropped groups. Their members are promoted into the nearest built
|
|
607
631
|
* ancestor, so callers must clear those members' `parentId`.
|
|
@@ -703,6 +727,8 @@ export function planCompoundGroups(
|
|
|
703
727
|
// Carried through, not just validated here: the caller turns each of
|
|
704
728
|
// these into ELK layout options on the parent node.
|
|
705
729
|
padTop: g.padTop,
|
|
730
|
+
padBottom: g.padBottom,
|
|
731
|
+
padX: g.padX,
|
|
706
732
|
});
|
|
707
733
|
}
|
|
708
734
|
if (!progress) {
|
|
@@ -933,16 +959,18 @@ export async function computeElkLayout(
|
|
|
933
959
|
// edge by ~13px) plus a gap before the first child.
|
|
934
960
|
'elk.padding': '[top=64,left=24,bottom=24,right=24]',
|
|
935
961
|
'elk.spacing.nodeNode': '40',
|
|
936
|
-
// Edges inside a frame host labels too, so the frame
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
962
|
+
// Edges inside a frame host labels too, so the frame reserves the same
|
|
963
|
+
// between-layer clearance the root does — the caller's `interLayerSpacing`.
|
|
964
|
+
//
|
|
965
|
+
// The reserved label box already carries the chip width plus
|
|
966
|
+
// `EDGE_LABEL_SIDE_PADDING` at each end (see `elkEdge.labels` below), and
|
|
967
|
+
// CENTER_LAYER applies this spacing on *both* sides of that label layer.
|
|
968
|
+
// Setting the full `width + 2 * padding` here therefore stacked a second
|
|
969
|
+
// box on top of the first: the same two nodes measured a 612px gap inside a
|
|
970
|
+
// process frame vs 268px at the root. Per-side clearance only; labels off
|
|
941
971
|
// falls back to a plain between-layer gap.
|
|
942
972
|
'elk.layered.spacing.nodeNodeBetweenLayers': String(
|
|
943
|
-
edgeLabels?.enabled === false
|
|
944
|
-
? 40
|
|
945
|
-
: (edgeLabels?.width ?? EDGE_LABEL_WIDTH) + EDGE_LABEL_SIDE_PADDING * 2,
|
|
973
|
+
edgeLabels?.enabled === false ? 40 : (options.interLayerSpacing ?? 0),
|
|
946
974
|
),
|
|
947
975
|
};
|
|
948
976
|
// Match the root's inline-label reservation, so ELK inside the frame leaves a
|
|
@@ -1008,10 +1036,13 @@ export async function computeElkLayout(
|
|
|
1008
1036
|
sizeOptions['elk.nodeSize.constraints'] = 'MINIMUM_SIZE';
|
|
1009
1037
|
sizeOptions['elk.nodeSize.minimum'] = `(${g.minWidth},0)`;
|
|
1010
1038
|
}
|
|
1011
|
-
if (g.padTop != null) {
|
|
1012
|
-
// Reserving the parent's own chrome. Children then
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1039
|
+
if (g.padTop != null || g.padBottom != null || g.padX != null) {
|
|
1040
|
+
// Reserving the parent's own chrome. Children then sit inside it, and the
|
|
1041
|
+
// parent's size still comes from ELK's fit. Each side falls back to the
|
|
1042
|
+
// default padding, so setting only one side keeps the others as they were
|
|
1043
|
+
// rather than collapsing them to zero.
|
|
1044
|
+
sizeOptions['elk.padding'] =
|
|
1045
|
+
`[top=${g.padTop ?? 12},left=${g.padX ?? 12},bottom=${g.padBottom ?? 12},right=${g.padX ?? 12}]`;
|
|
1015
1046
|
}
|
|
1016
1047
|
builtGroups.set(g.id, {
|
|
1017
1048
|
id: g.id,
|