@principal-ai/subsystems-react 0.53.0 → 0.53.2
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/subsystem/C4Graph.d.ts +91 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +672 -180
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +36 -1
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +45 -10
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +14 -5
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/c4.d.ts +27 -3
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +48 -0
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +68 -8
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +95 -14
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +2 -1
- package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
- package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
- package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +132 -0
- package/src/subsystem/C4Graph.groups.test.ts +217 -0
- package/src/subsystem/C4Graph.morph.test.tsx +208 -0
- package/src/subsystem/C4Graph.tsx +803 -226
- package/src/subsystem/C4NodeCard.test.ts +6 -3
- package/src/subsystem/C4NodeCard.tsx +92 -20
- package/src/subsystem/IssueList.category.test.ts +39 -0
- package/src/subsystem/IssueList.tsx +16 -5
- package/src/subsystem/c4.ts +74 -3
- package/src/utils/elkLayout.test.ts +131 -0
- package/src/utils/elkLayout.ts +133 -16
|
@@ -150,18 +150,21 @@ describe('nodeTopLabel — the technology, or the kind when none is stated', ()
|
|
|
150
150
|
expect(
|
|
151
151
|
nodeTopLabel(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
|
|
152
152
|
).toBe('Bun + Electrobun');
|
|
153
|
-
expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
154
153
|
});
|
|
155
154
|
|
|
156
155
|
test('a container with no technology falls back to its kind', () => {
|
|
157
156
|
expect(nodeTopLabel(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
|
|
158
157
|
});
|
|
159
158
|
|
|
160
|
-
test('
|
|
161
|
-
expect(nodeTopLabel(node({ kind: 'component' }))).toBe('component');
|
|
159
|
+
test('an external system / person falls back to its C4 kind', () => {
|
|
162
160
|
expect(nodeTopLabel(node({ kind: 'external-system' }))).toBe('external-system');
|
|
163
161
|
expect(nodeTopLabel(node({ kind: 'person' }))).toBe('person');
|
|
164
162
|
});
|
|
163
|
+
|
|
164
|
+
test('a component has no top label — its technology is the container’s', () => {
|
|
165
|
+
expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('');
|
|
166
|
+
expect(nodeTopLabel(node({ kind: 'component' }))).toBe('');
|
|
167
|
+
});
|
|
165
168
|
});
|
|
166
169
|
|
|
167
170
|
describe('nodeShape — kind without relying on colour or dash', () => {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
|
-
import { AppWindow, Component, Database } from 'lucide-react';
|
|
16
|
+
import { AppWindow, Component, Database, Maximize2 } from 'lucide-react';
|
|
17
17
|
import type { LucideIcon } from 'lucide-react';
|
|
18
18
|
import { TechMark, technologyBrand } from './techIcons';
|
|
19
19
|
import type { C4Element } from './c4';
|
|
@@ -50,6 +50,17 @@ export function nodeSize(node: C4Element): { width: number; height: number } {
|
|
|
50
50
|
*/
|
|
51
51
|
const LABEL_SLOT_H = 40;
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Space a *grown* container reserves above its nested components: the card's own
|
|
55
|
+
* top padding + the label slot + the row gap + the technology row, plus a little
|
|
56
|
+
* air. The description is hidden while grown, so it is not part of this.
|
|
57
|
+
*
|
|
58
|
+
* The graph hands this to ELK as the compound parent's top padding rather than
|
|
59
|
+
* nudging the children down itself — so the parent's size and the children's
|
|
60
|
+
* placement are still one ELK fit, not our arithmetic layered on top of it.
|
|
61
|
+
*/
|
|
62
|
+
export const OPEN_HEADER_PAD = 84;
|
|
63
|
+
|
|
53
64
|
type Theme = ReturnType<typeof useTheme>['theme'];
|
|
54
65
|
|
|
55
66
|
export interface NodeStyle {
|
|
@@ -151,14 +162,17 @@ export function nodeKindIcon(node: C4Element): LucideIcon | undefined {
|
|
|
151
162
|
* The text on the top row: the technology the box is built with (Bun, React,
|
|
152
163
|
* Postgres…). Falls back to the container kind / C4 kind when no technology is
|
|
153
164
|
* stated, so the row is never blank.
|
|
165
|
+
*
|
|
166
|
+
* A component returns `''`: its technology is almost always the container's, so
|
|
167
|
+
* restating it is noise. The container frame already carries it.
|
|
154
168
|
*/
|
|
155
169
|
export function nodeTopLabel(node: C4Element): string {
|
|
170
|
+
if (node.kind === 'component') return '';
|
|
156
171
|
const technology = nodeTechnology(node);
|
|
157
172
|
if (technology) return technology;
|
|
158
173
|
switch (node.kind) {
|
|
159
174
|
case 'container':
|
|
160
175
|
return node.containerKind;
|
|
161
|
-
case 'component':
|
|
162
176
|
case 'external-system':
|
|
163
177
|
case 'person':
|
|
164
178
|
return node.kind;
|
|
@@ -231,9 +245,31 @@ export interface C4NodeCardProps {
|
|
|
231
245
|
* row for containers. Omitted/0 → no count.
|
|
232
246
|
*/
|
|
233
247
|
componentCount?: number;
|
|
248
|
+
/**
|
|
249
|
+
* When set, the card draws an expand affordance (bottom-right) that calls
|
|
250
|
+
* this — the drill-down trigger. Only containers with components pass it.
|
|
251
|
+
*/
|
|
252
|
+
onExpand?: () => void;
|
|
253
|
+
/**
|
|
254
|
+
* Explicit box size, overriding the kind's default. Lets a host animate the
|
|
255
|
+
* card (the drill-down grow) without the card owning that state.
|
|
256
|
+
*/
|
|
257
|
+
width?: number;
|
|
258
|
+
height?: number;
|
|
259
|
+
/**
|
|
260
|
+
* Show the description line under the label. Off when the card is grown to
|
|
261
|
+
* host components — the internals occupy that space.
|
|
262
|
+
* @default true
|
|
263
|
+
*/
|
|
264
|
+
showDescription?: boolean;
|
|
265
|
+
/**
|
|
266
|
+
* Content drawn below the header (label / technology / description). Used by
|
|
267
|
+
* the grown card to host its component cards.
|
|
268
|
+
*/
|
|
269
|
+
children?: React.ReactNode;
|
|
234
270
|
}
|
|
235
271
|
|
|
236
|
-
export function C4NodeCard({ node, selected = false, handles, onClick, componentCount = 0 }: C4NodeCardProps) {
|
|
272
|
+
export function C4NodeCard({ node, selected = false, handles, onClick, componentCount = 0, onExpand, width, height, showDescription = true, children }: C4NodeCardProps) {
|
|
237
273
|
const { theme } = useTheme();
|
|
238
274
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
239
275
|
const style = nodeStyle(node, theme, selected);
|
|
@@ -252,8 +288,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
252
288
|
<div
|
|
253
289
|
onClick={onClick}
|
|
254
290
|
style={{
|
|
255
|
-
|
|
256
|
-
|
|
291
|
+
position: 'relative',
|
|
292
|
+
width: width ?? size.width,
|
|
293
|
+
minHeight: height ?? size.height,
|
|
294
|
+
transition: 'width 420ms ease-out, min-height 420ms ease-out',
|
|
257
295
|
boxSizing: 'border-box',
|
|
258
296
|
display: 'flex',
|
|
259
297
|
flexDirection: 'column',
|
|
@@ -280,7 +318,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
280
318
|
style={{
|
|
281
319
|
display: 'flex',
|
|
282
320
|
alignItems: 'flex-start',
|
|
283
|
-
justifyContent: centered ? 'center' : '
|
|
321
|
+
justifyContent: centered ? 'center' : 'space-between',
|
|
284
322
|
gap: 6,
|
|
285
323
|
height: LABEL_SLOT_H,
|
|
286
324
|
minWidth: 0,
|
|
@@ -337,20 +375,22 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
337
375
|
minWidth: 0,
|
|
338
376
|
}}
|
|
339
377
|
>
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
378
|
+
{topLabel && (
|
|
379
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
|
380
|
+
{brand && <TechMark brand={brand} />}
|
|
381
|
+
<span
|
|
382
|
+
style={{
|
|
383
|
+
fontWeight: 600,
|
|
384
|
+
whiteSpace: 'nowrap',
|
|
385
|
+
overflow: 'hidden',
|
|
386
|
+
textOverflow: 'ellipsis',
|
|
387
|
+
minWidth: 0,
|
|
388
|
+
}}
|
|
389
|
+
>
|
|
390
|
+
{topLabel}
|
|
391
|
+
</span>
|
|
352
392
|
</span>
|
|
353
|
-
|
|
393
|
+
)}
|
|
354
394
|
{node.kind === 'container' && (
|
|
355
395
|
<span
|
|
356
396
|
style={{
|
|
@@ -366,7 +406,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
366
406
|
</span>
|
|
367
407
|
)}
|
|
368
408
|
</span>
|
|
369
|
-
{subtitle && (
|
|
409
|
+
{showDescription && subtitle && (
|
|
370
410
|
<span
|
|
371
411
|
style={{
|
|
372
412
|
fontFamily: theme.fonts.monospace,
|
|
@@ -381,6 +421,38 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
381
421
|
{subtitle}
|
|
382
422
|
</span>
|
|
383
423
|
)}
|
|
424
|
+
{children}
|
|
425
|
+
{/* Drill-down affordance: bottom-right, its own hit target so the card's
|
|
426
|
+
own click (select) is unaffected. */}
|
|
427
|
+
{onExpand && (
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
onClick={(e) => {
|
|
431
|
+
e.stopPropagation();
|
|
432
|
+
onExpand();
|
|
433
|
+
}}
|
|
434
|
+
aria-label="Show components"
|
|
435
|
+
style={{
|
|
436
|
+
position: 'absolute',
|
|
437
|
+
right: 6,
|
|
438
|
+
bottom: 6,
|
|
439
|
+
display: 'inline-flex',
|
|
440
|
+
alignItems: 'center',
|
|
441
|
+
justifyContent: 'center',
|
|
442
|
+
width: 22,
|
|
443
|
+
height: 22,
|
|
444
|
+
padding: 0,
|
|
445
|
+
borderRadius: 4,
|
|
446
|
+
border: `1px solid ${style.color}`,
|
|
447
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
448
|
+
color: style.color,
|
|
449
|
+
cursor: 'pointer',
|
|
450
|
+
lineHeight: 1,
|
|
451
|
+
}}
|
|
452
|
+
>
|
|
453
|
+
<Maximize2 size={13} strokeWidth={2.25} />
|
|
454
|
+
</button>
|
|
455
|
+
)}
|
|
384
456
|
{handles}
|
|
385
457
|
</div>
|
|
386
458
|
);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { issueCategory, issueKindLabel } from './IssueList';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every C4 process-verification kind must land in the dynamic-topology
|
|
6
|
+
* category. These kinds are emitted by new audit passes over time, and an
|
|
7
|
+
* unmapped kind silently falls through to `construct` — the list then files a
|
|
8
|
+
* process finding under Constructs while the maintenance lane tally counts it
|
|
9
|
+
* as dynamic-topology, so the construct lane reads verified (green) beside a
|
|
10
|
+
* process issue. The regressions are real: `boundary_process_missing` shipped
|
|
11
|
+
* unmapped long before the container-first kinds joined it.
|
|
12
|
+
*/
|
|
13
|
+
describe('issueCategory — C4 process kinds', () => {
|
|
14
|
+
const processKinds = [
|
|
15
|
+
'boundary_process_missing',
|
|
16
|
+
'boundary_process_nest_disagree',
|
|
17
|
+
'boundary_process_unassigned',
|
|
18
|
+
'boundary_process_proposed',
|
|
19
|
+
'boundary_process_rejected',
|
|
20
|
+
'boundary_process_unbacked',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
for (const kind of processKinds) {
|
|
24
|
+
test(`${kind} categorizes as dynamic-topology`, () => {
|
|
25
|
+
expect(issueCategory({ id: 'x', severity: 'info', kind, message: '' })).toBe(
|
|
26
|
+
'dynamic-topology',
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('mapped kinds get human labels, not the humanized fallback', () => {
|
|
32
|
+
expect(issueKindLabel({ id: 'x', severity: 'info', kind: 'boundary_process_missing', message: '' })).toBe(
|
|
33
|
+
'Process claim missing',
|
|
34
|
+
);
|
|
35
|
+
expect(issueKindLabel({ id: 'x', severity: 'info', kind: 'boundary_process_unbacked', message: '' })).toBe(
|
|
36
|
+
'Process claim unbacked',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -14,23 +14,25 @@
|
|
|
14
14
|
import { useEffect, useRef, useState } from 'react';
|
|
15
15
|
import type { LucideIcon } from 'lucide-react';
|
|
16
16
|
import {
|
|
17
|
+
AppWindow,
|
|
17
18
|
ChevronDown,
|
|
18
19
|
ChevronRight,
|
|
19
20
|
CircleCheck,
|
|
20
21
|
CircleDashed,
|
|
21
22
|
CircleEllipsis,
|
|
22
23
|
CircleHelp,
|
|
24
|
+
CircleSlash,
|
|
23
25
|
CircleX,
|
|
24
26
|
Component,
|
|
25
27
|
FileX,
|
|
26
28
|
FolderGit2,
|
|
27
29
|
Footprints,
|
|
28
30
|
History,
|
|
31
|
+
Link2Off,
|
|
29
32
|
MapPin,
|
|
30
33
|
Network,
|
|
31
34
|
Route,
|
|
32
35
|
Search,
|
|
33
|
-
Server,
|
|
34
36
|
Shapes,
|
|
35
37
|
Sigma,
|
|
36
38
|
Split,
|
|
@@ -80,7 +82,7 @@ export const SUBSYSTEM_ISSUE_CATEGORY_ICON: Record<
|
|
|
80
82
|
repo: FolderGit2,
|
|
81
83
|
construct: Component,
|
|
82
84
|
'static-topology': Network,
|
|
83
|
-
'dynamic-topology':
|
|
85
|
+
'dynamic-topology': AppWindow,
|
|
84
86
|
trail: Route,
|
|
85
87
|
};
|
|
86
88
|
|
|
@@ -112,9 +114,11 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
|
|
|
112
114
|
// C4 process verification (verifyProcessBoundaries): a boundary whose
|
|
113
115
|
// container claim is not an accepted one. `verified` reports nothing —
|
|
114
116
|
// the audit reports absence only.
|
|
117
|
+
boundary_process_missing: 'dynamic-topology',
|
|
115
118
|
boundary_process_unassigned: 'dynamic-topology',
|
|
116
119
|
boundary_process_proposed: 'dynamic-topology',
|
|
117
120
|
boundary_process_rejected: 'dynamic-topology',
|
|
121
|
+
boundary_process_unbacked: 'dynamic-topology',
|
|
118
122
|
trail: 'trail',
|
|
119
123
|
step_unconfirmed: 'trail',
|
|
120
124
|
step_stale: 'trail',
|
|
@@ -180,9 +184,12 @@ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
|
|
|
180
184
|
boundary_process_nest_disagree: Split,
|
|
181
185
|
// C4 process verification: unclaimed = no container at all, proposed = an
|
|
182
186
|
// agent scaffold awaits a decision, rejected = a container was declined.
|
|
187
|
+
boundary_process_missing: CircleSlash,
|
|
183
188
|
boundary_process_unassigned: CircleEllipsis,
|
|
184
189
|
boundary_process_proposed: CircleDashed,
|
|
185
190
|
boundary_process_rejected: CircleX,
|
|
191
|
+
// Container-first gate: a claim cites a key no accepted container owns.
|
|
192
|
+
boundary_process_unbacked: Link2Off,
|
|
186
193
|
// Trail step verification: unconfirmed = no augmentation yet, stale = code changed
|
|
187
194
|
step_unconfirmed: CircleHelp,
|
|
188
195
|
step_stale: History,
|
|
@@ -267,9 +274,11 @@ const KIND_LABEL: Record<string, string> = {
|
|
|
267
274
|
boundary_process_nest_disagree: 'Module spans multiple process contexts',
|
|
268
275
|
// C4 process verification (labels read as boundary states, since the layer
|
|
269
276
|
// scopes them to process frames).
|
|
277
|
+
boundary_process_missing: 'Process claim missing',
|
|
270
278
|
boundary_process_unassigned: 'Process boundary unclaimed',
|
|
271
279
|
boundary_process_proposed: 'Container awaiting decision',
|
|
272
280
|
boundary_process_rejected: 'Container rejected',
|
|
281
|
+
boundary_process_unbacked: 'Process claim unbacked',
|
|
273
282
|
trail: 'Step issue',
|
|
274
283
|
step_unconfirmed: 'Call site not verified',
|
|
275
284
|
step_stale: 'Call site changed',
|
|
@@ -304,9 +313,11 @@ const KIND_ORDER: Record<string, number> = {
|
|
|
304
313
|
boundary_module_file_mismatch: 23,
|
|
305
314
|
// Runtime topology — process containment
|
|
306
315
|
boundary_process_nest_disagree: 30,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
316
|
+
boundary_process_missing: 31,
|
|
317
|
+
boundary_process_unassigned: 32,
|
|
318
|
+
boundary_process_unbacked: 33,
|
|
319
|
+
boundary_process_proposed: 34,
|
|
320
|
+
boundary_process_rejected: 35,
|
|
310
321
|
// Trail — mechanical issues first, then step verification
|
|
311
322
|
trail: 40,
|
|
312
323
|
step_unconfirmed: 41,
|
package/src/subsystem/c4.ts
CHANGED
|
@@ -153,7 +153,11 @@ export interface C4ElementSet {
|
|
|
153
153
|
/** Which level to draw. Determines which frames are drawn. */
|
|
154
154
|
export type C4View = 'container' | 'component';
|
|
155
155
|
|
|
156
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* A compound frame the renderer draws: the system, or a container holding its
|
|
158
|
+
* components. Never authored — `deriveC4Groups` produces these from the element
|
|
159
|
+
* `parentId` chains and the current scope.
|
|
160
|
+
*/
|
|
157
161
|
export interface C4Group {
|
|
158
162
|
id: string;
|
|
159
163
|
kind: 'system' | 'container';
|
|
@@ -162,6 +166,63 @@ export interface C4Group {
|
|
|
162
166
|
memberIds: string[];
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Derive the frames to draw from the elements and the current scope.
|
|
171
|
+
*
|
|
172
|
+
* A container becomes a frame whenever its components are on screen — in the
|
|
173
|
+
* component view, or when it is the opened container. The system frame wraps
|
|
174
|
+
* whichever containers are drawn. No frame is authored; this is the view of the
|
|
175
|
+
* element set, so a container's identity (id, label, technology, colour) lives
|
|
176
|
+
* only on the element.
|
|
177
|
+
*/
|
|
178
|
+
export function deriveC4Groups(
|
|
179
|
+
model: Pick<C4Model, 'view' | 'system' | 'nodes' | 'openContainerId'>,
|
|
180
|
+
): C4Group[] {
|
|
181
|
+
const groups: C4Group[] = [];
|
|
182
|
+
const containers = model.nodes.filter((n): n is C4Container => n.kind === 'container');
|
|
183
|
+
const components = model.nodes.filter((n): n is C4Component => n.kind === 'component');
|
|
184
|
+
|
|
185
|
+
const opened = model.openContainerId ?? null;
|
|
186
|
+
// Component view shows every container's components; container view shows a
|
|
187
|
+
// container's components only when it is opened.
|
|
188
|
+
const framing = (containerId: string): boolean =>
|
|
189
|
+
model.view === 'component' || opened === containerId;
|
|
190
|
+
|
|
191
|
+
const framedContainers: string[] = [];
|
|
192
|
+
for (const c of containers) {
|
|
193
|
+
const members = components
|
|
194
|
+
.filter((comp) => (comp.container ?? comp.parentId) === c.id)
|
|
195
|
+
.map((comp) => comp.id);
|
|
196
|
+
if (members.length > 0 && framing(c.id)) {
|
|
197
|
+
groups.push({
|
|
198
|
+
id: c.id,
|
|
199
|
+
kind: 'container',
|
|
200
|
+
label: c.label,
|
|
201
|
+
parentId: model.system.id,
|
|
202
|
+
memberIds: members,
|
|
203
|
+
});
|
|
204
|
+
framedContainers.push(c.id);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The system frame wraps the framed containers (component view) or the whole
|
|
209
|
+
// set of containers (container view) — the boundary is the system, so it must
|
|
210
|
+
// NOT shrink to the opened container. Opening one adds a nested container
|
|
211
|
+
// frame inside it; the system frame still spans everything.
|
|
212
|
+
const systemMembers =
|
|
213
|
+
model.view === 'component' ? framedContainers : containers.map((c) => c.id);
|
|
214
|
+
if (systemMembers.length > 0) {
|
|
215
|
+
groups.push({
|
|
216
|
+
id: model.system.id,
|
|
217
|
+
kind: 'system',
|
|
218
|
+
label: model.system.label,
|
|
219
|
+
memberIds: systemMembers,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return groups;
|
|
224
|
+
}
|
|
225
|
+
|
|
165
226
|
/**
|
|
166
227
|
* A line between two elements. Authored, like the boxes.
|
|
167
228
|
*
|
|
@@ -229,13 +290,23 @@ export function protocolColor(protocol: string | undefined): string {
|
|
|
229
290
|
return best ? PROTOCOL_COLOR[best]! : PROTOCOL_COLOR_FALLBACK;
|
|
230
291
|
}
|
|
231
292
|
|
|
232
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* A drawable C4 projection. Authored: the boxes and lines, nothing drawn for
|
|
295
|
+
* you. Frames are derived by the renderer from the elements' `parentId` chains
|
|
296
|
+
* — a container is a frame when its components are being shown (component view,
|
|
297
|
+
* or opened via `openContainerId`), never a separately-authored thing.
|
|
298
|
+
*/
|
|
233
299
|
export interface C4Model {
|
|
234
300
|
view: C4View;
|
|
235
301
|
system: { id: string; label: string; repoKey?: string };
|
|
236
302
|
nodes: C4Element[];
|
|
237
|
-
groups: C4Group[];
|
|
238
303
|
edges: C4Edge[];
|
|
304
|
+
/**
|
|
305
|
+
* Drill-down scope: the id of a container whose components should be shown
|
|
306
|
+
* inside it as a frame. Absent/null → the container is drawn as a box and its
|
|
307
|
+
* components are hidden. The renderer derives the frames either way.
|
|
308
|
+
*/
|
|
309
|
+
openContainerId?: string | null;
|
|
239
310
|
}
|
|
240
311
|
|
|
241
312
|
/**
|
|
@@ -16,10 +16,126 @@ import {
|
|
|
16
16
|
fractionAlongPath,
|
|
17
17
|
planCompoundGroups,
|
|
18
18
|
getElkOptions,
|
|
19
|
+
absoluteBoundsOf,
|
|
20
|
+
flowPositionOf,
|
|
19
21
|
type Point,
|
|
20
22
|
} from './elkLayout';
|
|
21
23
|
import { EDGE_LABEL_WIDTH, EDGE_LABEL_SIDE_PADDING } from './edgeLabel';
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A synthetic laid-out result standing in for the drill-down's open layout: a
|
|
27
|
+
* compound parent at (12,12) with two children, a sibling container, and a
|
|
28
|
+
* flattened frame that never enters the ELK tree.
|
|
29
|
+
*/
|
|
30
|
+
function syntheticResult() {
|
|
31
|
+
const rect = (x: number, y: number, width: number, height: number) => ({
|
|
32
|
+
x, y, width, height,
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
absoluteRects: new Map([
|
|
36
|
+
['container:host', rect(12, 12, 508, 416)],
|
|
37
|
+
['cmp:a', rect(24, 96, 140, 140)],
|
|
38
|
+
['cmp:b', rect(24, 276, 140, 140)],
|
|
39
|
+
['container:api', rect(576, 218, 250, 150)],
|
|
40
|
+
]),
|
|
41
|
+
// A nested group's entry is parent-relative — the trap `flowPositionOf` avoids.
|
|
42
|
+
groupBounds: new Map([
|
|
43
|
+
['container:host', rect(12, 12, 508, 416)],
|
|
44
|
+
['container:api', rect(576, 218, 250, 150)],
|
|
45
|
+
['system:acme', rect(-12, -52, 1152, 504)],
|
|
46
|
+
]),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('absolute coordinate space', () => {
|
|
51
|
+
test('a root node keeps its absolute position', () => {
|
|
52
|
+
const r = syntheticResult();
|
|
53
|
+
expect(flowPositionOf(r, 'container:host')).toEqual({ x: 12, y: 12 });
|
|
54
|
+
expect(flowPositionOf(r, 'container:api')).toEqual({ x: 576, y: 218 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The bug this guards: a child's own position is already parent-relative, so
|
|
58
|
+
// subtracting the parent's absolute origin again pushed it out of its card.
|
|
59
|
+
test('a child is relative to its parent, offset exactly once', () => {
|
|
60
|
+
const r = syntheticResult();
|
|
61
|
+
expect(flowPositionOf(r, 'cmp:a', 'container:host')).toEqual({ x: 12, y: 84 });
|
|
62
|
+
expect(flowPositionOf(r, 'cmp:b', 'container:host')).toEqual({ x: 12, y: 264 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('children land inside the parent box they are nested in', () => {
|
|
66
|
+
const r = syntheticResult();
|
|
67
|
+
const box = absoluteBoundsOf(r, 'container:host')!;
|
|
68
|
+
for (const id of ['cmp:a', 'cmp:b']) {
|
|
69
|
+
const p = flowPositionOf(r, id, 'container:host')!;
|
|
70
|
+
const own = r.absoluteRects.get(id)!;
|
|
71
|
+
expect(p.x).toBeGreaterThanOrEqual(0);
|
|
72
|
+
expect(p.y).toBeGreaterThanOrEqual(0);
|
|
73
|
+
expect(p.x + own.width).toBeLessThanOrEqual(box.width);
|
|
74
|
+
expect(p.y + own.height).toBeLessThanOrEqual(box.height);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// A compound parent has no `result.nodes` entry at all, so anything reading
|
|
79
|
+
// positions from there leaves it on its authored slot.
|
|
80
|
+
test('a compound parent gets a real position, not a fallback', () => {
|
|
81
|
+
const r = syntheticResult();
|
|
82
|
+
expect(flowPositionOf(r, 'container:host')).not.toBeUndefined();
|
|
83
|
+
expect(flowPositionOf(r, 'container:host')).toEqual(
|
|
84
|
+
r.absoluteRects.get('container:host') && { x: 12, y: 12 },
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a flattened frame resolves from the synthesized bounds', () => {
|
|
89
|
+
// Not in absoluteRects — it never entered the ELK tree.
|
|
90
|
+
expect(syntheticResult().absoluteRects.has('system:acme')).toBe(false);
|
|
91
|
+
expect(absoluteBoundsOf(syntheticResult(), 'system:acme')).toEqual({
|
|
92
|
+
x: -12, y: -52, width: 1152, height: 504,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an unknown id has no position rather than a silent zero', () => {
|
|
97
|
+
expect(flowPositionOf(syntheticResult(), 'nope')).toBeUndefined();
|
|
98
|
+
expect(absoluteBoundsOf(syntheticResult(), 'nope')).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an unknown parent falls back to absolute, not to a wrong origin', () => {
|
|
102
|
+
expect(flowPositionOf(syntheticResult(), 'cmp:a', 'nope')).toEqual({ x: 24, y: 96 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A card's FLIP offset is the difference between where it was and where the new
|
|
108
|
+
* layout puts it. If this is computed in the wrong space the card slides to a
|
|
109
|
+
* plausible-looking wrong place, which is what "the anchors shift" looks like.
|
|
110
|
+
*/
|
|
111
|
+
describe('morph offsets', () => {
|
|
112
|
+
interface Rect { x: number; y: number; width: number; height: number }
|
|
113
|
+
const offsetTo = (from: Rect, to: Rect) => ({
|
|
114
|
+
x: from.x - to.x,
|
|
115
|
+
y: from.y - to.y,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a card that moves is offset by exactly the move', () => {
|
|
119
|
+
// Closed: (330,182) 250x150. Open: the compound parent ELK fitted at (12,12).
|
|
120
|
+
const closed = { x: 330, y: 182, width: 250, height: 150 };
|
|
121
|
+
const open = { x: 12, y: 12, width: 508, height: 416 };
|
|
122
|
+
expect(offsetTo(closed, open)).toEqual({ x: 318, y: 170 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a card that does not move gets no offset', () => {
|
|
126
|
+
const same = { x: 576, y: 218, width: 250, height: 150 };
|
|
127
|
+
expect(offsetTo(same, same)).toEqual({ x: 0, y: 0 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('an offset ignores size, because the card transitions its own size', () => {
|
|
131
|
+
// Growing in place must not shift the anchor: the card's width/height
|
|
132
|
+
// transition handles the size, a translate must stay 0.
|
|
133
|
+
const before = { x: 12, y: 12, width: 250, height: 150 };
|
|
134
|
+
const after = { x: 12, y: 12, width: 508, height: 416 };
|
|
135
|
+
expect(offsetTo(before, after)).toEqual({ x: 0, y: 0 });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
23
139
|
describe('planCompoundGroups', () => {
|
|
24
140
|
test('drops a single-leaf group by default and promotes its member', () => {
|
|
25
141
|
const plan = planCompoundGroups(
|
|
@@ -130,6 +246,21 @@ describe('planCompoundGroups', () => {
|
|
|
130
246
|
);
|
|
131
247
|
expect(plan.built[0]!.minWidth).toBe(220);
|
|
132
248
|
});
|
|
249
|
+
|
|
250
|
+
// `padTop` only reaches ELK if the plan carries it; when it was dropped here
|
|
251
|
+
// the parent's own header was silently overlaid by its first child.
|
|
252
|
+
test('carries padTop through to the plan', () => {
|
|
253
|
+
const plan = planCompoundGroups(
|
|
254
|
+
[{ id: 'mod', memberIds: ['a', 'b'], padTop: 84 }],
|
|
255
|
+
['a', 'b'],
|
|
256
|
+
);
|
|
257
|
+
expect(plan.built[0]!.padTop).toBe(84);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('leaves padTop unset when the group does not reserve chrome', () => {
|
|
261
|
+
const plan = planCompoundGroups([{ id: 'mod', memberIds: ['a', 'b'] }], ['a', 'b']);
|
|
262
|
+
expect(plan.built[0]!.padTop).toBeUndefined();
|
|
263
|
+
});
|
|
133
264
|
});
|
|
134
265
|
|
|
135
266
|
describe('elkLayout helper functions', () => {
|