@principal-ai/subsystems-react 0.51.4 → 0.53.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 +193 -47
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +33 -27
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +89 -43
- 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 +42 -19
- 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 +35 -245
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +11 -29
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.js +5 -5
- 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 +15 -1
- package/dist/utils/edgeLabel.d.ts.map +1 -1
- package/dist/utils/edgeLabel.js +25 -1
- package/dist/utils/edgeLabel.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +47 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +71 -7
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -3
- 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/subsystem/C4Graph.tsx +278 -82
- package/src/subsystem/C4NodeCard.test.ts +32 -22
- package/src/subsystem/C4NodeCard.tsx +134 -69
- package/src/subsystem/IssueList.tsx +23 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +38 -16
- 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 +62 -305
- package/src/subsystem/nodes.group.test.tsx +15 -0
- package/src/subsystem/nodes.tsx +7 -7
- package/src/subsystem/techIcons/index.tsx +8 -0
- package/src/utils/edgeLabel.test.ts +21 -2
- package/src/utils/edgeLabel.ts +29 -1
- package/src/utils/elkLayout.test.ts +16 -0
- package/src/utils/elkLayout.ts +119 -7
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
|
@@ -26,41 +26,36 @@ import {
|
|
|
26
26
|
useViewport,
|
|
27
27
|
} from '@xyflow/react';
|
|
28
28
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
29
|
+
import { Box, LayoutGrid } from 'lucide-react';
|
|
29
30
|
import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
30
|
-
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT,
|
|
31
|
-
import {
|
|
31
|
+
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
|
|
32
|
+
import { protocolColor } from './c4';
|
|
32
33
|
import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
|
|
33
34
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
34
|
-
import type {
|
|
35
|
+
import type { C4Element, C4Model } from './c4';
|
|
35
36
|
|
|
36
37
|
export interface C4GraphProps {
|
|
37
38
|
model: C4Model;
|
|
38
39
|
title?: string;
|
|
39
40
|
onSelectNode?: (id: string | null) => void;
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* Above the default 28 the `needs …` gap row was removed, so cards are
|
|
46
|
-
* shorter and packing them tighter than the spacing they were tuned at makes
|
|
47
|
-
* them collide. Raise it for a more airy graph.
|
|
42
|
+
* Breathing room around each card, in flow px, applied as ELK spacing (not by
|
|
43
|
+
* inflating the node box — that left the edge lines starting short of the
|
|
44
|
+
* card border). `nodeSpacing` is `2 × gutter`; `edgeNodeSpacing` is `gutter`.
|
|
48
45
|
* @default 28
|
|
49
46
|
*/
|
|
50
47
|
gutter?: number;
|
|
51
48
|
}
|
|
52
49
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
|
|
59
|
-
const { node, selected } = props.data;
|
|
50
|
+
function C4NodeView(
|
|
51
|
+
props: NodeProps<Node<{ element: C4Element; selected: boolean; componentCount?: number }>>,
|
|
52
|
+
) {
|
|
53
|
+
const { element, selected, componentCount } = props.data;
|
|
60
54
|
return (
|
|
61
55
|
<C4NodeCard
|
|
62
|
-
node={
|
|
56
|
+
node={element}
|
|
63
57
|
selected={selected}
|
|
58
|
+
componentCount={componentCount ?? 0}
|
|
64
59
|
handles={
|
|
65
60
|
<>
|
|
66
61
|
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
@@ -82,11 +77,9 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
|
|
|
82
77
|
function C4EdgeLabels({
|
|
83
78
|
positions,
|
|
84
79
|
edges,
|
|
85
|
-
muted,
|
|
86
80
|
}: {
|
|
87
81
|
positions: Map<string, { x: number; y: number }>;
|
|
88
82
|
edges: C4Model['edges'];
|
|
89
|
-
muted: string;
|
|
90
83
|
}) {
|
|
91
84
|
const { theme } = useTheme();
|
|
92
85
|
const viewport = useViewport();
|
|
@@ -106,7 +99,7 @@ function C4EdgeLabels({
|
|
|
106
99
|
{[...positions].map(([id, p]) => {
|
|
107
100
|
const edge = byId.get(id);
|
|
108
101
|
if (!edge) return null;
|
|
109
|
-
const color =
|
|
102
|
+
const color = protocolColor(edge.protocol);
|
|
110
103
|
const text = edge.protocol ?? edge.label;
|
|
111
104
|
return (
|
|
112
105
|
<div
|
|
@@ -120,7 +113,7 @@ function C4EdgeLabels({
|
|
|
120
113
|
display: 'flex',
|
|
121
114
|
alignItems: 'center',
|
|
122
115
|
justifyContent: 'center',
|
|
123
|
-
width:
|
|
116
|
+
width: estimateEdgeLabelWidth(text),
|
|
124
117
|
height: EDGE_LABEL_HEIGHT,
|
|
125
118
|
boxSizing: 'border-box',
|
|
126
119
|
padding: '7px 8px',
|
|
@@ -186,6 +179,10 @@ function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?:
|
|
|
186
179
|
function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
|
|
187
180
|
const { theme } = useTheme();
|
|
188
181
|
const color = props.data.color;
|
|
182
|
+
const isSystem = props.data.kind === 'system';
|
|
183
|
+
// A system frame is the box we draw; a container frame groups components
|
|
184
|
+
// inside one. Boxes for each, so the badge says which level you are looking at.
|
|
185
|
+
const FrameIcon = isSystem ? Box : LayoutGrid;
|
|
189
186
|
return (
|
|
190
187
|
<div
|
|
191
188
|
style={{
|
|
@@ -205,6 +202,9 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
|
|
|
205
202
|
transform: 'translateY(-50%)',
|
|
206
203
|
left: 12,
|
|
207
204
|
zIndex: 1,
|
|
205
|
+
display: 'inline-flex',
|
|
206
|
+
alignItems: 'center',
|
|
207
|
+
gap: 6,
|
|
208
208
|
fontFamily: theme.fonts.monospace,
|
|
209
209
|
fontSize: theme.fontSizes[2],
|
|
210
210
|
fontWeight: 600,
|
|
@@ -221,6 +221,7 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
|
|
|
221
221
|
whiteSpace: 'nowrap',
|
|
222
222
|
}}
|
|
223
223
|
>
|
|
224
|
+
<FrameIcon size={13} strokeWidth={2.25} aria-hidden />
|
|
224
225
|
{props.data.label}
|
|
225
226
|
</span>
|
|
226
227
|
</div>
|
|
@@ -263,12 +264,26 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
263
264
|
setReady(false);
|
|
264
265
|
setSelectedId(null);
|
|
265
266
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
267
|
+
// Draw only the elements for this view. A container diagram shows the
|
|
268
|
+
// runtime units (containers, externals, people); components belong to the
|
|
269
|
+
// component view. Components are still carried on the model (see the side
|
|
270
|
+
// panel), just not drawn here.
|
|
271
|
+
const inView = (n: C4Element): boolean =>
|
|
272
|
+
model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
|
|
273
|
+
|
|
274
|
+
// How many components each container groups — computed once here because
|
|
275
|
+
// the card only receives its own element, not the model.
|
|
276
|
+
const childComponentCount = new Map<string, number>();
|
|
277
|
+
for (const n of model.nodes) {
|
|
278
|
+
if (n.kind !== 'component') continue;
|
|
279
|
+
const parent = n.container ?? n.parentId;
|
|
280
|
+
if (parent) childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Layout against the true card size, so ELK's ports sit on the card's
|
|
284
|
+
// border — an inflated box leaves the line starting short of the card edge.
|
|
285
|
+
// The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
|
|
286
|
+
const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
|
|
272
287
|
const size = nodeSize(n);
|
|
273
288
|
return {
|
|
274
289
|
id: n.id,
|
|
@@ -276,10 +291,10 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
276
291
|
// Fallback grid pitch uses the container box — the largest, so the
|
|
277
292
|
// pre-layout positions never overlap. ELK replaces them on success.
|
|
278
293
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
279
|
-
width: size.width
|
|
280
|
-
height: size.height
|
|
294
|
+
width: size.width,
|
|
295
|
+
height: size.height,
|
|
281
296
|
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
282
|
-
data: {
|
|
297
|
+
data: { element: n, selected: false, componentCount: childComponentCount.get(n.id) ?? 0 },
|
|
283
298
|
};
|
|
284
299
|
});
|
|
285
300
|
|
|
@@ -289,9 +304,32 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
289
304
|
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
290
305
|
}));
|
|
291
306
|
|
|
307
|
+
// Band nodes so a flat layout still reads as C4 and frames can't interleave.
|
|
308
|
+
//
|
|
309
|
+
// Flat mode has no ELK parents, so without bands ELK mixes members of
|
|
310
|
+
// different frames across the whole graph — their synthesized bounding
|
|
311
|
+
// boxes then overlap. A partition per frame keeps each frame's members
|
|
312
|
+
// contiguous; people lead, externals trail.
|
|
313
|
+
//
|
|
314
|
+
// Order (ascending = earlier in the layout direction): people, then each
|
|
315
|
+
// frame in model order, then external systems.
|
|
316
|
+
const frameIds = model.groups.map((g) => g.id);
|
|
317
|
+
const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
|
|
318
|
+
const partitionByNode = new Map<string, number>(
|
|
319
|
+
model.nodes.map((n) => {
|
|
320
|
+
if (n.kind === 'person') return [n.id, 0];
|
|
321
|
+
if (n.kind === 'external-system') return [n.id, frameIds.length + 1];
|
|
322
|
+
// A node inside a frame shares its frame's band; a frame-less node
|
|
323
|
+
// (a container at container view) gets its own by id order.
|
|
324
|
+
const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
|
|
325
|
+
return [n.id, 1 + band];
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
|
|
292
329
|
const rfEdges: Edge[] = model.edges.map((e) => {
|
|
293
|
-
//
|
|
294
|
-
|
|
330
|
+
// Colour keys off the protocol, not the trail verb: on a container
|
|
331
|
+
// diagram the transport is what a reader distinguishes at a glance.
|
|
332
|
+
const color = protocolColor(e.protocol);
|
|
295
333
|
return {
|
|
296
334
|
id: e.id,
|
|
297
335
|
type: 'c4-edge',
|
|
@@ -311,7 +349,22 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
311
349
|
preserveNodePositions: false,
|
|
312
350
|
groups,
|
|
313
351
|
keepSingletonGroups: true,
|
|
314
|
-
edgeLabels: { enabled: true, placement: 'CENTER' },
|
|
352
|
+
edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
|
|
353
|
+
// The gutter that used to inflate each node box is now real spacing, so
|
|
354
|
+
// the ports still sit on the card edge.
|
|
355
|
+
nodeSpacing: gutter * 2,
|
|
356
|
+
edgeNodeSpacing: gutter,
|
|
357
|
+
// Between-layer/between-partition gap. This is what keeps the external
|
|
358
|
+
// band clear of the system frame's padding — ELK gives partitions zero
|
|
359
|
+
// default separation. It stacks on top of ELK's reserved label layer, so
|
|
360
|
+
// it is kept modest.
|
|
361
|
+
interLayerSpacing: 40,
|
|
362
|
+
// Draw the system frame as a decoration around the boxes rather than an
|
|
363
|
+
// ELK parent. Nesting made in-frame edges inherit the frame's padding and
|
|
364
|
+
// spacing, so they ran longer than a root-level edge touching the same
|
|
365
|
+
// nodes; flattening gives every edge one spacing.
|
|
366
|
+
flatGroups: true,
|
|
367
|
+
partitionByNode,
|
|
315
368
|
})
|
|
316
369
|
.then((result) => {
|
|
317
370
|
if (!alive) return;
|
|
@@ -341,15 +394,36 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
341
394
|
return d;
|
|
342
395
|
};
|
|
343
396
|
|
|
397
|
+
// Shell positions from flat synthesis are absolute. React Flow reads a
|
|
398
|
+
// child's `position` as relative to its parent, so a shell nested in
|
|
399
|
+
// another shell must be made relative to it.
|
|
400
|
+
const shellAbs = new Map(result.groupBounds);
|
|
401
|
+
const shellOrigin = (id: string | undefined): { x: number; y: number } => {
|
|
402
|
+
if (!id) return { x: 0, y: 0 };
|
|
403
|
+
const b = shellAbs.get(id);
|
|
404
|
+
return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
|
|
405
|
+
};
|
|
406
|
+
|
|
344
407
|
const shells: Node[] = [...result.groupBounds.entries()]
|
|
345
408
|
.sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
|
|
346
409
|
.map(([id, b]) => {
|
|
347
410
|
const def = groupById.get(id);
|
|
348
411
|
const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
|
|
412
|
+
const origin = shellOrigin(parentId);
|
|
413
|
+
// A container frame borrows its container node's border colour, so a
|
|
414
|
+
// frame and the box it wraps read as the same thing. Falls back to
|
|
415
|
+
// the accent when no element matches (e.g. a hand-authored group id).
|
|
416
|
+
const element = model.nodes.find((n) => n.id === id);
|
|
417
|
+
const frameColor =
|
|
418
|
+
def?.kind === 'system'
|
|
419
|
+
? (theme.colors.text ?? '#888')
|
|
420
|
+
: element
|
|
421
|
+
? nodeStyle(element, theme).color
|
|
422
|
+
: (theme.colors.accent ?? theme.colors.info);
|
|
349
423
|
return {
|
|
350
424
|
id,
|
|
351
425
|
type: 'c4-group',
|
|
352
|
-
position: { x: b.x, y: b.y },
|
|
426
|
+
position: { x: b.x - origin.x, y: b.y - origin.y },
|
|
353
427
|
width: Math.max(200, b.width),
|
|
354
428
|
height: Math.max(120, b.height),
|
|
355
429
|
draggable: false,
|
|
@@ -358,20 +432,26 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
358
432
|
data: {
|
|
359
433
|
label: def?.label ?? id,
|
|
360
434
|
kind: def?.kind ?? 'container',
|
|
361
|
-
color:
|
|
435
|
+
color: frameColor,
|
|
362
436
|
},
|
|
363
437
|
};
|
|
364
438
|
});
|
|
365
439
|
|
|
366
440
|
const leaves = rfNodes.map((n) => {
|
|
367
441
|
const p = positioned.get(n.id);
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
if
|
|
371
|
-
|
|
372
|
-
|
|
442
|
+
// Absolute flow position from the flat layout.
|
|
443
|
+
const abs = p ? p.position : n.position;
|
|
444
|
+
// Re-parent to the shell this node sits in, if any: a component's
|
|
445
|
+
// container, or a container's system frame. React Flow positions a
|
|
446
|
+
// child relative to its parent, so subtract the shell origin.
|
|
447
|
+
let parentId: string | undefined;
|
|
448
|
+
if (n.data && typeof n.data === 'object') {
|
|
449
|
+
const modelNode = (n.data as { element?: C4Element }).element;
|
|
450
|
+
if (modelNode?.parentId && builtGroups.has(modelNode.parentId)) parentId = modelNode.parentId;
|
|
373
451
|
}
|
|
374
|
-
|
|
452
|
+
const origin = shellOrigin(parentId);
|
|
453
|
+
const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
|
|
454
|
+
return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
|
|
375
455
|
});
|
|
376
456
|
|
|
377
457
|
setNodes([...shells, ...leaves]);
|
|
@@ -399,6 +479,24 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
399
479
|
[model, selectedId],
|
|
400
480
|
);
|
|
401
481
|
|
|
482
|
+
/**
|
|
483
|
+
* The components authored inside the selected element, if it is a container.
|
|
484
|
+
* These are `C4Component`s whose `container` names the selected id (or, once
|
|
485
|
+
* flattened, whose `parentId` does). Shown in the panel so a container can be
|
|
486
|
+
* inspected without zooming the diagram into its components.
|
|
487
|
+
*/
|
|
488
|
+
const selectedComponents = useMemo(
|
|
489
|
+
() =>
|
|
490
|
+
selected
|
|
491
|
+
? model.nodes.filter(
|
|
492
|
+
(n) =>
|
|
493
|
+
n.kind === 'component' &&
|
|
494
|
+
(n.container === selected.id || n.parentId === selected.id),
|
|
495
|
+
)
|
|
496
|
+
: [],
|
|
497
|
+
[model, selected],
|
|
498
|
+
);
|
|
499
|
+
|
|
402
500
|
const displayNodes = useMemo(
|
|
403
501
|
() =>
|
|
404
502
|
nodes.map((n) =>
|
|
@@ -409,7 +507,11 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
409
507
|
[nodes, selectedId],
|
|
410
508
|
);
|
|
411
509
|
|
|
412
|
-
|
|
510
|
+
// What the current view draws: runtime units at container level, components
|
|
511
|
+
// at component level. Mirrors the `inView` filter used for the nodes.
|
|
512
|
+
const runtime = model.nodes.filter((n) =>
|
|
513
|
+
model.view === 'component' ? n.kind === 'component' : n.kind === 'container',
|
|
514
|
+
).length;
|
|
413
515
|
const outside = model.nodes.filter(
|
|
414
516
|
(n) => n.kind === 'external-system' || n.kind === 'person',
|
|
415
517
|
).length;
|
|
@@ -443,7 +545,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
443
545
|
<GraphChrome />
|
|
444
546
|
</ReactFlow>
|
|
445
547
|
{ready && labelPositions.size > 0 && (
|
|
446
|
-
<C4EdgeLabels positions={labelPositions} edges={model.edges}
|
|
548
|
+
<C4EdgeLabels positions={labelPositions} edges={model.edges} />
|
|
447
549
|
)}
|
|
448
550
|
{!ready && (
|
|
449
551
|
<div
|
|
@@ -468,6 +570,8 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
468
570
|
position: 'absolute',
|
|
469
571
|
top: 12,
|
|
470
572
|
left: 12,
|
|
573
|
+
// Above the edge-label overlay (zIndex 5).
|
|
574
|
+
zIndex: 10,
|
|
471
575
|
fontSize: theme.fontSizes[2],
|
|
472
576
|
fontWeight: 600,
|
|
473
577
|
color: theme.colors.text,
|
|
@@ -489,6 +593,9 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
489
593
|
position: 'absolute',
|
|
490
594
|
top: 12,
|
|
491
595
|
right: 12,
|
|
596
|
+
// Above the edge-label overlay (zIndex 5), or chips paint over the
|
|
597
|
+
// panel.
|
|
598
|
+
zIndex: 10,
|
|
492
599
|
width: 280,
|
|
493
600
|
maxHeight: 'calc(100% - 24px)',
|
|
494
601
|
overflowY: 'auto',
|
|
@@ -499,7 +606,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
499
606
|
}}
|
|
500
607
|
>
|
|
501
608
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
502
|
-
<span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
609
|
+
<span style={{ fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
503
610
|
{selected.label}
|
|
504
611
|
</span>
|
|
505
612
|
<button
|
|
@@ -514,13 +621,30 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
514
621
|
×
|
|
515
622
|
</button>
|
|
516
623
|
</div>
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
624
|
+
{/* The sub-line carries the technology — the fact worth reading at a
|
|
625
|
+
glance. Counts are omitted: the list below is already the count,
|
|
626
|
+
and kind/state are on the box itself. A person has no technology,
|
|
627
|
+
so its sub-line is blank. */}
|
|
628
|
+
{selected.kind !== 'person' &&
|
|
629
|
+
(() => {
|
|
630
|
+
const technology = (selected as { technology?: string }).technology;
|
|
631
|
+
return (
|
|
632
|
+
<div
|
|
633
|
+
style={{
|
|
634
|
+
fontFamily: theme.fonts.monospace,
|
|
635
|
+
fontSize: theme.fontSizes[0],
|
|
636
|
+
color: technology ? muted : (theme.colors.warning ?? '#e8a33a'),
|
|
637
|
+
margin: '4px 0 8px',
|
|
638
|
+
}}
|
|
639
|
+
>
|
|
640
|
+
{technology ?? 'technology: not stated'}
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
})()}
|
|
521
644
|
|
|
522
|
-
{/*
|
|
523
|
-
|
|
645
|
+
{/* The container's own description already shows on the card, so the
|
|
646
|
+
panel only adds what the card omits: the claimed process. */}
|
|
647
|
+
{selected.kind === 'container' && selected.process && (
|
|
524
648
|
<div
|
|
525
649
|
style={{
|
|
526
650
|
fontFamily: theme.fonts.monospace,
|
|
@@ -529,52 +653,124 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
529
653
|
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
530
654
|
paddingTop: 6,
|
|
531
655
|
marginBottom: 8,
|
|
656
|
+
whiteSpace: 'normal',
|
|
657
|
+
lineHeight: 1.4,
|
|
658
|
+
}}
|
|
659
|
+
>
|
|
660
|
+
verifies process: {selected.process}
|
|
661
|
+
</div>
|
|
662
|
+
)}
|
|
663
|
+
|
|
664
|
+
{/* Components authored inside this container, listed so the container
|
|
665
|
+
can be inspected without zooming the diagram into them. Inert for
|
|
666
|
+
now — the drill-down hung off them comes later. */}
|
|
667
|
+
{selected.kind === 'container' && selectedComponents.length > 0 && (
|
|
668
|
+
<div
|
|
669
|
+
style={{
|
|
532
670
|
display: 'flex',
|
|
533
671
|
flexDirection: 'column',
|
|
534
|
-
gap:
|
|
672
|
+
gap: 6,
|
|
673
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
674
|
+
paddingTop: 8,
|
|
535
675
|
}}
|
|
536
676
|
>
|
|
537
|
-
<span
|
|
538
|
-
{
|
|
539
|
-
|
|
677
|
+
<span
|
|
678
|
+
style={{
|
|
679
|
+
fontFamily: theme.fonts.monospace,
|
|
680
|
+
fontSize: theme.fontSizes[0],
|
|
681
|
+
color: muted,
|
|
682
|
+
textTransform: 'uppercase',
|
|
683
|
+
letterSpacing: 0.5,
|
|
684
|
+
}}
|
|
685
|
+
>
|
|
686
|
+
Components
|
|
540
687
|
</span>
|
|
541
|
-
{
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
688
|
+
{selectedComponents.map((c) => (
|
|
689
|
+
<div key={c.id} style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
690
|
+
<span
|
|
691
|
+
title={c.description ?? c.label}
|
|
692
|
+
style={{
|
|
693
|
+
fontFamily: theme.fonts.body,
|
|
694
|
+
color: theme.colors.text,
|
|
695
|
+
overflow: 'hidden',
|
|
696
|
+
textOverflow: 'ellipsis',
|
|
697
|
+
whiteSpace: 'nowrap',
|
|
698
|
+
}}
|
|
699
|
+
>
|
|
700
|
+
{c.label}
|
|
701
|
+
</span>
|
|
702
|
+
<span
|
|
703
|
+
style={{
|
|
704
|
+
fontFamily: theme.fonts.monospace,
|
|
705
|
+
fontSize: theme.fontSizes[0],
|
|
706
|
+
color: muted,
|
|
707
|
+
overflow: 'hidden',
|
|
708
|
+
textOverflow: 'ellipsis',
|
|
709
|
+
whiteSpace: 'nowrap',
|
|
710
|
+
}}
|
|
711
|
+
>
|
|
712
|
+
{'technology' in c && c.technology ? c.technology : c.kind}
|
|
713
|
+
{(c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''}
|
|
714
|
+
</span>
|
|
715
|
+
</div>
|
|
716
|
+
))}
|
|
557
717
|
</div>
|
|
558
718
|
)}
|
|
559
719
|
|
|
560
|
-
|
|
561
|
-
|
|
720
|
+
{/* No components authored: fall back to the raw member aliases, or
|
|
721
|
+
say so plainly when there is nothing inside at all. Same divider +
|
|
722
|
+
header as the authored list, so the panel sections consistently. */}
|
|
723
|
+
{selected.kind === 'container' && selectedComponents.length === 0 && (
|
|
724
|
+
<div
|
|
725
|
+
style={{
|
|
726
|
+
display: 'flex',
|
|
727
|
+
flexDirection: 'column',
|
|
728
|
+
gap: 6,
|
|
729
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
730
|
+
paddingTop: 8,
|
|
731
|
+
}}
|
|
732
|
+
>
|
|
562
733
|
<span
|
|
563
|
-
key={alias}
|
|
564
|
-
title={alias}
|
|
565
734
|
style={{
|
|
566
735
|
fontFamily: theme.fonts.monospace,
|
|
567
736
|
fontSize: theme.fontSizes[0],
|
|
568
|
-
color:
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
whiteSpace: 'nowrap',
|
|
737
|
+
color: muted,
|
|
738
|
+
textTransform: 'uppercase',
|
|
739
|
+
letterSpacing: 0.5,
|
|
572
740
|
}}
|
|
573
741
|
>
|
|
574
|
-
|
|
742
|
+
Components
|
|
575
743
|
</span>
|
|
576
|
-
|
|
577
|
-
|
|
744
|
+
{(selected.members ?? []).length > 0 ? (
|
|
745
|
+
(selected.members ?? []).map((alias) => (
|
|
746
|
+
<span
|
|
747
|
+
key={alias}
|
|
748
|
+
title={alias}
|
|
749
|
+
style={{
|
|
750
|
+
fontFamily: theme.fonts.monospace,
|
|
751
|
+
fontSize: theme.fontSizes[0],
|
|
752
|
+
color: theme.colors.text,
|
|
753
|
+
overflow: 'hidden',
|
|
754
|
+
textOverflow: 'ellipsis',
|
|
755
|
+
whiteSpace: 'nowrap',
|
|
756
|
+
}}
|
|
757
|
+
>
|
|
758
|
+
{alias}
|
|
759
|
+
</span>
|
|
760
|
+
))
|
|
761
|
+
) : (
|
|
762
|
+
<span
|
|
763
|
+
style={{
|
|
764
|
+
fontFamily: theme.fonts.monospace,
|
|
765
|
+
fontSize: theme.fontSizes[0],
|
|
766
|
+
color: muted,
|
|
767
|
+
}}
|
|
768
|
+
>
|
|
769
|
+
No components
|
|
770
|
+
</span>
|
|
771
|
+
)}
|
|
772
|
+
</div>
|
|
773
|
+
)}
|
|
578
774
|
</div>
|
|
579
775
|
)}
|
|
580
776
|
</div>
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
nodeSize,
|
|
13
13
|
COMPONENT_SIZE,
|
|
14
14
|
} from './C4NodeCard';
|
|
15
|
-
import type {
|
|
15
|
+
import type { C4Element, C4Container } from './c4';
|
|
16
16
|
|
|
17
17
|
const theme = {
|
|
18
18
|
colors: {
|
|
@@ -25,12 +25,20 @@ const theme = {
|
|
|
25
25
|
} as Parameters<typeof nodeStyle>[1];
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* A
|
|
29
|
-
* element carries a decision — so the card never has to cope with a
|
|
30
|
-
* proposed, and no test pretends otherwise.
|
|
28
|
+
* A container element with overridable fields. `state` is required — every
|
|
29
|
+
* authored element carries a decision — so the card never has to cope with a
|
|
30
|
+
* box nobody proposed, and no test pretends otherwise.
|
|
31
31
|
*/
|
|
32
|
-
function node(over: Partial<
|
|
33
|
-
return {
|
|
32
|
+
function node(over: Partial<C4Container> = {}): C4Element {
|
|
33
|
+
return {
|
|
34
|
+
id: 'n',
|
|
35
|
+
kind: 'container',
|
|
36
|
+
label: 'n',
|
|
37
|
+
containerKind: 'application',
|
|
38
|
+
technology: '',
|
|
39
|
+
state: 'proposed',
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
describe('nodeStyle — one axis per channel', () => {
|
|
@@ -39,16 +47,12 @@ describe('nodeStyle — one axis per channel', () => {
|
|
|
39
47
|
expect(nodeStyle(node({ state: 'accepted' }), theme, true).dash).toBe('solid');
|
|
40
48
|
});
|
|
41
49
|
|
|
42
|
-
test('accepted is solid
|
|
43
|
-
|
|
44
|
-
expect(s.dash).toBe('solid');
|
|
45
|
-
expect(s.color).toBe('#4ec9b0');
|
|
50
|
+
test('accepted is solid', () => {
|
|
51
|
+
expect(nodeStyle(node({ state: 'accepted' }), theme).dash).toBe('solid');
|
|
46
52
|
});
|
|
47
53
|
|
|
48
|
-
test('proposed is dashed
|
|
49
|
-
|
|
50
|
-
expect(s.dash).toBe('dashed');
|
|
51
|
-
expect(s.color).toBe('#e8a33a');
|
|
54
|
+
test('proposed is dashed', () => {
|
|
55
|
+
expect(nodeStyle(node({ state: 'proposed' }), theme).dash).toBe('dashed');
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
test('rejected is dotted and muted', () => {
|
|
@@ -67,12 +71,18 @@ describe('nodeStyle — one axis per channel', () => {
|
|
|
67
71
|
}
|
|
68
72
|
});
|
|
69
73
|
|
|
70
|
-
test('
|
|
71
|
-
// Same state,
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
);
|
|
75
|
-
|
|
74
|
+
test('colour follows the technology, not the state', () => {
|
|
75
|
+
// Same kind and state, different tech: different hue.
|
|
76
|
+
const react = nodeStyle(node({ technology: 'React 19', state: 'accepted' }), theme).color;
|
|
77
|
+
const bun = nodeStyle(node({ technology: 'Bun', state: 'accepted' }), theme).color;
|
|
78
|
+
expect(react).not.toBe(bun);
|
|
79
|
+
// State does not move the colour.
|
|
80
|
+
expect(nodeStyle(node({ technology: 'React 19', state: 'proposed' }), theme).color).toBe(react);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an unknown technology falls back to the muted border', () => {
|
|
84
|
+
expect(nodeStyle(node({ technology: '', state: 'accepted' }), theme).color).toBe('#555');
|
|
85
|
+
expect(nodeStyle(node({ technology: 'COBOL', state: 'accepted' }), theme).color).toBe('#555');
|
|
76
86
|
});
|
|
77
87
|
|
|
78
88
|
test('border weight carries the level, not the state', () => {
|
|
@@ -165,10 +175,10 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
165
175
|
expect(nodeShape(node({ kind: 'component' })).kind).toBe('square');
|
|
166
176
|
});
|
|
167
177
|
|
|
168
|
-
test('an external system
|
|
178
|
+
test('an external system has no corner radius', () => {
|
|
169
179
|
const s = nodeShape(node({ kind: 'external-system' }));
|
|
170
180
|
expect(s.kind).toBe('rect');
|
|
171
|
-
expect(s.radius).
|
|
181
|
+
expect(s.radius).toBe(0);
|
|
172
182
|
});
|
|
173
183
|
|
|
174
184
|
test('a person is a pill', () => {
|