@principal-ai/subsystems-react 0.51.3 → 0.52.0
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 +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +3 -7
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +202 -63
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +26 -26
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +56 -30
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +20 -1
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +74 -26
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +48 -49
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +50 -0
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.d.ts +88 -84
- package/dist/subsystem/c4Evidence.d.ts.map +1 -1
- package/dist/subsystem/c4Evidence.js +113 -166
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/model.d.ts +6 -0
- 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 +9 -7
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/techIcons/index.d.ts +5 -0
- package/dist/subsystem/techIcons/index.d.ts.map +1 -1
- package/dist/subsystem/techIcons/index.js +3 -0
- package/dist/subsystem/techIcons/index.js.map +1 -1
- package/dist/utils/edgeLabel.d.ts +14 -0
- package/dist/utils/edgeLabel.d.ts.map +1 -1
- package/dist/utils/edgeLabel.js +24 -0
- package/dist/utils/edgeLabel.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +65 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +145 -11
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -6
- package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
- package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
- package/src/subsystem/C4Graph.tsx +296 -99
- package/src/subsystem/C4NodeCard.test.ts +32 -22
- package/src/subsystem/C4NodeCard.tsx +67 -39
- package/src/subsystem/IssueList.tsx +23 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +79 -23
- package/src/subsystem/c4.protocolColor.test.ts +26 -0
- package/src/subsystem/c4.ts +76 -52
- package/src/subsystem/c4Evidence.test.ts +177 -237
- package/src/subsystem/c4Evidence.ts +160 -235
- package/src/subsystem/model.ts +6 -0
- package/src/subsystem/nodes.group.test.tsx +15 -0
- package/src/subsystem/nodes.tsx +12 -9
- package/src/subsystem/techIcons/index.tsx +8 -0
- package/src/utils/edgeLabel.test.ts +21 -2
- package/src/utils/edgeLabel.ts +28 -0
- package/src/utils/elkLayout.test.ts +50 -0
- package/src/utils/elkLayout.ts +191 -11
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
|
@@ -144,6 +144,26 @@ const trails: SubsystemTrail[] = [
|
|
|
144
144
|
},
|
|
145
145
|
],
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
id: 'wt-planned-seam',
|
|
149
|
+
title: 'Planned seam (step-proposed)',
|
|
150
|
+
steps: [
|
|
151
|
+
{
|
|
152
|
+
from: 'audit',
|
|
153
|
+
to: 'cli',
|
|
154
|
+
mechanism: 'uses',
|
|
155
|
+
file: 'src/bun/verify-subsystem-component.ts',
|
|
156
|
+
line: 1,
|
|
157
|
+
symbol: 'probeCliVersion',
|
|
158
|
+
// Step-level proposed: BOTH endpoints are real components — the
|
|
159
|
+
// seam itself is what is planned. The edge label draws dashed on
|
|
160
|
+
// the canvas; distinct from a proposed endpoint, which dashes the
|
|
161
|
+
// node instead. Verification skips the step until promoted.
|
|
162
|
+
proposed: true,
|
|
163
|
+
annotation: 'The step itself is proposed — edge label is dashed.',
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
147
167
|
];
|
|
148
168
|
|
|
149
169
|
function ProposedTrailsDemo() {
|
|
@@ -153,7 +173,7 @@ function ProposedTrailsDemo() {
|
|
|
153
173
|
components={components}
|
|
154
174
|
trails={trails}
|
|
155
175
|
title="proposed-work marker"
|
|
156
|
-
description="Trails that touch a **proposed** component get their title tinted darkgoldenrod in the flows panel; expand a row and each step onto a proposed component is tinted too (index and title). Nodes for proposed components
|
|
176
|
+
description="Trails that touch a **proposed** component get their title tinted darkgoldenrod in the flows panel; expand a row and each step onto a proposed component is tinted too (index and title). Nodes for proposed components draw dashed darkgoldenrod on the canvas. A step that is itself `proposed` — a planned seam between real components — dashes its **edge label** instead."
|
|
157
177
|
/>
|
|
158
178
|
</div>
|
|
159
179
|
);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { useEffect, useMemo, useState } from 'react';
|
|
15
15
|
import {
|
|
16
16
|
Edge,
|
|
17
|
+
EdgeProps,
|
|
17
18
|
Handle,
|
|
18
19
|
MarkerType,
|
|
19
20
|
Node,
|
|
@@ -25,40 +26,31 @@ import {
|
|
|
25
26
|
useViewport,
|
|
26
27
|
} from '@xyflow/react';
|
|
27
28
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
28
|
-
import { computeElkLayout } from '../utils/elkLayout';
|
|
29
|
-
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT,
|
|
30
|
-
import {
|
|
31
|
-
import { C4NodeCard, NODE_H, NODE_W, nodeSize
|
|
29
|
+
import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
30
|
+
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
|
|
31
|
+
import { protocolColor } from './c4';
|
|
32
|
+
import { C4NodeCard, NODE_H, NODE_W, nodeSize } from './C4NodeCard';
|
|
32
33
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
33
|
-
import type {
|
|
34
|
+
import type { C4Element, C4Model } from './c4';
|
|
34
35
|
|
|
35
36
|
export interface C4GraphProps {
|
|
36
37
|
model: C4Model;
|
|
37
38
|
title?: string;
|
|
38
39
|
onSelectNode?: (id: string | null) => void;
|
|
39
40
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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.
|
|
41
|
+
* Breathing room around each card, in flow px, applied as ELK spacing (not by
|
|
42
|
+
* inflating the node box — that left the edge lines starting short of the
|
|
43
|
+
* card border). `nodeSpacing` is `2 × gutter`; `edgeNodeSpacing` is `gutter`.
|
|
47
44
|
* @default 28
|
|
48
45
|
*/
|
|
49
46
|
gutter?: number;
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
|
|
58
|
-
const { node, selected } = props.data;
|
|
49
|
+
function C4NodeView(props: NodeProps<Node<{ element: C4Element; selected: boolean }>>) {
|
|
50
|
+
const { element, selected } = props.data;
|
|
59
51
|
return (
|
|
60
52
|
<C4NodeCard
|
|
61
|
-
node={
|
|
53
|
+
node={element}
|
|
62
54
|
selected={selected}
|
|
63
55
|
handles={
|
|
64
56
|
<>
|
|
@@ -73,21 +65,17 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
|
|
|
73
65
|
/**
|
|
74
66
|
* Edge-label overlay.
|
|
75
67
|
*
|
|
76
|
-
* Drawn as
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* Positions come from ELK's computed label anchors (`edgeLabelPositions`, flow
|
|
81
|
-
* coords); the current viewport converts them to screen space.
|
|
68
|
+
* Drawn as chips in flow space via `<ViewportPortal>` — React Flow applies the
|
|
69
|
+
* viewport transform, so the label chip needs no manual zoom/pan math and can
|
|
70
|
+
* never drift from the edges it sits on. Positions are ELK's computed label
|
|
71
|
+
* anchors (`edgeLabelPositions`), in flow coordinates.
|
|
82
72
|
*/
|
|
83
73
|
function C4EdgeLabels({
|
|
84
74
|
positions,
|
|
85
75
|
edges,
|
|
86
|
-
muted,
|
|
87
76
|
}: {
|
|
88
77
|
positions: Map<string, { x: number; y: number }>;
|
|
89
78
|
edges: C4Model['edges'];
|
|
90
|
-
muted: string;
|
|
91
79
|
}) {
|
|
92
80
|
const { theme } = useTheme();
|
|
93
81
|
const viewport = useViewport();
|
|
@@ -107,9 +95,7 @@ function C4EdgeLabels({
|
|
|
107
95
|
{[...positions].map(([id, p]) => {
|
|
108
96
|
const edge = byId.get(id);
|
|
109
97
|
if (!edge) return null;
|
|
110
|
-
const color =
|
|
111
|
-
// C4 asks the line to name the interaction's protocol; fall back to the
|
|
112
|
-
// label when a caller supplied none.
|
|
98
|
+
const color = protocolColor(edge.protocol);
|
|
113
99
|
const text = edge.protocol ?? edge.label;
|
|
114
100
|
return (
|
|
115
101
|
<div
|
|
@@ -123,7 +109,7 @@ function C4EdgeLabels({
|
|
|
123
109
|
display: 'flex',
|
|
124
110
|
alignItems: 'center',
|
|
125
111
|
justifyContent: 'center',
|
|
126
|
-
width:
|
|
112
|
+
width: estimateEdgeLabelWidth(text),
|
|
127
113
|
height: EDGE_LABEL_HEIGHT,
|
|
128
114
|
boxSizing: 'border-box',
|
|
129
115
|
padding: '7px 8px',
|
|
@@ -162,6 +148,30 @@ function C4EdgeLabels({
|
|
|
162
148
|
);
|
|
163
149
|
}
|
|
164
150
|
|
|
151
|
+
/**
|
|
152
|
+
* C4 edge — draws ELK's routed polyline, not React Flow's default bezier.
|
|
153
|
+
*
|
|
154
|
+
* The line and the label chip must share one geometry: the chip is placed from
|
|
155
|
+
* the same `elkPathPoints` this renders. React Flow's built-in edge would curve
|
|
156
|
+
* between handles while the chip sat on ELK's orthogonal route, which is why
|
|
157
|
+
* labels read as "off the line".
|
|
158
|
+
*/
|
|
159
|
+
function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?: string }>>) {
|
|
160
|
+
const path = data?.path ?? '';
|
|
161
|
+
if (!path) return null;
|
|
162
|
+
return (
|
|
163
|
+
<path
|
|
164
|
+
d={path}
|
|
165
|
+
fill="none"
|
|
166
|
+
stroke={data?.color}
|
|
167
|
+
strokeWidth={1}
|
|
168
|
+
strokeDasharray="6 4"
|
|
169
|
+
markerEnd={markerEnd}
|
|
170
|
+
style={{ pointerEvents: 'none' }}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
|
|
166
176
|
const { theme } = useTheme();
|
|
167
177
|
const color = props.data.color;
|
|
@@ -211,6 +221,10 @@ const nodeTypes = {
|
|
|
211
221
|
'c4-group': C4GroupView,
|
|
212
222
|
};
|
|
213
223
|
|
|
224
|
+
const edgeTypes = {
|
|
225
|
+
'c4-edge': C4EdgeView,
|
|
226
|
+
};
|
|
227
|
+
|
|
214
228
|
function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
215
229
|
const { theme } = useTheme();
|
|
216
230
|
const { fitView } = useReactFlow();
|
|
@@ -238,12 +252,17 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
238
252
|
setReady(false);
|
|
239
253
|
setSelectedId(null);
|
|
240
254
|
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
255
|
+
// Draw only the elements for this view. A container diagram shows the
|
|
256
|
+
// runtime units (containers, externals, people); components belong to the
|
|
257
|
+
// component view. Components are still carried on the model (see the side
|
|
258
|
+
// panel), just not drawn here.
|
|
259
|
+
const inView = (n: C4Element): boolean =>
|
|
260
|
+
model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
|
|
261
|
+
|
|
262
|
+
// Layout against the true card size, so ELK's ports sit on the card's
|
|
263
|
+
// border — an inflated box leaves the line starting short of the card edge.
|
|
264
|
+
// The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
|
|
265
|
+
const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
|
|
247
266
|
const size = nodeSize(n);
|
|
248
267
|
return {
|
|
249
268
|
id: n.id,
|
|
@@ -251,10 +270,10 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
251
270
|
// Fallback grid pitch uses the container box — the largest, so the
|
|
252
271
|
// pre-layout positions never overlap. ELK replaces them on success.
|
|
253
272
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
254
|
-
width: size.width
|
|
255
|
-
height: size.height
|
|
273
|
+
width: size.width,
|
|
274
|
+
height: size.height,
|
|
256
275
|
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
257
|
-
data: {
|
|
276
|
+
data: { element: n, selected: false },
|
|
258
277
|
};
|
|
259
278
|
});
|
|
260
279
|
|
|
@@ -264,24 +283,43 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
264
283
|
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
265
284
|
}));
|
|
266
285
|
|
|
286
|
+
// Band nodes so a flat layout still reads as C4 and frames can't interleave.
|
|
287
|
+
//
|
|
288
|
+
// Flat mode has no ELK parents, so without bands ELK mixes members of
|
|
289
|
+
// different frames across the whole graph — their synthesized bounding
|
|
290
|
+
// boxes then overlap. A partition per frame keeps each frame's members
|
|
291
|
+
// contiguous; people lead, externals trail.
|
|
292
|
+
//
|
|
293
|
+
// Order (ascending = earlier in the layout direction): people, then each
|
|
294
|
+
// frame in model order, then external systems.
|
|
295
|
+
const frameIds = model.groups.map((g) => g.id);
|
|
296
|
+
const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
|
|
297
|
+
const partitionByNode = new Map<string, number>(
|
|
298
|
+
model.nodes.map((n) => {
|
|
299
|
+
if (n.kind === 'person') return [n.id, 0];
|
|
300
|
+
if (n.kind === 'external-system') return [n.id, frameIds.length + 1];
|
|
301
|
+
// A node inside a frame shares its frame's band; a frame-less node
|
|
302
|
+
// (a container at container view) gets its own by id order.
|
|
303
|
+
const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
|
|
304
|
+
return [n.id, 1 + band];
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
|
|
267
308
|
const rfEdges: Edge[] = model.edges.map((e) => {
|
|
268
|
-
//
|
|
269
|
-
|
|
309
|
+
// Colour keys off the protocol, not the trail verb: on a container
|
|
310
|
+
// diagram the transport is what a reader distinguishes at a glance.
|
|
311
|
+
const color = protocolColor(e.protocol);
|
|
270
312
|
return {
|
|
271
313
|
id: e.id,
|
|
314
|
+
type: 'c4-edge',
|
|
272
315
|
source: e.source,
|
|
273
316
|
target: e.target,
|
|
274
317
|
// The reserved label box and the chip must agree on size, so ELK is
|
|
275
318
|
// given the same text the chip draws (protocol first, then label). The
|
|
276
319
|
// chip itself is the C4EdgeLabels overlay, not this field.
|
|
277
320
|
label: e.protocol ?? e.label,
|
|
278
|
-
style: {
|
|
279
|
-
stroke: color,
|
|
280
|
-
strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
|
|
281
|
-
strokeDasharray: '6 4',
|
|
282
|
-
},
|
|
283
321
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
284
|
-
data: { edge: e, color },
|
|
322
|
+
data: { edge: e, color, path: '' },
|
|
285
323
|
};
|
|
286
324
|
});
|
|
287
325
|
|
|
@@ -290,12 +328,38 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
290
328
|
preserveNodePositions: false,
|
|
291
329
|
groups,
|
|
292
330
|
keepSingletonGroups: true,
|
|
293
|
-
edgeLabels: { enabled: true, placement: 'CENTER' },
|
|
331
|
+
edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
|
|
332
|
+
// The gutter that used to inflate each node box is now real spacing, so
|
|
333
|
+
// the ports still sit on the card edge.
|
|
334
|
+
nodeSpacing: gutter * 2,
|
|
335
|
+
edgeNodeSpacing: gutter,
|
|
336
|
+
// Between-layer/between-partition gap. This is what keeps the external
|
|
337
|
+
// band clear of the system frame's padding — ELK gives partitions zero
|
|
338
|
+
// default separation. It stacks on top of ELK's reserved label layer, so
|
|
339
|
+
// it is kept modest.
|
|
340
|
+
interLayerSpacing: 40,
|
|
341
|
+
// Draw the system frame as a decoration around the boxes rather than an
|
|
342
|
+
// ELK parent. Nesting made in-frame edges inherit the frame's padding and
|
|
343
|
+
// spacing, so they ran longer than a root-level edge touching the same
|
|
344
|
+
// nodes; flattening gives every edge one spacing.
|
|
345
|
+
flatGroups: true,
|
|
346
|
+
partitionByNode,
|
|
294
347
|
})
|
|
295
348
|
.then((result) => {
|
|
296
349
|
if (!alive) return;
|
|
297
350
|
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
298
|
-
|
|
351
|
+
// Draw each edge along ELK's routed polyline and place its label from
|
|
352
|
+
// the same points — one geometry for the line and the chip.
|
|
353
|
+
const labelPositions = new Map<string, { x: number; y: number }>();
|
|
354
|
+
const withPaths = rfEdges.map((e) => {
|
|
355
|
+
const pts = result.edgePathPoints?.get(e.id);
|
|
356
|
+
if (!pts || pts.length === 0) return e;
|
|
357
|
+
labelPositions.set(e.id, pointAlongPath(pts, 0.5));
|
|
358
|
+
// Rounded corners on ELK's orthogonal route — gentler than raw right
|
|
359
|
+
// angles, and still the exact polyline the label is placed on.
|
|
360
|
+
return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
|
|
361
|
+
});
|
|
362
|
+
setLabelPositions(labelPositions);
|
|
299
363
|
const builtGroups = new Set(result.groupBounds.keys());
|
|
300
364
|
const groupById = new Map(model.groups.map((g) => [g.id, g]));
|
|
301
365
|
|
|
@@ -309,15 +373,26 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
309
373
|
return d;
|
|
310
374
|
};
|
|
311
375
|
|
|
376
|
+
// Shell positions from flat synthesis are absolute. React Flow reads a
|
|
377
|
+
// child's `position` as relative to its parent, so a shell nested in
|
|
378
|
+
// another shell must be made relative to it.
|
|
379
|
+
const shellAbs = new Map(result.groupBounds);
|
|
380
|
+
const shellOrigin = (id: string | undefined): { x: number; y: number } => {
|
|
381
|
+
if (!id) return { x: 0, y: 0 };
|
|
382
|
+
const b = shellAbs.get(id);
|
|
383
|
+
return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
|
|
384
|
+
};
|
|
385
|
+
|
|
312
386
|
const shells: Node[] = [...result.groupBounds.entries()]
|
|
313
387
|
.sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
|
|
314
388
|
.map(([id, b]) => {
|
|
315
389
|
const def = groupById.get(id);
|
|
316
390
|
const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
|
|
391
|
+
const origin = shellOrigin(parentId);
|
|
317
392
|
return {
|
|
318
393
|
id,
|
|
319
394
|
type: 'c4-group',
|
|
320
|
-
position: { x: b.x, y: b.y },
|
|
395
|
+
position: { x: b.x - origin.x, y: b.y - origin.y },
|
|
321
396
|
width: Math.max(200, b.width),
|
|
322
397
|
height: Math.max(120, b.height),
|
|
323
398
|
draggable: false,
|
|
@@ -333,17 +408,23 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
333
408
|
|
|
334
409
|
const leaves = rfNodes.map((n) => {
|
|
335
410
|
const p = positioned.get(n.id);
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
if
|
|
339
|
-
|
|
340
|
-
|
|
411
|
+
// Absolute flow position from the flat layout.
|
|
412
|
+
const abs = p ? p.position : n.position;
|
|
413
|
+
// Re-parent to the shell this node sits in, if any: a component's
|
|
414
|
+
// container, or a container's system frame. React Flow positions a
|
|
415
|
+
// child relative to its parent, so subtract the shell origin.
|
|
416
|
+
let parentId: string | undefined;
|
|
417
|
+
if (n.data && typeof n.data === 'object') {
|
|
418
|
+
const modelNode = (n.data as { element?: C4Element }).element;
|
|
419
|
+
if (modelNode?.parentId && builtGroups.has(modelNode.parentId)) parentId = modelNode.parentId;
|
|
341
420
|
}
|
|
342
|
-
|
|
421
|
+
const origin = shellOrigin(parentId);
|
|
422
|
+
const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
|
|
423
|
+
return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
|
|
343
424
|
});
|
|
344
425
|
|
|
345
426
|
setNodes([...shells, ...leaves]);
|
|
346
|
-
setRfEdges(
|
|
427
|
+
setRfEdges(withPaths);
|
|
347
428
|
setReady(true);
|
|
348
429
|
requestAnimationFrame(() => fitView({ padding: 0.15 }));
|
|
349
430
|
})
|
|
@@ -367,6 +448,24 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
367
448
|
[model, selectedId],
|
|
368
449
|
);
|
|
369
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The components authored inside the selected element, if it is a container.
|
|
453
|
+
* These are `C4Component`s whose `container` names the selected id (or, once
|
|
454
|
+
* flattened, whose `parentId` does). Shown in the panel so a container can be
|
|
455
|
+
* inspected without zooming the diagram into its components.
|
|
456
|
+
*/
|
|
457
|
+
const selectedComponents = useMemo(
|
|
458
|
+
() =>
|
|
459
|
+
selected
|
|
460
|
+
? model.nodes.filter(
|
|
461
|
+
(n) =>
|
|
462
|
+
n.kind === 'component' &&
|
|
463
|
+
(n.container === selected.id || n.parentId === selected.id),
|
|
464
|
+
)
|
|
465
|
+
: [],
|
|
466
|
+
[model, selected],
|
|
467
|
+
);
|
|
468
|
+
|
|
370
469
|
const displayNodes = useMemo(
|
|
371
470
|
() =>
|
|
372
471
|
nodes.map((n) =>
|
|
@@ -377,7 +476,11 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
377
476
|
[nodes, selectedId],
|
|
378
477
|
);
|
|
379
478
|
|
|
380
|
-
|
|
479
|
+
// What the current view draws: runtime units at container level, components
|
|
480
|
+
// at component level. Mirrors the `inView` filter used for the nodes.
|
|
481
|
+
const runtime = model.nodes.filter((n) =>
|
|
482
|
+
model.view === 'component' ? n.kind === 'component' : n.kind === 'container',
|
|
483
|
+
).length;
|
|
381
484
|
const outside = model.nodes.filter(
|
|
382
485
|
(n) => n.kind === 'external-system' || n.kind === 'person',
|
|
383
486
|
).length;
|
|
@@ -389,6 +492,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
389
492
|
nodes={displayNodes}
|
|
390
493
|
edges={rfEdges}
|
|
391
494
|
nodeTypes={nodeTypes}
|
|
495
|
+
edgeTypes={edgeTypes}
|
|
392
496
|
className={GRAPH_CANVAS_CLASS}
|
|
393
497
|
{...GRAPH_NAV_PROPS}
|
|
394
498
|
onNodeClick={(_e, node) => {
|
|
@@ -402,11 +506,15 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
402
506
|
onSelectNode?.(null);
|
|
403
507
|
}}
|
|
404
508
|
proOptions={{ hideAttribution: true }}
|
|
509
|
+
// Pin the flow to its container. The label overlay is a sibling sized
|
|
510
|
+
// `inset: 0`, so the two must share an origin or `viewport.y` (measured
|
|
511
|
+
// in the flow's own box) lands the chips off vertically.
|
|
512
|
+
style={{ width: '100%', height: '100%' }}
|
|
405
513
|
>
|
|
406
514
|
<GraphChrome />
|
|
407
515
|
</ReactFlow>
|
|
408
516
|
{ready && labelPositions.size > 0 && (
|
|
409
|
-
<C4EdgeLabels positions={labelPositions} edges={model.edges}
|
|
517
|
+
<C4EdgeLabels positions={labelPositions} edges={model.edges} />
|
|
410
518
|
)}
|
|
411
519
|
{!ready && (
|
|
412
520
|
<div
|
|
@@ -462,7 +570,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
462
570
|
}}
|
|
463
571
|
>
|
|
464
572
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
465
|
-
<span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
573
|
+
<span style={{ fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
466
574
|
{selected.label}
|
|
467
575
|
</span>
|
|
468
576
|
<button
|
|
@@ -477,13 +585,30 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
477
585
|
×
|
|
478
586
|
</button>
|
|
479
587
|
</div>
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
588
|
+
{/* The sub-line carries the technology — the fact worth reading at a
|
|
589
|
+
glance. Counts are omitted: the list below is already the count,
|
|
590
|
+
and kind/state are on the box itself. A person has no technology,
|
|
591
|
+
so its sub-line is blank. */}
|
|
592
|
+
{selected.kind !== 'person' &&
|
|
593
|
+
(() => {
|
|
594
|
+
const technology = (selected as { technology?: string }).technology;
|
|
595
|
+
return (
|
|
596
|
+
<div
|
|
597
|
+
style={{
|
|
598
|
+
fontFamily: theme.fonts.monospace,
|
|
599
|
+
fontSize: theme.fontSizes[0],
|
|
600
|
+
color: technology ? muted : (theme.colors.warning ?? '#e8a33a'),
|
|
601
|
+
margin: '4px 0 8px',
|
|
602
|
+
}}
|
|
603
|
+
>
|
|
604
|
+
{technology ?? 'technology: not stated'}
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
})()}
|
|
484
608
|
|
|
485
|
-
{/*
|
|
486
|
-
|
|
609
|
+
{/* The container's own description already shows on the card, so the
|
|
610
|
+
panel only adds what the card omits: the claimed process. */}
|
|
611
|
+
{selected.kind === 'container' && selected.process && (
|
|
487
612
|
<div
|
|
488
613
|
style={{
|
|
489
614
|
fontFamily: theme.fonts.monospace,
|
|
@@ -492,52 +617,124 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
492
617
|
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
493
618
|
paddingTop: 6,
|
|
494
619
|
marginBottom: 8,
|
|
620
|
+
whiteSpace: 'normal',
|
|
621
|
+
lineHeight: 1.4,
|
|
622
|
+
}}
|
|
623
|
+
>
|
|
624
|
+
verifies process: {selected.process}
|
|
625
|
+
</div>
|
|
626
|
+
)}
|
|
627
|
+
|
|
628
|
+
{/* Components authored inside this container, listed so the container
|
|
629
|
+
can be inspected without zooming the diagram into them. Inert for
|
|
630
|
+
now — the drill-down hung off them comes later. */}
|
|
631
|
+
{selected.kind === 'container' && selectedComponents.length > 0 && (
|
|
632
|
+
<div
|
|
633
|
+
style={{
|
|
495
634
|
display: 'flex',
|
|
496
635
|
flexDirection: 'column',
|
|
497
|
-
gap:
|
|
636
|
+
gap: 6,
|
|
637
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
638
|
+
paddingTop: 8,
|
|
498
639
|
}}
|
|
499
640
|
>
|
|
500
|
-
<span
|
|
501
|
-
{
|
|
502
|
-
|
|
641
|
+
<span
|
|
642
|
+
style={{
|
|
643
|
+
fontFamily: theme.fonts.monospace,
|
|
644
|
+
fontSize: theme.fontSizes[0],
|
|
645
|
+
color: muted,
|
|
646
|
+
textTransform: 'uppercase',
|
|
647
|
+
letterSpacing: 0.5,
|
|
648
|
+
}}
|
|
649
|
+
>
|
|
650
|
+
Components
|
|
503
651
|
</span>
|
|
504
|
-
{
|
|
505
|
-
<
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
652
|
+
{selectedComponents.map((c) => (
|
|
653
|
+
<div key={c.id} style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
654
|
+
<span
|
|
655
|
+
title={c.description ?? c.label}
|
|
656
|
+
style={{
|
|
657
|
+
fontFamily: theme.fonts.body,
|
|
658
|
+
color: theme.colors.text,
|
|
659
|
+
overflow: 'hidden',
|
|
660
|
+
textOverflow: 'ellipsis',
|
|
661
|
+
whiteSpace: 'nowrap',
|
|
662
|
+
}}
|
|
663
|
+
>
|
|
664
|
+
{c.label}
|
|
665
|
+
</span>
|
|
666
|
+
<span
|
|
667
|
+
style={{
|
|
668
|
+
fontFamily: theme.fonts.monospace,
|
|
669
|
+
fontSize: theme.fontSizes[0],
|
|
670
|
+
color: muted,
|
|
671
|
+
overflow: 'hidden',
|
|
672
|
+
textOverflow: 'ellipsis',
|
|
673
|
+
whiteSpace: 'nowrap',
|
|
674
|
+
}}
|
|
675
|
+
>
|
|
676
|
+
{'technology' in c && c.technology ? c.technology : c.kind}
|
|
677
|
+
{(c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''}
|
|
678
|
+
</span>
|
|
679
|
+
</div>
|
|
680
|
+
))}
|
|
520
681
|
</div>
|
|
521
682
|
)}
|
|
522
683
|
|
|
523
|
-
|
|
524
|
-
|
|
684
|
+
{/* No components authored: fall back to the raw member aliases, or
|
|
685
|
+
say so plainly when there is nothing inside at all. Same divider +
|
|
686
|
+
header as the authored list, so the panel sections consistently. */}
|
|
687
|
+
{selected.kind === 'container' && selectedComponents.length === 0 && (
|
|
688
|
+
<div
|
|
689
|
+
style={{
|
|
690
|
+
display: 'flex',
|
|
691
|
+
flexDirection: 'column',
|
|
692
|
+
gap: 6,
|
|
693
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
694
|
+
paddingTop: 8,
|
|
695
|
+
}}
|
|
696
|
+
>
|
|
525
697
|
<span
|
|
526
|
-
key={alias}
|
|
527
|
-
title={alias}
|
|
528
698
|
style={{
|
|
529
699
|
fontFamily: theme.fonts.monospace,
|
|
530
700
|
fontSize: theme.fontSizes[0],
|
|
531
|
-
color:
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
whiteSpace: 'nowrap',
|
|
701
|
+
color: muted,
|
|
702
|
+
textTransform: 'uppercase',
|
|
703
|
+
letterSpacing: 0.5,
|
|
535
704
|
}}
|
|
536
705
|
>
|
|
537
|
-
|
|
706
|
+
Components
|
|
538
707
|
</span>
|
|
539
|
-
|
|
540
|
-
|
|
708
|
+
{(selected.members ?? []).length > 0 ? (
|
|
709
|
+
(selected.members ?? []).map((alias) => (
|
|
710
|
+
<span
|
|
711
|
+
key={alias}
|
|
712
|
+
title={alias}
|
|
713
|
+
style={{
|
|
714
|
+
fontFamily: theme.fonts.monospace,
|
|
715
|
+
fontSize: theme.fontSizes[0],
|
|
716
|
+
color: theme.colors.text,
|
|
717
|
+
overflow: 'hidden',
|
|
718
|
+
textOverflow: 'ellipsis',
|
|
719
|
+
whiteSpace: 'nowrap',
|
|
720
|
+
}}
|
|
721
|
+
>
|
|
722
|
+
{alias}
|
|
723
|
+
</span>
|
|
724
|
+
))
|
|
725
|
+
) : (
|
|
726
|
+
<span
|
|
727
|
+
style={{
|
|
728
|
+
fontFamily: theme.fonts.monospace,
|
|
729
|
+
fontSize: theme.fontSizes[0],
|
|
730
|
+
color: muted,
|
|
731
|
+
}}
|
|
732
|
+
>
|
|
733
|
+
No components
|
|
734
|
+
</span>
|
|
735
|
+
)}
|
|
736
|
+
</div>
|
|
737
|
+
)}
|
|
541
738
|
</div>
|
|
542
739
|
)}
|
|
543
740
|
</div>
|