@principal-ai/subsystems-react 0.51.1 → 0.51.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 +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +12 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +82 -14
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +29 -14
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +71 -57
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +11 -2
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +66 -3
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +8 -4
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/c4.d.ts +31 -92
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +6 -285
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.d.ts +1 -1
- package/dist/subsystem/c4Evidence.js +2 -2
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/model.d.ts +8 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/techIcons/bun.d.ts +11 -0
- package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
- package/dist/subsystem/techIcons/bun.js +11 -0
- package/dist/subsystem/techIcons/bun.js.map +1 -0
- package/dist/subsystem/techIcons/index.d.ts +36 -0
- package/dist/subsystem/techIcons/index.d.ts.map +1 -0
- package/dist/subsystem/techIcons/index.js +55 -0
- package/dist/subsystem/techIcons/index.js.map +1 -0
- package/dist/subsystem/techIcons/node.d.ts +12 -0
- package/dist/subsystem/techIcons/node.d.ts.map +1 -0
- package/dist/subsystem/techIcons/node.js +39 -0
- package/dist/subsystem/techIcons/node.js.map +1 -0
- package/dist/subsystem/techIcons/react.d.ts +12 -0
- package/dist/subsystem/techIcons/react.d.ts.map +1 -0
- package/dist/subsystem/techIcons/react.js +19 -0
- package/dist/subsystem/techIcons/react.js.map +1 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +16 -5
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +4 -6
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +21 -8
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +168 -2
- package/src/subsystem/C4Graph.tsx +127 -12
- package/src/subsystem/C4NodeCard.test.ts +86 -55
- package/src/subsystem/C4NodeCard.tsx +92 -71
- package/src/subsystem/IssueList.tsx +12 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +79 -2
- package/src/subsystem/TrailsPanel.tsx +10 -5
- package/src/subsystem/c4.ts +30 -363
- package/src/subsystem/c4Evidence.test.ts +5 -5
- package/src/subsystem/c4Evidence.ts +2 -2
- package/src/subsystem/model.ts +8 -0
- package/src/subsystem/techIcons/bun.ts +10 -0
- package/src/subsystem/techIcons/index.tsx +73 -0
- package/src/subsystem/techIcons/node.ts +38 -0
- package/src/subsystem/techIcons/react.ts +18 -0
- package/src/subsystem/techIcons/techIcons.test.ts +39 -0
- package/src/utils/elkLayout.ts +16 -5
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
- package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
- package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
- package/src/subsystem/c4.test.ts +0 -573
|
@@ -61,8 +61,34 @@ const ISSUES: SubsystemIssue[] = [
|
|
|
61
61
|
message: 'Step 2 references a line that moved (was :42).',
|
|
62
62
|
target: {
|
|
63
63
|
kind: 'step',
|
|
64
|
-
id: '
|
|
65
|
-
label: '
|
|
64
|
+
id: 'story-steps',
|
|
65
|
+
label: 'Story steps',
|
|
66
|
+
detail: 'step 2',
|
|
67
|
+
stepIndex: 1,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'i20',
|
|
72
|
+
severity: 'info',
|
|
73
|
+
kind: 'step_unconfirmed',
|
|
74
|
+
message: 'Trail "Story steps" step 1: call site not verified (entry → store via writes)',
|
|
75
|
+
target: {
|
|
76
|
+
kind: 'step',
|
|
77
|
+
id: 'story-steps',
|
|
78
|
+
label: 'Story steps',
|
|
79
|
+
detail: 'step 1',
|
|
80
|
+
stepIndex: 0,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'i21',
|
|
85
|
+
severity: 'info',
|
|
86
|
+
kind: 'step_stale',
|
|
87
|
+
message: 'Trail "Story steps" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
|
|
88
|
+
target: {
|
|
89
|
+
kind: 'step',
|
|
90
|
+
id: 'story-steps',
|
|
91
|
+
label: 'Story steps',
|
|
66
92
|
detail: 'step 2',
|
|
67
93
|
stepIndex: 1,
|
|
68
94
|
},
|
|
@@ -181,3 +207,143 @@ export const GraphWithIssues: Story = {
|
|
|
181
207
|
</div>
|
|
182
208
|
),
|
|
183
209
|
};
|
|
210
|
+
|
|
211
|
+
/** Trail step verification findings — unconfirmed and stale call sites. */
|
|
212
|
+
const TRAIL_STEP_ISSUES: SubsystemIssue[] = [
|
|
213
|
+
{
|
|
214
|
+
id: 'ts1',
|
|
215
|
+
severity: 'info',
|
|
216
|
+
kind: 'step_unconfirmed',
|
|
217
|
+
message: 'Trail "Add to Cart" step 1: call site not verified (addToCart → cartStore via writes)',
|
|
218
|
+
target: {
|
|
219
|
+
kind: 'step',
|
|
220
|
+
id: 'add-to-cart',
|
|
221
|
+
label: 'Add to Cart',
|
|
222
|
+
detail: 'step 1',
|
|
223
|
+
stepIndex: 0,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: 'ts2',
|
|
228
|
+
severity: 'info',
|
|
229
|
+
kind: 'step_unconfirmed',
|
|
230
|
+
message: 'Trail "Add to Cart" step 2: call site not verified (cartStore → inventoryService via calls)',
|
|
231
|
+
target: {
|
|
232
|
+
kind: 'step',
|
|
233
|
+
id: 'add-to-cart',
|
|
234
|
+
label: 'Add to Cart',
|
|
235
|
+
detail: 'step 2',
|
|
236
|
+
stepIndex: 1,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'ts3',
|
|
241
|
+
severity: 'info',
|
|
242
|
+
kind: 'step_stale',
|
|
243
|
+
message: 'Trail "Checkout" step 1: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
|
|
244
|
+
target: {
|
|
245
|
+
kind: 'step',
|
|
246
|
+
id: 'checkout',
|
|
247
|
+
label: 'Checkout',
|
|
248
|
+
detail: 'step 1',
|
|
249
|
+
stepIndex: 0,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: 'ts4',
|
|
254
|
+
severity: 'info',
|
|
255
|
+
kind: 'step_stale',
|
|
256
|
+
message: 'Trail "Checkout" step 3: call site changed since verification (lines 78-82 in src/checkout/api.ts)',
|
|
257
|
+
target: {
|
|
258
|
+
kind: 'step',
|
|
259
|
+
id: 'checkout',
|
|
260
|
+
label: 'Checkout',
|
|
261
|
+
detail: 'step 3',
|
|
262
|
+
stepIndex: 2,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
/** Trail step verification: shows unconfirmed and stale call site findings. */
|
|
268
|
+
export const TrailStepVerification: Story = {
|
|
269
|
+
render: () => (
|
|
270
|
+
<div style={{ display: 'flex', flexDirection: 'column', width: 380, height: '100vh', borderRight: '1px solid #333' }}>
|
|
271
|
+
<SubsystemIssueList
|
|
272
|
+
issues={TRAIL_STEP_ISSUES}
|
|
273
|
+
focusCategory="trail"
|
|
274
|
+
onSelectIssue={() => {}}
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
),
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** Trail step verification badges on graph edges. */
|
|
281
|
+
const stepVerificationComponents = components([
|
|
282
|
+
['api', 'checkoutApi', 'function', 'src/checkout/api.ts', 'pkg:github/you/your-app', 'Handles cart requests.', 'checkoutApi'],
|
|
283
|
+
['store', 'cartStore', 'store', 'src/checkout/cartStore.ts', 'pkg:github/you/your-app', 'Retained cart state.', 'cartStore'],
|
|
284
|
+
['payment', 'PaymentService', 'class', 'src/payment/service.ts', 'pkg:github/you/your-app', 'Processes payments.', 'PaymentService'],
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const stepVerificationTrails = [
|
|
288
|
+
{
|
|
289
|
+
id: 'checkout-flow',
|
|
290
|
+
title: 'Checkout',
|
|
291
|
+
steps: [
|
|
292
|
+
{ from: 'api', to: 'store', mechanism: 'writes' as const },
|
|
293
|
+
{ from: 'store', to: 'payment', mechanism: 'calls' as const },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
const stepVerificationIssues: SubsystemIssue[] = [
|
|
299
|
+
{
|
|
300
|
+
id: 'sv1',
|
|
301
|
+
severity: 'info',
|
|
302
|
+
kind: 'step_unconfirmed',
|
|
303
|
+
message: 'Trail "Checkout" step 1: call site not verified (checkoutApi → cartStore via writes)',
|
|
304
|
+
target: {
|
|
305
|
+
kind: 'step',
|
|
306
|
+
id: 'checkout-flow',
|
|
307
|
+
label: 'Checkout',
|
|
308
|
+
detail: 'step 1',
|
|
309
|
+
stepIndex: 0,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'sv2',
|
|
314
|
+
severity: 'info',
|
|
315
|
+
kind: 'step_stale',
|
|
316
|
+
message: 'Trail "Checkout" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
|
|
317
|
+
target: {
|
|
318
|
+
kind: 'step',
|
|
319
|
+
id: 'checkout-flow',
|
|
320
|
+
label: 'Checkout',
|
|
321
|
+
detail: 'step 2',
|
|
322
|
+
stepIndex: 1,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
/** Graph with trail step verification badges on edges — ? for unconfirmed, clock for stale. */
|
|
328
|
+
export const GraphWithStepVerificationBadges: Story = {
|
|
329
|
+
render: () => (
|
|
330
|
+
<div style={{ width: '100%', height: '100vh' }}>
|
|
331
|
+
<SubsystemComponentGraph
|
|
332
|
+
components={stepVerificationComponents}
|
|
333
|
+
trails={stepVerificationTrails}
|
|
334
|
+
initialTrailId="checkout-flow"
|
|
335
|
+
title="Checkout with Step Verification"
|
|
336
|
+
description="Shows edge badges for step verification status: question mark (?) for unconfirmed call sites, clock for stale call sites."
|
|
337
|
+
diagnostic={{
|
|
338
|
+
status: 'issues',
|
|
339
|
+
issueCount: stepVerificationIssues.length,
|
|
340
|
+
onToggle: () => {},
|
|
341
|
+
}}
|
|
342
|
+
issues={stepVerificationIssues}
|
|
343
|
+
showIssues
|
|
344
|
+
onSelectIssue={() => {}}
|
|
345
|
+
onApplyIssueFix={() => {}}
|
|
346
|
+
/>
|
|
347
|
+
</div>
|
|
348
|
+
),
|
|
349
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
|
|
3
3
|
*
|
|
4
|
-
* Feed it
|
|
4
|
+
* Feed it an authored `C4Model` and it draws the
|
|
5
5
|
* system as a compound frame, its containers (or components) as boxes, and
|
|
6
6
|
* externals/actors outside the system. `trail` steps render as flow
|
|
7
7
|
* edges. Click a box to list the source components it rolled up.
|
|
@@ -22,9 +22,11 @@ import {
|
|
|
22
22
|
ReactFlow,
|
|
23
23
|
ReactFlowProvider,
|
|
24
24
|
useReactFlow,
|
|
25
|
+
useViewport,
|
|
25
26
|
} from '@xyflow/react';
|
|
26
27
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
27
28
|
import { computeElkLayout } from '../utils/elkLayout';
|
|
29
|
+
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
|
|
28
30
|
import { MECHANISM_COLOR } from './model';
|
|
29
31
|
import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
|
|
30
32
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
@@ -34,6 +36,17 @@ export interface C4GraphProps {
|
|
|
34
36
|
model: C4Model;
|
|
35
37
|
title?: string;
|
|
36
38
|
onSelectNode?: (id: string | null) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Extra width / height reserved around each card when laying out, in flow px.
|
|
41
|
+
* The visible gap between boxes is `2 × gutter` (ELK's `nodeNode` spacing,
|
|
42
|
+
* plus the frame padding where a frame adds one).
|
|
43
|
+
*
|
|
44
|
+
* Above the default 28 the `needs …` gap row was removed, so cards are
|
|
45
|
+
* shorter and packing them tighter than the spacing they were tuned at makes
|
|
46
|
+
* them collide. Raise it for a more airy graph.
|
|
47
|
+
* @default 28
|
|
48
|
+
*/
|
|
49
|
+
gutter?: number;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
function edgeColor(mechanism: string | undefined, fallback: string): string {
|
|
@@ -57,6 +70,98 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
|
|
|
57
70
|
);
|
|
58
71
|
}
|
|
59
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Edge-label overlay.
|
|
75
|
+
*
|
|
76
|
+
* Drawn as an HTML layer above the canvas rather than React Flow's built-in
|
|
77
|
+
* edge label, so a C4 line's label reads like the subsystem graph's: an opaque
|
|
78
|
+
* rounded chip in the mechanism colour, monospace text, scaling with zoom.
|
|
79
|
+
*
|
|
80
|
+
* Positions come from ELK's computed label anchors (`edgeLabelPositions`, flow
|
|
81
|
+
* coords); the current viewport converts them to screen space.
|
|
82
|
+
*/
|
|
83
|
+
function C4EdgeLabels({
|
|
84
|
+
positions,
|
|
85
|
+
edges,
|
|
86
|
+
muted,
|
|
87
|
+
}: {
|
|
88
|
+
positions: Map<string, { x: number; y: number }>;
|
|
89
|
+
edges: C4Model['edges'];
|
|
90
|
+
muted: string;
|
|
91
|
+
}) {
|
|
92
|
+
const { theme } = useTheme();
|
|
93
|
+
const viewport = useViewport();
|
|
94
|
+
const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
95
|
+
const byId = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
style={{
|
|
100
|
+
position: 'absolute',
|
|
101
|
+
inset: 0,
|
|
102
|
+
overflow: 'hidden',
|
|
103
|
+
pointerEvents: 'none',
|
|
104
|
+
zIndex: 5,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{[...positions].map(([id, p]) => {
|
|
108
|
+
const edge = byId.get(id);
|
|
109
|
+
if (!edge) return null;
|
|
110
|
+
const color = edgeColor(edge.mechanisms[0], muted);
|
|
111
|
+
// C4 asks the line to name the interaction's protocol; fall back to the
|
|
112
|
+
// label when a caller supplied none.
|
|
113
|
+
const text = edge.protocol ?? edge.label;
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
key={id}
|
|
117
|
+
style={{
|
|
118
|
+
position: 'absolute',
|
|
119
|
+
left: p.x * viewport.zoom + viewport.x,
|
|
120
|
+
top: p.y * viewport.zoom + viewport.y,
|
|
121
|
+
transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
|
|
122
|
+
transformOrigin: 'center center',
|
|
123
|
+
display: 'flex',
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
justifyContent: 'center',
|
|
126
|
+
width: EDGE_LABEL_WIDTH,
|
|
127
|
+
height: EDGE_LABEL_HEIGHT,
|
|
128
|
+
boxSizing: 'border-box',
|
|
129
|
+
padding: '7px 8px',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<span
|
|
133
|
+
aria-hidden
|
|
134
|
+
style={{
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
inset: 0,
|
|
137
|
+
borderRadius: 4,
|
|
138
|
+
border: `0.5px solid ${color}`,
|
|
139
|
+
background: surface,
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
<span
|
|
143
|
+
style={{
|
|
144
|
+
position: 'relative',
|
|
145
|
+
maxWidth: '100%',
|
|
146
|
+
overflow: 'hidden',
|
|
147
|
+
textOverflow: 'ellipsis',
|
|
148
|
+
whiteSpace: 'nowrap',
|
|
149
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
150
|
+
lineHeight: 1,
|
|
151
|
+
fontFamily: theme.fonts.monospace,
|
|
152
|
+
fontWeight: 500,
|
|
153
|
+
color,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{text}
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
60
165
|
function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
|
|
61
166
|
const { theme } = useTheme();
|
|
62
167
|
const color = props.data.color;
|
|
@@ -106,7 +211,7 @@ const nodeTypes = {
|
|
|
106
211
|
'c4-group': C4GroupView,
|
|
107
212
|
};
|
|
108
213
|
|
|
109
|
-
function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
214
|
+
function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
110
215
|
const { theme } = useTheme();
|
|
111
216
|
const { fitView } = useReactFlow();
|
|
112
217
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -114,6 +219,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
114
219
|
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
115
220
|
const [ready, setReady] = useState(false);
|
|
116
221
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
222
|
+
const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
|
|
223
|
+
() => new Map(),
|
|
224
|
+
);
|
|
117
225
|
|
|
118
226
|
const layoutKey = useMemo(
|
|
119
227
|
() =>
|
|
@@ -130,10 +238,11 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
130
238
|
setReady(false);
|
|
131
239
|
setSelectedId(null);
|
|
132
240
|
|
|
133
|
-
// Per-
|
|
134
|
-
// container dimensions for everything would make ELK reserve
|
|
135
|
-
// space around a
|
|
136
|
-
// apart as it needs.
|
|
241
|
+
// Per-kind size, so a square component is laid out as a square. Feeding the
|
|
242
|
+
// container dimensions for everything would make ELK reserve 250x150 of
|
|
243
|
+
// space around a 140x140 box and space the component view twice as far
|
|
244
|
+
// apart as it needs. `gutter` is added here, not passed as node spacing, so
|
|
245
|
+
// a frame's padding clears the card inside it by the same margin.
|
|
137
246
|
const rfNodes: Node[] = model.nodes.map((n, i) => {
|
|
138
247
|
const size = nodeSize(n);
|
|
139
248
|
return {
|
|
@@ -142,8 +251,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
142
251
|
// Fallback grid pitch uses the container box — the largest, so the
|
|
143
252
|
// pre-layout positions never overlap. ELK replaces them on success.
|
|
144
253
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
145
|
-
width: size.width,
|
|
146
|
-
height: size.height,
|
|
254
|
+
width: size.width + gutter,
|
|
255
|
+
height: size.height + gutter,
|
|
147
256
|
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
148
257
|
data: { node: n, selected: false },
|
|
149
258
|
};
|
|
@@ -162,16 +271,17 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
162
271
|
id: e.id,
|
|
163
272
|
source: e.source,
|
|
164
273
|
target: e.target,
|
|
165
|
-
label
|
|
166
|
-
|
|
167
|
-
|
|
274
|
+
// The reserved label box and the chip must agree on size, so ELK is
|
|
275
|
+
// given the same text the chip draws (protocol first, then label). The
|
|
276
|
+
// chip itself is the C4EdgeLabels overlay, not this field.
|
|
277
|
+
label: e.protocol ?? e.label,
|
|
168
278
|
style: {
|
|
169
279
|
stroke: color,
|
|
170
280
|
strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
|
|
171
281
|
strokeDasharray: '6 4',
|
|
172
282
|
},
|
|
173
283
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
174
|
-
data: { edge: e },
|
|
284
|
+
data: { edge: e, color },
|
|
175
285
|
};
|
|
176
286
|
});
|
|
177
287
|
|
|
@@ -180,10 +290,12 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
180
290
|
preserveNodePositions: false,
|
|
181
291
|
groups,
|
|
182
292
|
keepSingletonGroups: true,
|
|
293
|
+
edgeLabels: { enabled: true, placement: 'CENTER' },
|
|
183
294
|
})
|
|
184
295
|
.then((result) => {
|
|
185
296
|
if (!alive) return;
|
|
186
297
|
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
298
|
+
setLabelPositions(result.edgeLabelPositions ?? new Map());
|
|
187
299
|
const builtGroups = new Set(result.groupBounds.keys());
|
|
188
300
|
const groupById = new Map(model.groups.map((g) => [g.id, g]));
|
|
189
301
|
|
|
@@ -293,6 +405,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
293
405
|
>
|
|
294
406
|
<GraphChrome />
|
|
295
407
|
</ReactFlow>
|
|
408
|
+
{ready && labelPositions.size > 0 && (
|
|
409
|
+
<C4EdgeLabels positions={labelPositions} edges={model.edges} muted={muted} />
|
|
410
|
+
)}
|
|
296
411
|
{!ready && (
|
|
297
412
|
<div
|
|
298
413
|
style={{
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
nodeStyle,
|
|
4
4
|
nodeSubtitle,
|
|
5
5
|
nodeTag,
|
|
6
|
+
nodeKindIcon,
|
|
7
|
+
nodeTopLabel,
|
|
6
8
|
nodeShape,
|
|
7
9
|
nodeMissing,
|
|
8
10
|
NODE_W,
|
|
@@ -111,6 +113,47 @@ describe('nodeTag — what the box IS', () => {
|
|
|
111
113
|
});
|
|
112
114
|
});
|
|
113
115
|
|
|
116
|
+
describe('nodeKindIcon — a container’s sort, not its C4 kind', () => {
|
|
117
|
+
test('application and data-store get different icons', () => {
|
|
118
|
+
const app = nodeKindIcon(node({ containerKind: 'application' }));
|
|
119
|
+
const store = nodeKindIcon(node({ containerKind: 'data-store' }));
|
|
120
|
+
expect(app).toBeDefined();
|
|
121
|
+
expect(store).toBeDefined();
|
|
122
|
+
expect(app).not.toBe(store);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('only containers get an icon — the shape carries the rest', () => {
|
|
126
|
+
for (const kind of ['component', 'external-system', 'person'] as const) {
|
|
127
|
+
expect(nodeKindIcon(node({ kind }))).toBeUndefined();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the icon does not depend on confirmation state', () => {
|
|
132
|
+
expect(nodeKindIcon(node({ state: 'proposed' }))).toBe(
|
|
133
|
+
nodeKindIcon(node({ state: 'accepted' })),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('nodeTopLabel — the technology, or the kind when none is stated', () => {
|
|
139
|
+
test('a stated technology is the label', () => {
|
|
140
|
+
expect(
|
|
141
|
+
nodeTopLabel(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
|
|
142
|
+
).toBe('Bun + Electrobun');
|
|
143
|
+
expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a container with no technology falls back to its kind', () => {
|
|
147
|
+
expect(nodeTopLabel(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the other kinds fall back to their C4 kind', () => {
|
|
151
|
+
expect(nodeTopLabel(node({ kind: 'component' }))).toBe('component');
|
|
152
|
+
expect(nodeTopLabel(node({ kind: 'external-system' }))).toBe('external-system');
|
|
153
|
+
expect(nodeTopLabel(node({ kind: 'person' }))).toBe('person');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
114
157
|
describe('nodeShape — kind without relying on colour or dash', () => {
|
|
115
158
|
test('a container is a sharp rectangle', () => {
|
|
116
159
|
const s = nodeShape(node());
|
|
@@ -142,47 +185,28 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
142
185
|
});
|
|
143
186
|
|
|
144
187
|
describe('nodeSubtitle', () => {
|
|
145
|
-
test('
|
|
188
|
+
test('is the element description when it has one', () => {
|
|
146
189
|
expect(
|
|
147
|
-
nodeSubtitle(node({
|
|
148
|
-
).toBe('
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
test('a component shows its construct and file basename', () => {
|
|
169
|
-
const s = nodeSubtitle(
|
|
170
|
-
node({
|
|
171
|
-
kind: 'component',
|
|
172
|
-
component: {
|
|
173
|
-
alias: 'a',
|
|
174
|
-
name: 'buildGroups',
|
|
175
|
-
construct: 'function',
|
|
176
|
-
file: 'src/subsystem/model.ts',
|
|
177
|
-
purl: 'pkg:x#src/subsystem/model.ts',
|
|
178
|
-
},
|
|
179
|
-
}),
|
|
180
|
-
);
|
|
181
|
-
expect(s).toBe('function · model.ts');
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test('a component shows its technology when it has one', () => {
|
|
185
|
-
expect(nodeSubtitle(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
190
|
+
nodeSubtitle(node({ technology: 'Bun', description: 'Runs the audit pipeline.' })),
|
|
191
|
+
).toBe('Runs the audit pipeline.');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('is empty when nothing is stated — there is no fallback', () => {
|
|
195
|
+
const withTech = node({ technology: 'Bun + Electrobun' });
|
|
196
|
+
const withMembers = node({ constructs: ['function', 'store'], members: ['a', 'b'] });
|
|
197
|
+
const component = node({
|
|
198
|
+
kind: 'component',
|
|
199
|
+
component: {
|
|
200
|
+
alias: 'a',
|
|
201
|
+
name: 'buildGroups',
|
|
202
|
+
construct: 'function',
|
|
203
|
+
file: 'src/subsystem/model.ts',
|
|
204
|
+
purl: 'pkg:x#src/subsystem/model.ts',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
for (const n of [withTech, withMembers, component]) {
|
|
208
|
+
expect(nodeSubtitle(n)).toBe('');
|
|
209
|
+
}
|
|
186
210
|
});
|
|
187
211
|
});
|
|
188
212
|
|
|
@@ -248,8 +272,8 @@ describe('nodeSize — a component is a proper square', () => {
|
|
|
248
272
|
|
|
249
273
|
describe('card metrics', () => {
|
|
250
274
|
test('exposes the size the graph lays out against', () => {
|
|
251
|
-
expect(NODE_W).toBe(
|
|
252
|
-
expect(NODE_H).toBe(
|
|
275
|
+
expect(NODE_W).toBe(250);
|
|
276
|
+
expect(NODE_H).toBe(150);
|
|
253
277
|
});
|
|
254
278
|
|
|
255
279
|
test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
|
|
@@ -258,23 +282,23 @@ describe('card metrics', () => {
|
|
|
258
282
|
});
|
|
259
283
|
|
|
260
284
|
test('is near-square, not a wide strip', () => {
|
|
261
|
-
// The card carries
|
|
262
|
-
//
|
|
263
|
-
//
|
|
285
|
+
// The card carries three fields (technology row, label, description). A
|
|
286
|
+
// 3:1 strip wraps the last two hard; this is the aspect ratio that lets
|
|
287
|
+
// each sit on its own line.
|
|
264
288
|
const ratio = NODE_W / NODE_H;
|
|
265
289
|
expect(ratio).toBeGreaterThan(1);
|
|
266
290
|
expect(ratio).toBeLessThan(1.8);
|
|
267
291
|
});
|
|
268
292
|
|
|
269
|
-
test('is tall enough for
|
|
270
|
-
//
|
|
293
|
+
test('is tall enough for three lines of content', () => {
|
|
294
|
+
// technology row + 2-line label + description, plus padding.
|
|
271
295
|
expect(NODE_H).toBeGreaterThanOrEqual(130);
|
|
272
296
|
});
|
|
273
297
|
|
|
274
|
-
test('leaves breathing room around
|
|
298
|
+
test('leaves breathing room around three lines of text', () => {
|
|
275
299
|
// Top-aligned now, not centred, so this is about slack rather than
|
|
276
|
-
// balance:
|
|
277
|
-
const contentBudget = 16 + 40 + 16 +
|
|
300
|
+
// balance: technology row + 2-line label slot + description + padding.
|
|
301
|
+
const contentBudget = 16 + 40 + 16 + 20; // lines + 10px padding ×2
|
|
278
302
|
expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
|
|
279
303
|
});
|
|
280
304
|
|
|
@@ -286,16 +310,23 @@ describe('card metrics', () => {
|
|
|
286
310
|
});
|
|
287
311
|
|
|
288
312
|
describe('container kinds', () => {
|
|
289
|
-
test('
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
313
|
+
test('each container kind has an icon and a fallback label', () => {
|
|
314
|
+
for (const containerKind of ['application', 'data-store'] as const) {
|
|
315
|
+
expect(nodeKindIcon(node({ containerKind }))).toBeDefined();
|
|
316
|
+
// No technology stated, so the label falls back to the kind.
|
|
317
|
+
expect(nodeTopLabel(node({ containerKind, technology: '' }))).toBe(containerKind);
|
|
293
318
|
}
|
|
294
319
|
});
|
|
295
320
|
|
|
321
|
+
test('application and data-store do not share an icon', () => {
|
|
322
|
+
expect(nodeKindIcon(node({ containerKind: 'application' }))).not.toBe(
|
|
323
|
+
nodeKindIcon(node({ containerKind: 'data-store' })),
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
296
327
|
test('a library is not one of them', () => {
|
|
297
328
|
// C4 says a module "typically" is not an element at all, so there is no
|
|
298
329
|
// kind to render. A library-shaped group is a review comment.
|
|
299
|
-
expect(['application', 'data-store'
|
|
330
|
+
expect(['application', 'data-store']).not.toContain('library');
|
|
300
331
|
});
|
|
301
332
|
});
|