@principal-ai/subsystems-react 0.51.2 → 0.51.4
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 +115 -22
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +22 -22
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +56 -77
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +32 -13
- 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 +24 -86
- 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.js +1 -1
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/model.d.ts +14 -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 +4 -2
- package/dist/subsystem/nodes.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/edgeLabel.d.ts +1 -1
- package/dist/utils/edgeLabel.d.ts.map +1 -1
- package/dist/utils/edgeLabel.js +1 -1
- package/dist/utils/edgeLabel.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +18 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +90 -9
- 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 +20 -10
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
- package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
- package/src/subsystem/C4Graph.tsx +171 -19
- package/src/subsystem/C4NodeCard.test.ts +71 -77
- package/src/subsystem/C4NodeCard.tsx +65 -95
- package/src/subsystem/SubsystemComponentGraph.tsx +41 -13
- package/src/subsystem/TrailsPanel.tsx +10 -5
- package/src/subsystem/c4.ts +23 -357
- package/src/subsystem/c4Evidence.test.ts +1 -1
- package/src/subsystem/c4Evidence.ts +1 -1
- package/src/subsystem/model.ts +14 -0
- package/src/subsystem/nodes.tsx +5 -2
- 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/edgeLabel.ts +1 -1
- package/src/utils/elkLayout.test.ts +34 -0
- package/src/utils/elkLayout.ts +88 -9
- 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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import '@xyflow/react/dist/style.css';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
4
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
5
|
+
import { C4Graph } from '../../../subsystem/C4Graph';
|
|
6
|
+
import type { C4Edge, C4Model, C4Node } from '../../../subsystem/c4';
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: 'Subsystem/C4Graph/C4Container',
|
|
10
|
+
component: C4Graph,
|
|
11
|
+
parameters: { layout: 'fullscreen' },
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
decorators: [
|
|
14
|
+
(Story) => (
|
|
15
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
16
|
+
<Story />
|
|
17
|
+
</ThemeProvider>
|
|
18
|
+
),
|
|
19
|
+
],
|
|
20
|
+
} satisfies Meta<typeof C4Graph>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// A C4 container diagram, hand-built.
|
|
27
|
+
//
|
|
28
|
+
// Nothing here is derived: the boxes are `C4Node`s an author wrote down, and
|
|
29
|
+
// the lines are explicit `C4Edge`s — no trails, no rollup. This is the shape a
|
|
30
|
+
// caller hands to `C4Graph` once a model has been authored.
|
|
31
|
+
//
|
|
32
|
+
// The system frame holds the containers and the external systems (C4 draws a
|
|
33
|
+
// dependency we do not own inside the boundary); the person sits outside it.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const SYSTEM = { id: 'system:acme', label: 'Subsystem Modeling', repoKey: 'pkg:github/acme/subsystem-modeling' };
|
|
37
|
+
|
|
38
|
+
function container(over: Partial<C4Node> & Pick<C4Node, 'id' | 'label'>): C4Node {
|
|
39
|
+
return {
|
|
40
|
+
kind: 'container',
|
|
41
|
+
members: [],
|
|
42
|
+
constructs: [],
|
|
43
|
+
state: 'accepted',
|
|
44
|
+
containerKind: 'application',
|
|
45
|
+
...over,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const NODES: C4Node[] = [
|
|
50
|
+
container({
|
|
51
|
+
id: 'container:studio-host',
|
|
52
|
+
label: 'Subsystem Studio — host',
|
|
53
|
+
containerKind: 'application',
|
|
54
|
+
technology: 'Bun + Electrobun',
|
|
55
|
+
description: 'Owns the model store on disk and serves the renderer over RPC.',
|
|
56
|
+
parentId: SYSTEM.id,
|
|
57
|
+
}),
|
|
58
|
+
container({
|
|
59
|
+
id: 'container:studio-renderer',
|
|
60
|
+
label: 'Subsystem Studio — renderer',
|
|
61
|
+
containerKind: 'application',
|
|
62
|
+
technology: 'React 19',
|
|
63
|
+
description: 'Renders subsystem models and talks to the host over RPC.',
|
|
64
|
+
parentId: SYSTEM.id,
|
|
65
|
+
}),
|
|
66
|
+
container({
|
|
67
|
+
id: 'container:cli',
|
|
68
|
+
label: 'Subsystem Modeling CLI',
|
|
69
|
+
containerKind: 'application',
|
|
70
|
+
technology: 'Node',
|
|
71
|
+
description: 'Creates and updates models from the terminal.',
|
|
72
|
+
parentId: SYSTEM.id,
|
|
73
|
+
}),
|
|
74
|
+
container({
|
|
75
|
+
id: 'container:model-store',
|
|
76
|
+
label: 'Subsystem model store',
|
|
77
|
+
containerKind: 'data-store',
|
|
78
|
+
technology: 'JSON on disk',
|
|
79
|
+
description: 'Every stored model and the correction queue.',
|
|
80
|
+
parentId: SYSTEM.id,
|
|
81
|
+
}),
|
|
82
|
+
// An external system drawn inside the boundary — something we depend on.
|
|
83
|
+
{
|
|
84
|
+
kind: 'external-system',
|
|
85
|
+
id: 'external:opencode',
|
|
86
|
+
label: 'OpenCode v2 service',
|
|
87
|
+
technology: 'HTTP',
|
|
88
|
+
description: 'Runs the maintain agent sessions this model is edited by.',
|
|
89
|
+
state: 'accepted',
|
|
90
|
+
members: [],
|
|
91
|
+
constructs: [],
|
|
92
|
+
parentId: SYSTEM.id,
|
|
93
|
+
},
|
|
94
|
+
// A person sits outside every system boundary.
|
|
95
|
+
{
|
|
96
|
+
kind: 'person',
|
|
97
|
+
id: 'person:maintainer',
|
|
98
|
+
label: 'Maintain operator',
|
|
99
|
+
description: 'Reviews and accepts the correction proposals.',
|
|
100
|
+
state: 'accepted',
|
|
101
|
+
members: [],
|
|
102
|
+
constructs: [],
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const EDGES: C4Edge[] = [
|
|
107
|
+
{
|
|
108
|
+
id: 'flow:renderer-host',
|
|
109
|
+
source: 'container:studio-renderer',
|
|
110
|
+
target: 'container:studio-host',
|
|
111
|
+
label: 'RPC',
|
|
112
|
+
protocol: 'Electrobun RPC',
|
|
113
|
+
mechanisms: ['calls'],
|
|
114
|
+
count: 1,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'flow:host-store',
|
|
118
|
+
source: 'container:studio-host',
|
|
119
|
+
target: 'container:model-store',
|
|
120
|
+
label: 'fs · JSON',
|
|
121
|
+
protocol: 'file I/O',
|
|
122
|
+
mechanisms: ['reads', 'writes'],
|
|
123
|
+
count: 2,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'flow:host-opencode',
|
|
127
|
+
source: 'container:studio-host',
|
|
128
|
+
target: 'external:opencode',
|
|
129
|
+
label: 'HTTPS',
|
|
130
|
+
protocol: 'HTTP',
|
|
131
|
+
mechanisms: ['calls'],
|
|
132
|
+
count: 1,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'flow:cli-store',
|
|
136
|
+
source: 'container:cli',
|
|
137
|
+
target: 'container:model-store',
|
|
138
|
+
label: 'fs · JSON',
|
|
139
|
+
protocol: 'file I/O',
|
|
140
|
+
mechanisms: ['writes'],
|
|
141
|
+
count: 1,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'flow:maintainer-renderer',
|
|
145
|
+
source: 'person:maintainer',
|
|
146
|
+
target: 'container:studio-renderer',
|
|
147
|
+
label: 'UI',
|
|
148
|
+
mechanisms: ['uses'],
|
|
149
|
+
count: 1,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The container-view projection of the authored elements. From here on it is a
|
|
155
|
+
* plain render input: `C4Graph` sizes and lays these out, it does not build any
|
|
156
|
+
* of them.
|
|
157
|
+
*/
|
|
158
|
+
const containerModel: C4Model = {
|
|
159
|
+
view: 'container',
|
|
160
|
+
system: SYSTEM,
|
|
161
|
+
nodes: NODES,
|
|
162
|
+
groups: [
|
|
163
|
+
{
|
|
164
|
+
id: SYSTEM.id,
|
|
165
|
+
kind: 'system',
|
|
166
|
+
label: SYSTEM.label,
|
|
167
|
+
memberIds: NODES.filter((n) => n.parentId === SYSTEM.id).map((n) => n.id),
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
edges: EDGES,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function Demo() {
|
|
174
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
175
|
+
const model = useMemo(() => containerModel, []);
|
|
176
|
+
|
|
177
|
+
const containers = model.nodes.filter((n) => n.kind === 'container').length;
|
|
178
|
+
const outside = model.nodes.filter(
|
|
179
|
+
(n) => n.kind === 'external-system' || n.kind === 'person',
|
|
180
|
+
).length;
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
184
|
+
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
185
|
+
<C4Graph model={model} title="Subsystem Modeling" onSelectNode={setSelected} />
|
|
186
|
+
</div>
|
|
187
|
+
<div
|
|
188
|
+
style={{
|
|
189
|
+
padding: '6px 12px',
|
|
190
|
+
display: 'flex',
|
|
191
|
+
alignItems: 'center',
|
|
192
|
+
gap: 18,
|
|
193
|
+
fontFamily: 'monospace',
|
|
194
|
+
fontSize: 12,
|
|
195
|
+
color: '#aaa',
|
|
196
|
+
borderTop: '1px solid #333',
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<span>
|
|
200
|
+
{containers} containers · {outside} outside
|
|
201
|
+
</span>
|
|
202
|
+
<span>{model.edges.length} lines</span>
|
|
203
|
+
<span>selected: {selected ?? '(none)'}</span>
|
|
204
|
+
<span style={{ color: '#777' }}>boxes and lines are authored, not derived</span>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const Containers: Story = {
|
|
211
|
+
render: () => <Demo />,
|
|
212
|
+
};
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
nodeStyle,
|
|
7
7
|
nodeSubtitle,
|
|
8
8
|
nodeTag,
|
|
9
|
-
|
|
9
|
+
nodeTopLabel,
|
|
10
10
|
nodeShape,
|
|
11
11
|
nodeSize,
|
|
12
12
|
nodeMissing,
|
|
@@ -25,7 +25,7 @@ import type { C4Node } from '../../../subsystem/c4';
|
|
|
25
25
|
// is a plain presentational component, so it can be reviewed and diffed
|
|
26
26
|
// without a layout engine in the way.
|
|
27
27
|
//
|
|
28
|
-
// Every card here is a flattened `C4Node` —
|
|
28
|
+
// Every card here is a flattened `C4Node` — the one shape a C4 box draws with,
|
|
29
29
|
// produces from an authored element, with the per-kind fields already resolved
|
|
30
30
|
// to the one shape the card draws with.
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
@@ -334,7 +334,12 @@ function Gallery() {
|
|
|
334
334
|
|
|
335
335
|
<div style={{ fontFamily: 'monospace', fontSize: 11, marginBottom: 26, lineHeight: 1.9 }}>
|
|
336
336
|
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
|
337
|
-
<span style={{ color: '#e8e8ea' }}>KIND — shape
|
|
337
|
+
<span style={{ color: '#e8e8ea' }}>KIND — shape and border weight</span>
|
|
338
|
+
</div>
|
|
339
|
+
<div style={{ color: muted, marginTop: 2 }}>
|
|
340
|
+
Top-left is the technology’s brand mark — shown only when we hold the official SVG — and its
|
|
341
|
+
name. Top-right is a container’s C4 sort, application vs data-store. The line under the label
|
|
342
|
+
is the description.
|
|
338
343
|
</div>
|
|
339
344
|
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 4 }}>
|
|
340
345
|
{KINDS.map((kind) => {
|
|
@@ -385,13 +390,18 @@ function Gallery() {
|
|
|
385
390
|
</div>
|
|
386
391
|
)}
|
|
387
392
|
<Case
|
|
388
|
-
caption={
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
393
|
+
caption={[
|
|
394
|
+
v.node.id,
|
|
395
|
+
`"${nodeTag(v.node)}"`,
|
|
396
|
+
v.node.kind === 'container' ? v.node.containerKind : null,
|
|
397
|
+
v.node.state,
|
|
398
|
+
`${nodeShape(v.node).kind} ${nodeSize(v.node).width}×${nodeSize(v.node).height}`,
|
|
399
|
+
nodeTopLabel(v.node),
|
|
400
|
+
nodeSubtitle(v.node),
|
|
401
|
+
nodeMissing(v.node).length ? `missing ${nodeMissing(v.node).join('+')}` : null,
|
|
402
|
+
]
|
|
403
|
+
.filter(Boolean)
|
|
404
|
+
.join(' · ')}
|
|
395
405
|
>
|
|
396
406
|
<C4NodeCard node={v.node} />
|
|
397
407
|
</Case>
|
|
@@ -503,8 +503,10 @@ const MAINTAIN_ISSUES: SubsystemIssue[] = [
|
|
|
503
503
|
target: {
|
|
504
504
|
kind: 'step',
|
|
505
505
|
id: 'wt-wait-finish-event',
|
|
506
|
-
|
|
507
|
-
detail
|
|
506
|
+
// The host labels a step by its call-site symbol — the same row text
|
|
507
|
+
// the trail UI shows — with the trail title as the detail line.
|
|
508
|
+
label: 'matchFinishEvent',
|
|
509
|
+
detail: 'Wait for the finish event',
|
|
508
510
|
stepIndex: 6,
|
|
509
511
|
},
|
|
510
512
|
},
|
|
@@ -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
|
);
|
|
@@ -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.
|
|
@@ -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,
|
|
@@ -22,9 +23,11 @@ import {
|
|
|
22
23
|
ReactFlow,
|
|
23
24
|
ReactFlowProvider,
|
|
24
25
|
useReactFlow,
|
|
26
|
+
useViewport,
|
|
25
27
|
} from '@xyflow/react';
|
|
26
28
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
27
|
-
import { computeElkLayout } from '../utils/elkLayout';
|
|
29
|
+
import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
30
|
+
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
|
|
28
31
|
import { MECHANISM_COLOR } from './model';
|
|
29
32
|
import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
|
|
30
33
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
@@ -34,6 +37,17 @@ export interface C4GraphProps {
|
|
|
34
37
|
model: C4Model;
|
|
35
38
|
title?: string;
|
|
36
39
|
onSelectNode?: (id: string | null) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Extra width / height reserved around each card when laying out, in flow px.
|
|
42
|
+
* The visible gap between boxes is `2 × gutter` (ELK's `nodeNode` spacing,
|
|
43
|
+
* plus the frame padding where a frame adds one).
|
|
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.
|
|
48
|
+
* @default 28
|
|
49
|
+
*/
|
|
50
|
+
gutter?: number;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
function edgeColor(mechanism: string | undefined, fallback: string): string {
|
|
@@ -57,6 +71,118 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
|
|
|
57
71
|
);
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Edge-label overlay.
|
|
76
|
+
*
|
|
77
|
+
* Drawn as chips in flow space via `<ViewportPortal>` — React Flow applies the
|
|
78
|
+
* viewport transform, so the label chip needs no manual zoom/pan math and can
|
|
79
|
+
* never drift from the edges it sits on. Positions are ELK's computed label
|
|
80
|
+
* anchors (`edgeLabelPositions`), in flow coordinates.
|
|
81
|
+
*/
|
|
82
|
+
function C4EdgeLabels({
|
|
83
|
+
positions,
|
|
84
|
+
edges,
|
|
85
|
+
muted,
|
|
86
|
+
}: {
|
|
87
|
+
positions: Map<string, { x: number; y: number }>;
|
|
88
|
+
edges: C4Model['edges'];
|
|
89
|
+
muted: string;
|
|
90
|
+
}) {
|
|
91
|
+
const { theme } = useTheme();
|
|
92
|
+
const viewport = useViewport();
|
|
93
|
+
const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
94
|
+
const byId = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
style={{
|
|
99
|
+
position: 'absolute',
|
|
100
|
+
inset: 0,
|
|
101
|
+
overflow: 'hidden',
|
|
102
|
+
pointerEvents: 'none',
|
|
103
|
+
zIndex: 5,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{[...positions].map(([id, p]) => {
|
|
107
|
+
const edge = byId.get(id);
|
|
108
|
+
if (!edge) return null;
|
|
109
|
+
const color = edgeColor(edge.mechanisms[0], muted);
|
|
110
|
+
const text = edge.protocol ?? edge.label;
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
key={id}
|
|
114
|
+
style={{
|
|
115
|
+
position: 'absolute',
|
|
116
|
+
left: p.x * viewport.zoom + viewport.x,
|
|
117
|
+
top: p.y * viewport.zoom + viewport.y,
|
|
118
|
+
transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
|
|
119
|
+
transformOrigin: 'center center',
|
|
120
|
+
display: 'flex',
|
|
121
|
+
alignItems: 'center',
|
|
122
|
+
justifyContent: 'center',
|
|
123
|
+
width: EDGE_LABEL_WIDTH,
|
|
124
|
+
height: EDGE_LABEL_HEIGHT,
|
|
125
|
+
boxSizing: 'border-box',
|
|
126
|
+
padding: '7px 8px',
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<span
|
|
130
|
+
aria-hidden
|
|
131
|
+
style={{
|
|
132
|
+
position: 'absolute',
|
|
133
|
+
inset: 0,
|
|
134
|
+
borderRadius: 4,
|
|
135
|
+
border: `0.5px solid ${color}`,
|
|
136
|
+
background: surface,
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
<span
|
|
140
|
+
style={{
|
|
141
|
+
position: 'relative',
|
|
142
|
+
maxWidth: '100%',
|
|
143
|
+
overflow: 'hidden',
|
|
144
|
+
textOverflow: 'ellipsis',
|
|
145
|
+
whiteSpace: 'nowrap',
|
|
146
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
147
|
+
lineHeight: 1,
|
|
148
|
+
fontFamily: theme.fonts.monospace,
|
|
149
|
+
fontWeight: 500,
|
|
150
|
+
color,
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{text}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
})}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* C4 edge — draws ELK's routed polyline, not React Flow's default bezier.
|
|
164
|
+
*
|
|
165
|
+
* The line and the label chip must share one geometry: the chip is placed from
|
|
166
|
+
* the same `elkPathPoints` this renders. React Flow's built-in edge would curve
|
|
167
|
+
* between handles while the chip sat on ELK's orthogonal route, which is why
|
|
168
|
+
* labels read as "off the line".
|
|
169
|
+
*/
|
|
170
|
+
function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?: string }>>) {
|
|
171
|
+
const path = data?.path ?? '';
|
|
172
|
+
if (!path) return null;
|
|
173
|
+
return (
|
|
174
|
+
<path
|
|
175
|
+
d={path}
|
|
176
|
+
fill="none"
|
|
177
|
+
stroke={data?.color}
|
|
178
|
+
strokeWidth={1}
|
|
179
|
+
strokeDasharray="6 4"
|
|
180
|
+
markerEnd={markerEnd}
|
|
181
|
+
style={{ pointerEvents: 'none' }}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
60
186
|
function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
|
|
61
187
|
const { theme } = useTheme();
|
|
62
188
|
const color = props.data.color;
|
|
@@ -106,7 +232,11 @@ const nodeTypes = {
|
|
|
106
232
|
'c4-group': C4GroupView,
|
|
107
233
|
};
|
|
108
234
|
|
|
109
|
-
|
|
235
|
+
const edgeTypes = {
|
|
236
|
+
'c4-edge': C4EdgeView,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
110
240
|
const { theme } = useTheme();
|
|
111
241
|
const { fitView } = useReactFlow();
|
|
112
242
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -114,6 +244,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
114
244
|
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
115
245
|
const [ready, setReady] = useState(false);
|
|
116
246
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
247
|
+
const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
|
|
248
|
+
() => new Map(),
|
|
249
|
+
);
|
|
117
250
|
|
|
118
251
|
const layoutKey = useMemo(
|
|
119
252
|
() =>
|
|
@@ -130,10 +263,11 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
130
263
|
setReady(false);
|
|
131
264
|
setSelectedId(null);
|
|
132
265
|
|
|
133
|
-
// Per-
|
|
134
|
-
// container dimensions for everything would make ELK reserve
|
|
135
|
-
// space around a
|
|
136
|
-
// apart as it needs.
|
|
266
|
+
// Per-kind size, so a square component is laid out as a square. Feeding the
|
|
267
|
+
// container dimensions for everything would make ELK reserve 250x150 of
|
|
268
|
+
// space around a 140x140 box and space the component view twice as far
|
|
269
|
+
// apart as it needs. `gutter` is added here, not passed as node spacing, so
|
|
270
|
+
// a frame's padding clears the card inside it by the same margin.
|
|
137
271
|
const rfNodes: Node[] = model.nodes.map((n, i) => {
|
|
138
272
|
const size = nodeSize(n);
|
|
139
273
|
return {
|
|
@@ -142,8 +276,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
142
276
|
// Fallback grid pitch uses the container box — the largest, so the
|
|
143
277
|
// pre-layout positions never overlap. ELK replaces them on success.
|
|
144
278
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
145
|
-
width: size.width,
|
|
146
|
-
height: size.height,
|
|
279
|
+
width: size.width + gutter,
|
|
280
|
+
height: size.height + gutter,
|
|
147
281
|
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
148
282
|
data: { node: n, selected: false },
|
|
149
283
|
};
|
|
@@ -160,18 +294,15 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
160
294
|
const color = edgeColor(e.mechanisms[0], muted);
|
|
161
295
|
return {
|
|
162
296
|
id: e.id,
|
|
297
|
+
type: 'c4-edge',
|
|
163
298
|
source: e.source,
|
|
164
299
|
target: e.target,
|
|
165
|
-
label
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
stroke: color,
|
|
170
|
-
strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
|
|
171
|
-
strokeDasharray: '6 4',
|
|
172
|
-
},
|
|
300
|
+
// The reserved label box and the chip must agree on size, so ELK is
|
|
301
|
+
// given the same text the chip draws (protocol first, then label). The
|
|
302
|
+
// chip itself is the C4EdgeLabels overlay, not this field.
|
|
303
|
+
label: e.protocol ?? e.label,
|
|
173
304
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
174
|
-
data: { edge: e },
|
|
305
|
+
data: { edge: e, color, path: '' },
|
|
175
306
|
};
|
|
176
307
|
});
|
|
177
308
|
|
|
@@ -180,10 +311,23 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
180
311
|
preserveNodePositions: false,
|
|
181
312
|
groups,
|
|
182
313
|
keepSingletonGroups: true,
|
|
314
|
+
edgeLabels: { enabled: true, placement: 'CENTER' },
|
|
183
315
|
})
|
|
184
316
|
.then((result) => {
|
|
185
317
|
if (!alive) return;
|
|
186
318
|
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
319
|
+
// Draw each edge along ELK's routed polyline and place its label from
|
|
320
|
+
// the same points — one geometry for the line and the chip.
|
|
321
|
+
const labelPositions = new Map<string, { x: number; y: number }>();
|
|
322
|
+
const withPaths = rfEdges.map((e) => {
|
|
323
|
+
const pts = result.edgePathPoints?.get(e.id);
|
|
324
|
+
if (!pts || pts.length === 0) return e;
|
|
325
|
+
labelPositions.set(e.id, pointAlongPath(pts, 0.5));
|
|
326
|
+
// Rounded corners on ELK's orthogonal route — gentler than raw right
|
|
327
|
+
// angles, and still the exact polyline the label is placed on.
|
|
328
|
+
return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
|
|
329
|
+
});
|
|
330
|
+
setLabelPositions(labelPositions);
|
|
187
331
|
const builtGroups = new Set(result.groupBounds.keys());
|
|
188
332
|
const groupById = new Map(model.groups.map((g) => [g.id, g]));
|
|
189
333
|
|
|
@@ -231,7 +375,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
231
375
|
});
|
|
232
376
|
|
|
233
377
|
setNodes([...shells, ...leaves]);
|
|
234
|
-
setRfEdges(
|
|
378
|
+
setRfEdges(withPaths);
|
|
235
379
|
setReady(true);
|
|
236
380
|
requestAnimationFrame(() => fitView({ padding: 0.15 }));
|
|
237
381
|
})
|
|
@@ -277,6 +421,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
277
421
|
nodes={displayNodes}
|
|
278
422
|
edges={rfEdges}
|
|
279
423
|
nodeTypes={nodeTypes}
|
|
424
|
+
edgeTypes={edgeTypes}
|
|
280
425
|
className={GRAPH_CANVAS_CLASS}
|
|
281
426
|
{...GRAPH_NAV_PROPS}
|
|
282
427
|
onNodeClick={(_e, node) => {
|
|
@@ -290,9 +435,16 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
290
435
|
onSelectNode?.(null);
|
|
291
436
|
}}
|
|
292
437
|
proOptions={{ hideAttribution: true }}
|
|
438
|
+
// Pin the flow to its container. The label overlay is a sibling sized
|
|
439
|
+
// `inset: 0`, so the two must share an origin or `viewport.y` (measured
|
|
440
|
+
// in the flow's own box) lands the chips off vertically.
|
|
441
|
+
style={{ width: '100%', height: '100%' }}
|
|
293
442
|
>
|
|
294
443
|
<GraphChrome />
|
|
295
444
|
</ReactFlow>
|
|
445
|
+
{ready && labelPositions.size > 0 && (
|
|
446
|
+
<C4EdgeLabels positions={labelPositions} edges={model.edges} muted={muted} />
|
|
447
|
+
)}
|
|
296
448
|
{!ready && (
|
|
297
449
|
<div
|
|
298
450
|
style={{
|