@principal-ai/subsystems-react 0.53.1 → 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 +83 -0
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +314 -105
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/IssueList.js +2 -2
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +49 -13
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +57 -8
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +2 -1
- package/src/subsystem/C4Graph.groups.test.ts +217 -0
- package/src/subsystem/C4Graph.morph.test.tsx +208 -0
- package/src/subsystem/C4Graph.tsx +344 -111
- package/src/subsystem/IssueList.tsx +2 -2
- package/src/utils/elkLayout.test.ts +131 -0
- package/src/utils/elkLayout.ts +81 -14
|
@@ -14,6 +14,7 @@
|
|
|
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,
|
|
@@ -32,7 +33,6 @@ import {
|
|
|
32
33
|
Network,
|
|
33
34
|
Route,
|
|
34
35
|
Search,
|
|
35
|
-
Server,
|
|
36
36
|
Shapes,
|
|
37
37
|
Sigma,
|
|
38
38
|
Split,
|
|
@@ -82,7 +82,7 @@ export const SUBSYSTEM_ISSUE_CATEGORY_ICON: Record<
|
|
|
82
82
|
repo: FolderGit2,
|
|
83
83
|
construct: Component,
|
|
84
84
|
'static-topology': Network,
|
|
85
|
-
'dynamic-topology':
|
|
85
|
+
'dynamic-topology': AppWindow,
|
|
86
86
|
trail: Route,
|
|
87
87
|
};
|
|
88
88
|
|
|
@@ -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', () => {
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -126,7 +126,7 @@ export interface ElkLayoutOptions {
|
|
|
126
126
|
* nests this group under another group; omit for a root-level frame.
|
|
127
127
|
* Members reference their immediate parent via React Flow `parentId`.
|
|
128
128
|
*/
|
|
129
|
-
groups?:
|
|
129
|
+
groups?: CompoundGroupDef[];
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* Keep groups that hold a single leaf instead of dropping them and
|
|
@@ -195,6 +195,46 @@ export interface ElkLayoutResult {
|
|
|
195
195
|
absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* A node's rect in absolute (root) coordinates — the space `edgePathPoints` and
|
|
200
|
+
* the synthesized frame bounds are in.
|
|
201
|
+
*
|
|
202
|
+
* `absoluteRects` is the only map that is absolute for *every* node ELK placed.
|
|
203
|
+
* The alternatives each have a hole: `result.nodes` is parent-relative for a real
|
|
204
|
+
* group's children and has **no entry at all** for the group itself, and
|
|
205
|
+
* `groupBounds` is parent-relative for a nested group (and is the only source for
|
|
206
|
+
* a flattened group, which never enters the ELK tree).
|
|
207
|
+
*/
|
|
208
|
+
export function absoluteBoundsOf(
|
|
209
|
+
result: Pick<ElkLayoutResult, 'absoluteRects' | 'groupBounds'>,
|
|
210
|
+
id: string,
|
|
211
|
+
): { x: number; y: number; width: number; height: number } | undefined {
|
|
212
|
+
return result.absoluteRects.get(id) ?? result.groupBounds.get(id);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Where a node belongs in React Flow coordinates: a root node keeps its absolute
|
|
217
|
+
* position, a child is expressed relative to its parent.
|
|
218
|
+
*
|
|
219
|
+
* Convert in exactly one place, from the absolute space, subtracting the parent's
|
|
220
|
+
* absolute origin **once**. The two failure modes here are silent — a child
|
|
221
|
+
* double-offset by its parent's origin lands outside its own card, and a compound
|
|
222
|
+
* parent with no `result.nodes` entry silently keeps the authored position it was
|
|
223
|
+
* built with, so the container drifts away from its siblings, its frame and its
|
|
224
|
+
* edges while everything around it uses ELK's answer.
|
|
225
|
+
*/
|
|
226
|
+
export function flowPositionOf(
|
|
227
|
+
result: Pick<ElkLayoutResult, 'absoluteRects' | 'groupBounds'>,
|
|
228
|
+
id: string,
|
|
229
|
+
parentId?: string,
|
|
230
|
+
): { x: number; y: number } | undefined {
|
|
231
|
+
const abs = result.absoluteRects.get(id);
|
|
232
|
+
if (!abs) return undefined;
|
|
233
|
+
if (!parentId) return { x: abs.x, y: abs.y };
|
|
234
|
+
const origin = absoluteBoundsOf(result, parentId);
|
|
235
|
+
return origin ? { x: abs.x - origin.x, y: abs.y - origin.y } : { x: abs.x, y: abs.y };
|
|
236
|
+
}
|
|
237
|
+
|
|
198
238
|
/** Point in 2D space */
|
|
199
239
|
export interface Point {
|
|
200
240
|
x: number;
|
|
@@ -526,20 +566,31 @@ export function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
|
|
|
526
566
|
export interface CompoundGroupDef {
|
|
527
567
|
id: string;
|
|
528
568
|
memberIds: string[];
|
|
569
|
+
/** Nested inside another group. Omitted for a root-level group. */
|
|
570
|
+
parentId?: string;
|
|
529
571
|
minWidth?: number;
|
|
530
572
|
/**
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* the
|
|
573
|
+
* This id is *also* a drawn node — a card that hosts a group. Set it when the
|
|
574
|
+
* element is drawn as a card but must be laid out as a parent: the drill-down's
|
|
575
|
+
* opened container.
|
|
576
|
+
*
|
|
577
|
+
* The node and the group are not alternatives. The node is how the group is
|
|
578
|
+
* *drawn*, and the group is how it is *laid out* — so this says which one wins
|
|
579
|
+
* when a member id could be either. Without it the frame takes the bare leaf box
|
|
580
|
+
* and the group is built but never referenced, leaving its components laid out
|
|
581
|
+
* nowhere: the container reads as closed, at its 250x150 card size, whatever
|
|
582
|
+
* ELK was told about its children.
|
|
536
583
|
*/
|
|
537
|
-
|
|
584
|
+
drawnAsCard?: boolean;
|
|
538
585
|
/**
|
|
539
586
|
* Space reserved above the first child, in px. Set it when the parent draws
|
|
540
587
|
* its own chrome at the top (C4's grown container draws a label slot and a
|
|
541
588
|
* technology row) so ELK still owns the fit — it pads the parent by this and
|
|
542
589
|
* the children land below the chrome rather than under it.
|
|
590
|
+
*
|
|
591
|
+
* Note: there is deliberately no per-group partition. `partitionByNode` bands
|
|
592
|
+
* leaves, and ELK derives a compound node's band from its children, so a
|
|
593
|
+
* partition set on the parent itself is ignored.
|
|
543
594
|
*/
|
|
544
595
|
padTop?: number;
|
|
545
596
|
}
|
|
@@ -550,7 +601,7 @@ export interface CompoundGroupPlan {
|
|
|
550
601
|
* Built groups in build order — a group's children always appear before it,
|
|
551
602
|
* so callers can map ids to shells in one forward pass.
|
|
552
603
|
*/
|
|
553
|
-
built: Array<{ id: string; childIds: string[]; minWidth?: number; padTop?: number
|
|
604
|
+
built: Array<{ id: string; childIds: string[]; minWidth?: number; padTop?: number }>;
|
|
554
605
|
/**
|
|
555
606
|
* Dropped groups. Their members are promoted into the nearest built
|
|
556
607
|
* ancestor, so callers must clear those members' `parentId`.
|
|
@@ -645,7 +696,14 @@ export function planCompoundGroups(
|
|
|
645
696
|
continue;
|
|
646
697
|
}
|
|
647
698
|
|
|
648
|
-
built.push({
|
|
699
|
+
built.push({
|
|
700
|
+
id: g.id,
|
|
701
|
+
childIds,
|
|
702
|
+
minWidth: g.minWidth,
|
|
703
|
+
// Carried through, not just validated here: the caller turns each of
|
|
704
|
+
// these into ELK layout options on the parent node.
|
|
705
|
+
padTop: g.padTop,
|
|
706
|
+
});
|
|
649
707
|
}
|
|
650
708
|
if (!progress) {
|
|
651
709
|
// Cycle or unresolved refs — leave the rest unbuilt.
|
|
@@ -882,6 +940,9 @@ export async function computeElkLayout(
|
|
|
882
940
|
const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
|
|
883
941
|
const skippedGroups = new Set(plan.skipped);
|
|
884
942
|
const builtGroups = new Map<string, ElkNode>();
|
|
943
|
+
// Ids that are drawn as a card *and* laid out as a group. Such an id is in
|
|
944
|
+
// `elkById` too, so this is what says which of the two an enclosing frame takes.
|
|
945
|
+
const drawnAsCardIds = new Set(groupDefs.filter((g) => g.drawnAsCard).map((g) => g.id));
|
|
885
946
|
// Flat groups are not ELK parents at all: their members lay out at the root,
|
|
886
947
|
// and the frame is synthesized from the members' bounds after layout. This
|
|
887
948
|
// keeps every edge on one spacing instead of inheriting a nested frame's.
|
|
@@ -909,11 +970,18 @@ export async function computeElkLayout(
|
|
|
909
970
|
if (flatGroupIds.has(g.id)) continue;
|
|
910
971
|
const children: ElkNode[] = [];
|
|
911
972
|
for (const cid of g.childIds) {
|
|
912
|
-
|
|
913
|
-
|
|
973
|
+
// A member id can be a drawn node *and* a built group. Preferring the leaf —
|
|
974
|
+
// which is what this did unconditionally — silently drops the group: the
|
|
975
|
+
// frame ends up holding a bare 250x150 box, nothing references the group, and
|
|
976
|
+
// its components are laid out nowhere. So the card ids take the group, and
|
|
977
|
+
// everything else keeps the leaf. (For those, `builtGroups` has no entry
|
|
978
|
+
// anyway: flat groups are skipped above, and a nested group has no node.)
|
|
979
|
+
const nested = builtGroups.get(cid);
|
|
980
|
+
if (nested && drawnAsCardIds.has(cid)) children.push(nested);
|
|
914
981
|
else {
|
|
915
|
-
const
|
|
916
|
-
if (
|
|
982
|
+
const leaf = elkById.get(cid);
|
|
983
|
+
if (leaf) children.push(leaf);
|
|
984
|
+
else if (nested) children.push(nested);
|
|
917
985
|
}
|
|
918
986
|
}
|
|
919
987
|
const sizeOptions: Record<string, string> = {};
|
|
@@ -928,7 +996,6 @@ export async function computeElkLayout(
|
|
|
928
996
|
// the parent's height still comes from ELK's fit.
|
|
929
997
|
sizeOptions['elk.padding'] = `[top=${g.padTop},left=12,bottom=12,right=12]`;
|
|
930
998
|
}
|
|
931
|
-
if (g.partition != null) sizeOptions['partition'] = String(g.partition);
|
|
932
999
|
builtGroups.set(g.id, {
|
|
933
1000
|
id: g.id,
|
|
934
1001
|
children,
|