@principal-ai/subsystems-react 0.53.1 → 0.53.3
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 +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/C4NodeCard.d.ts +45 -10
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +75 -21
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.js +2 -2
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +24 -7
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +13 -2
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +14 -6
- package/dist/subsystem/nodes.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/index.ts +1 -1
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +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/C4NodeCard.test.ts +47 -8
- package/src/subsystem/C4NodeCard.tsx +84 -20
- package/src/subsystem/IssueList.tsx +2 -2
- package/src/subsystem/SubsystemComponentGraph.tsx +25 -7
- package/src/subsystem/model.ts +13 -2
- package/src/subsystem/nodes.tsx +14 -6
- package/src/utils/elkLayout.test.ts +131 -0
- package/src/utils/elkLayout.ts +81 -14
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
NODE_H,
|
|
12
12
|
nodeSize,
|
|
13
13
|
COMPONENT_SIZE,
|
|
14
|
+
COMPONENT_W,
|
|
15
|
+
COMPONENT_ASPECT,
|
|
14
16
|
} from './C4NodeCard';
|
|
15
17
|
import type { C4Element, C4Container } from './c4';
|
|
16
18
|
|
|
@@ -190,6 +192,19 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
190
192
|
expect(s.radius).toBeGreaterThanOrEqual(NODE_H / 2);
|
|
191
193
|
});
|
|
192
194
|
|
|
195
|
+
test('a component centres its text, a container does not', () => {
|
|
196
|
+
// Alignment is the cheapest level cue there is: it needs no comparison
|
|
197
|
+
// against anything else, so it survives a crop or a narrow viewport where
|
|
198
|
+
// the aspect ratio stops being readable.
|
|
199
|
+
expect(nodeShape(node({ kind: 'component' })).align).toBe('center');
|
|
200
|
+
expect(nodeShape(node()).align).toBe('left');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('a person centres its text too, but for its own reason', () => {
|
|
204
|
+
// Not a level cue — a pill has no straight edge for text to sit against.
|
|
205
|
+
expect(nodeShape(node({ kind: 'person' })).align).toBe('center');
|
|
206
|
+
});
|
|
207
|
+
|
|
193
208
|
test('shape does not depend on confirmation state', () => {
|
|
194
209
|
expect(nodeShape(node({ state: 'proposed' })).kind).toBe(
|
|
195
210
|
nodeShape(node({ state: 'accepted' })).kind,
|
|
@@ -250,10 +265,19 @@ describe('nodeMissing', () => {
|
|
|
250
265
|
});
|
|
251
266
|
});
|
|
252
267
|
|
|
253
|
-
describe('nodeSize — a component
|
|
254
|
-
test('a component
|
|
268
|
+
describe('nodeSize — a component reads as a square', () => {
|
|
269
|
+
test('a component is wider than it is tall, by the optical correction', () => {
|
|
270
|
+
// Equal sides get perceived as tall, so the box is built 8:7 to land on the
|
|
271
|
+
// square we actually want. Pinned both ways: the ratio the layout gets, and
|
|
272
|
+
// a ceiling on how far the correction may go — the upper bound is there to
|
|
273
|
+
// stop the box drifting toward the container's 5:3, which would cost the
|
|
274
|
+
// level signal this aspect exists to carry.
|
|
255
275
|
const s = nodeSize(node({ kind: 'component' }));
|
|
256
|
-
expect(s.width).
|
|
276
|
+
expect(s.width).toBeGreaterThan(s.height);
|
|
277
|
+
expect(s.width / s.height).toBeCloseTo(COMPONENT_ASPECT, 2);
|
|
278
|
+
expect(COMPONENT_ASPECT).toBeGreaterThan(1);
|
|
279
|
+
expect(COMPONENT_ASPECT).toBeLessThanOrEqual(1.25);
|
|
280
|
+
expect(COMPONENT_ASPECT).toBeLessThan(NODE_W / NODE_H);
|
|
257
281
|
});
|
|
258
282
|
|
|
259
283
|
test('a container is wider than it is tall', () => {
|
|
@@ -268,9 +292,19 @@ describe('nodeSize — a component is a proper square', () => {
|
|
|
268
292
|
expect(c.height).toBeLessThan(k.height);
|
|
269
293
|
});
|
|
270
294
|
|
|
271
|
-
test('the
|
|
295
|
+
test('the two levels are not the same shape', () => {
|
|
296
|
+
// The reason a separate constant exists at all: a component is the
|
|
297
|
+
// square-ish member of the set, not a miniature container.
|
|
298
|
+
const c = nodeSize(node({ kind: 'component' }));
|
|
299
|
+
const k = nodeSize(node());
|
|
300
|
+
expect(c.width / c.height).toBeLessThan(1.2);
|
|
301
|
+
expect(k.width / k.height).toBeGreaterThan(1.5);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('the box still fits its three lines of text', () => {
|
|
272
305
|
// tag + 2-line label slot + subtitle + 10px padding x2. Without this the
|
|
273
|
-
//
|
|
306
|
+
// component clips its own subtitle. The optical correction is applied to
|
|
307
|
+
// the width only, so this still holds at 140 tall.
|
|
274
308
|
const s = nodeSize(node({ kind: 'component' }));
|
|
275
309
|
const content = 16 + 40 + 16 + 20;
|
|
276
310
|
expect(s.height).toBeGreaterThanOrEqual(content);
|
|
@@ -289,9 +323,14 @@ describe('card metrics', () => {
|
|
|
289
323
|
expect(NODE_H).toBe(150);
|
|
290
324
|
});
|
|
291
325
|
|
|
292
|
-
test('COMPONENT_SIZE is
|
|
293
|
-
|
|
294
|
-
|
|
326
|
+
test('COMPONENT_SIZE is the short side, not the width', () => {
|
|
327
|
+
// Kept for callers that size off a single number. It is the height; the
|
|
328
|
+
// width the graph lays out against is COMPONENT_W, and collapsing the two
|
|
329
|
+
// back into one number would delete the optical correction.
|
|
330
|
+
const s = nodeSize(node({ kind: 'component' }));
|
|
331
|
+
expect(COMPONENT_SIZE).toBe(s.height);
|
|
332
|
+
expect(COMPONENT_SIZE).not.toBe(s.width);
|
|
333
|
+
expect(COMPONENT_W).toBe(s.width);
|
|
295
334
|
});
|
|
296
335
|
|
|
297
336
|
test('is near-square, not a wide strip', () => {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* wants it: technology on the top row, description under the label.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
15
16
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
17
|
import { AppWindow, Component, Database, Maximize2 } from 'lucide-react';
|
|
17
18
|
import type { LucideIcon } from 'lucide-react';
|
|
@@ -27,20 +28,45 @@ export const NODE_W = 250;
|
|
|
27
28
|
export const NODE_H = 150;
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* A component
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* A component has to *read* as a square, and equal sides do not manage that.
|
|
32
|
+
*
|
|
33
|
+
* A literally square box is perceived as tall: the eye measures it against the
|
|
34
|
+
* upright frame and the surrounding boxes, not against an abstract 1:1, so it
|
|
35
|
+
* lands looking narrower than it is. The correction is to make the real thing
|
|
36
|
+
* wider than it is tall, which is what this is for.
|
|
37
|
+
*
|
|
38
|
+
* 8:7 is further than the textbook 5%, and deliberately: the card's height is a
|
|
39
|
+
* `minHeight`, so a long description grows the drawn box past the height
|
|
40
|
+
* reserved here (see the probe below). 160 buys the square reading against a
|
|
41
|
+
* *rendered* height that runs past 140, not just against the nominal one.
|
|
42
|
+
*
|
|
43
|
+
* The level signal survives it: 8:7 is still unmistakably the square member of
|
|
44
|
+
* the set against the container's 5:3, and still beats a border-weight
|
|
45
|
+
* difference alone under grayscale and colour-blind reading.
|
|
46
|
+
*/
|
|
47
|
+
export const COMPONENT_ASPECT = 8 / 7;
|
|
48
|
+
|
|
49
|
+
/** Short side of a component card — the height, and a hard ceiling on the drawn box. */
|
|
50
|
+
export const COMPONENT_H = 140;
|
|
51
|
+
|
|
52
|
+
/** Long side — the optically corrected one. 160, derived so the ratio is the constant. */
|
|
53
|
+
export const COMPONENT_W = Math.round(COMPONENT_H * COMPONENT_ASPECT);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Nominal edge of a component card, for callers that sized a box off a single
|
|
57
|
+
* number. It is the *short* side; the width the graph lays out against is
|
|
58
|
+
* `COMPONENT_W`, not this.
|
|
34
59
|
*/
|
|
35
|
-
export const COMPONENT_SIZE =
|
|
60
|
+
export const COMPONENT_SIZE = COMPONENT_H;
|
|
36
61
|
|
|
37
62
|
/**
|
|
38
|
-
* Per-level box size. The graph lays out against these, so a square
|
|
39
|
-
* and a rectangular container do not collide at a shared nominal
|
|
63
|
+
* Per-level box size. The graph lays out against these, so a near-square
|
|
64
|
+
* component and a rectangular container do not collide at a shared nominal
|
|
65
|
+
* width.
|
|
40
66
|
*/
|
|
41
67
|
export function nodeSize(node: C4Element): { width: number; height: number } {
|
|
42
68
|
return node.kind === 'component'
|
|
43
|
-
? { width:
|
|
69
|
+
? { width: COMPONENT_W, height: COMPONENT_H }
|
|
44
70
|
: { width: NODE_W, height: NODE_H };
|
|
45
71
|
}
|
|
46
72
|
|
|
@@ -186,6 +212,13 @@ export interface NodeShape {
|
|
|
186
212
|
kind: C4ShapeKind;
|
|
187
213
|
/** Corner radius, for the shapes that use one. */
|
|
188
214
|
radius: number;
|
|
215
|
+
/**
|
|
216
|
+
* Horizontal text alignment. This is a level cue, not a typographic
|
|
217
|
+
* preference: a column of left-aligned containers beside a set of centred
|
|
218
|
+
* ones reads as two kinds without comparing box sizes, and unlike the aspect
|
|
219
|
+
* ratio it survives a crop or a narrow viewport.
|
|
220
|
+
*/
|
|
221
|
+
align: 'left' | 'center';
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
/**
|
|
@@ -195,24 +228,30 @@ export interface NodeShape {
|
|
|
195
228
|
* The convention:
|
|
196
229
|
*
|
|
197
230
|
* container wide rectangle, slight radius — a runtime boundary
|
|
198
|
-
* component square — a part inside one
|
|
199
|
-
* external system wide rectangle, no radius
|
|
200
|
-
* person pill
|
|
231
|
+
* component square, text centred — a part inside one
|
|
232
|
+
* external system wide rectangle, no radius — someone else's system
|
|
233
|
+
* person pill, text centred — not a box at all
|
|
234
|
+
*
|
|
235
|
+
* `square` here is a corner treatment, not a claim about the dimensions: a
|
|
236
|
+
* component's box is optically 8:7 (see `COMPONENT_ASPECT`) and still reads as
|
|
237
|
+
* this shape.
|
|
201
238
|
*
|
|
202
239
|
* A container keeps a slight radius; an external system is drawn with none, so
|
|
203
240
|
* the two are distinguishable by corner treatment as well as by the kind icon
|
|
204
|
-
* and frame position.
|
|
241
|
+
* and frame position. Alignment is the third, cheapest cue — it needs no
|
|
242
|
+
* comparison against anything. The border dash stays spent on
|
|
243
|
+
* proposed-vs-accepted.
|
|
205
244
|
*/
|
|
206
245
|
export function nodeShape(node: C4Element): NodeShape {
|
|
207
246
|
switch (node.kind) {
|
|
208
247
|
case 'person':
|
|
209
|
-
return { kind: 'pill', radius: NODE_H / 2 };
|
|
248
|
+
return { kind: 'pill', radius: NODE_H / 2, align: 'center' };
|
|
210
249
|
case 'external-system':
|
|
211
|
-
return { kind: 'rect', radius: 0 };
|
|
250
|
+
return { kind: 'rect', radius: 0, align: 'left' };
|
|
212
251
|
case 'component':
|
|
213
|
-
return { kind: 'square', radius: 6 };
|
|
252
|
+
return { kind: 'square', radius: 6, align: 'center' };
|
|
214
253
|
case 'container':
|
|
215
|
-
return { kind: 'rect', radius: 4 };
|
|
254
|
+
return { kind: 'rect', radius: 4, align: 'left' };
|
|
216
255
|
}
|
|
217
256
|
}
|
|
218
257
|
|
|
@@ -280,12 +319,37 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
280
319
|
const subtitle = nodeSubtitle(node);
|
|
281
320
|
|
|
282
321
|
const size = nodeSize(node);
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
|
|
322
|
+
const centered = shape.align === 'center';
|
|
323
|
+
// The pill's inset is a separate concern from alignment, and a centred
|
|
324
|
+
// component must not inherit it: 30px of each side would leave a 160-wide box
|
|
325
|
+
// 100px of text, which wraps the description into exactly the extra lines
|
|
326
|
+
// that grow the box past the height the layout reserved.
|
|
327
|
+
const pillInset = shape.kind === 'pill';
|
|
328
|
+
|
|
329
|
+
// TEMPORARY diagnostic — remove. The card's height is `minHeight`, so the box
|
|
330
|
+
// the browser lays out can exceed the nominal size the layout reserved, and
|
|
331
|
+
// nothing in the type system shows that: `nodeSize` and the drawn box are two
|
|
332
|
+
// different numbers. `offsetWidth/Height` are the border box, so this reports
|
|
333
|
+
// what was actually drawn. Logs on change only, so a reflow that does not
|
|
334
|
+
// resize the card stays quiet.
|
|
335
|
+
const probeRef = useRef<HTMLDivElement | null>(null);
|
|
336
|
+
const probeSeen = useRef('');
|
|
337
|
+
useLayoutEffect(() => {
|
|
338
|
+
const el = probeRef.current;
|
|
339
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
340
|
+
const ro = new ResizeObserver(() => {
|
|
341
|
+
const line = `${el.offsetWidth}x${el.offsetHeight} (nominal ${size.width}x${size.height})`;
|
|
342
|
+
if (line === probeSeen.current) return;
|
|
343
|
+
probeSeen.current = line;
|
|
344
|
+
console.log(`[c4-card] ${node.kind} ${node.id} ${line}`);
|
|
345
|
+
});
|
|
346
|
+
ro.observe(el);
|
|
347
|
+
return () => ro.disconnect();
|
|
348
|
+
}, [node.kind, node.id, size.width, size.height]);
|
|
286
349
|
|
|
287
350
|
return (
|
|
288
351
|
<div
|
|
352
|
+
ref={probeRef}
|
|
289
353
|
onClick={onClick}
|
|
290
354
|
style={{
|
|
291
355
|
position: 'relative',
|
|
@@ -300,7 +364,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
300
364
|
// height on every card. In a graph grid that makes the row unscannable.
|
|
301
365
|
justifyContent: 'flex-start',
|
|
302
366
|
gap: 3,
|
|
303
|
-
padding:
|
|
367
|
+
padding: pillInset ? '10px 30px' : '10px 12px',
|
|
304
368
|
borderRadius: shape.radius,
|
|
305
369
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
306
370
|
border: `${style.width}px ${style.dash} ${style.color}`,
|
|
@@ -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
|
|
|
@@ -1140,15 +1140,18 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1140
1140
|
[trails],
|
|
1141
1141
|
);
|
|
1142
1142
|
|
|
1143
|
-
// Per-node diagnostics badge: fold each component-targeted finding
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1143
|
+
// Per-node diagnostics badge: fold each component-targeted finding into one
|
|
1144
|
+
// badge per node — worst severity wins, the chip shows the earliest failing
|
|
1145
|
+
// rung, and the count tallies the findings. A finding with no construct rung
|
|
1146
|
+
// still badges the node when it carries a dedicated kind icon (e.g. a C4
|
|
1147
|
+
// process gap): the chip wears the kind's icon instead of a rung's, so the
|
|
1148
|
+
// node says "diagnostics here" for every finding the sidebar lists.
|
|
1146
1149
|
const issueBadgeByAlias = useMemo(() => {
|
|
1147
1150
|
const map = new Map<string, SubsystemNodeIssue>();
|
|
1148
1151
|
if (!issues?.length) return map;
|
|
1149
1152
|
for (const issue of issues) {
|
|
1150
1153
|
const rung = issueRung(issue.kind);
|
|
1151
|
-
if (!rung) continue;
|
|
1154
|
+
if (!rung && !ISSUE_KIND_ICON[issue.kind]) continue;
|
|
1152
1155
|
const comp = issueComponent(issue);
|
|
1153
1156
|
if (!comp) continue;
|
|
1154
1157
|
const prev = map.get(comp.alias);
|
|
@@ -1158,12 +1161,27 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1158
1161
|
? 'error'
|
|
1159
1162
|
: 'info',
|
|
1160
1163
|
rung:
|
|
1161
|
-
|
|
1162
|
-
? prev
|
|
1163
|
-
: rung
|
|
1164
|
+
rung == null
|
|
1165
|
+
? prev?.rung
|
|
1166
|
+
: prev?.rung != null &&
|
|
1167
|
+
ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
|
|
1168
|
+
? prev.rung
|
|
1169
|
+
: rung,
|
|
1164
1170
|
count: (prev?.count ?? 0) + 1,
|
|
1165
1171
|
});
|
|
1166
1172
|
}
|
|
1173
|
+
// A rung finding wins the chip; only rung-less nodes wear a kind icon —
|
|
1174
|
+
// the first kind-iconed finding the sidebar lists for that node.
|
|
1175
|
+
for (const [alias, badge] of map) {
|
|
1176
|
+
if (badge.rung != null) continue;
|
|
1177
|
+
const kinded = issues.find(
|
|
1178
|
+
(i) =>
|
|
1179
|
+
!issueRung(i.kind) &&
|
|
1180
|
+
ISSUE_KIND_ICON[i.kind] &&
|
|
1181
|
+
issueComponent(i)?.alias === alias,
|
|
1182
|
+
);
|
|
1183
|
+
if (kinded) map.set(alias, { ...badge, kind: kinded.kind });
|
|
1184
|
+
}
|
|
1167
1185
|
return map;
|
|
1168
1186
|
}, [issues, issueComponent]);
|
|
1169
1187
|
|
package/src/subsystem/model.ts
CHANGED
|
@@ -1135,8 +1135,19 @@ export type SubsystemIssueRung =
|
|
|
1135
1135
|
export interface SubsystemNodeIssue {
|
|
1136
1136
|
/** Worst severity on the node — `error` (contradicts source) beats `info`. */
|
|
1137
1137
|
severity: 'error' | 'info';
|
|
1138
|
-
/**
|
|
1139
|
-
|
|
1138
|
+
/**
|
|
1139
|
+
* Earliest failing rung across the node's findings. Absent when none of the
|
|
1140
|
+
* node's findings sit on the construct ladder — a rung-less finding (e.g. a
|
|
1141
|
+
* process gap) carries its audit `kind` instead.
|
|
1142
|
+
*/
|
|
1143
|
+
rung?: SubsystemIssueRung;
|
|
1144
|
+
/**
|
|
1145
|
+
* The audit kind of the node's finding when that finding has no construct
|
|
1146
|
+
* rung: the chip wears the kind's icon (`ISSUE_KIND_ICON`) rather than a
|
|
1147
|
+
* rung's. Set only when `rung` is absent — a rung finding is more specific
|
|
1148
|
+
* about where the construct fails, so it wins the chip.
|
|
1149
|
+
*/
|
|
1150
|
+
kind?: string;
|
|
1140
1151
|
/** Total findings on this node. */
|
|
1141
1152
|
count: number;
|
|
1142
1153
|
}
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { componentColor } from '../pierre/constructColors';
|
|
47
47
|
import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
|
|
48
48
|
import { ISSUE_KIND_ICON, ISSUE_RUNG_ICON } from './IssueList';
|
|
49
|
+
import { Search } from 'lucide-react';
|
|
49
50
|
import type { LucideIcon } from 'lucide-react';
|
|
50
51
|
|
|
51
52
|
export const CONSTRUCT_LABEL: Record<string, string> = {
|
|
@@ -122,11 +123,13 @@ function useSubsystemCallbacks(): SubsystemGraphCallbacks {
|
|
|
122
123
|
|
|
123
124
|
/**
|
|
124
125
|
* The diagnostics overlay for a component node: the node's own border turns
|
|
125
|
-
* dotted (see the node's `borderStyle`) and
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
126
|
+
* dotted (see the node's `borderStyle`) and a severity-colored corner chip
|
|
127
|
+
* carries the count. The chip's icon is the earliest-failing construct rung
|
|
128
|
+
* (file → symbol → declaration → type → signature); a finding with no rung
|
|
129
|
+
* (e.g. a C4 process gap) wears its kind icon instead — see
|
|
130
|
+
* `SubsystemNodeIssue.kind`. The chip is purely additive and non-interactive;
|
|
131
|
+
* the border stays construct-colored, so severity never has to compete with
|
|
132
|
+
* the construct palette.
|
|
130
133
|
*/
|
|
131
134
|
function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
|
|
132
135
|
const { theme } = useTheme();
|
|
@@ -134,10 +137,15 @@ function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
|
|
|
134
137
|
const color = isError
|
|
135
138
|
? (theme.colors.error ?? '#e5534b')
|
|
136
139
|
: (theme.colors.warning ?? '#d4a017');
|
|
140
|
+
const Icon = issue.rung
|
|
141
|
+
? ISSUE_RUNG_ICON[issue.rung]
|
|
142
|
+
: issue.kind
|
|
143
|
+
? (ISSUE_KIND_ICON[issue.kind] ?? Search)
|
|
144
|
+
: Search;
|
|
137
145
|
return (
|
|
138
146
|
<IssueChip
|
|
139
147
|
color={color}
|
|
140
|
-
Icon={
|
|
148
|
+
Icon={Icon}
|
|
141
149
|
count={issue.count}
|
|
142
150
|
anchor={{ right: -9, bottom: -8 }}
|
|
143
151
|
/>
|
|
@@ -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', () => {
|