@principal-ai/subsystems-react 0.53.6 → 0.55.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 +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +7 -5
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js +1 -2
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +144 -9
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +924 -148
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +22 -1
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +10 -2
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.d.ts +5 -4
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +12 -4
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
- package/dist/subsystem/ConstructsCatalog.js +10 -7
- package/dist/subsystem/ConstructsCatalog.js.map +1 -1
- package/dist/subsystem/GraphSidebar.d.ts +71 -0
- package/dist/subsystem/GraphSidebar.d.ts.map +1 -0
- package/dist/subsystem/GraphSidebar.js +145 -0
- package/dist/subsystem/GraphSidebar.js.map +1 -0
- package/dist/subsystem/GraphWithSidebar.d.ts +24 -0
- package/dist/subsystem/GraphWithSidebar.d.ts.map +1 -0
- package/dist/subsystem/GraphWithSidebar.js +25 -0
- package/dist/subsystem/GraphWithSidebar.js.map +1 -0
- package/dist/subsystem/IssueList.d.ts +4 -5
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +33 -28
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +33 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +367 -414
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailStepsNav.d.ts +48 -0
- package/dist/subsystem/TrailStepsNav.d.ts.map +1 -0
- package/dist/subsystem/TrailStepsNav.js +189 -0
- package/dist/subsystem/TrailStepsNav.js.map +1 -0
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +3 -4
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +151 -0
- package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -0
- package/dist/subsystem/__fixtures__/c4Drilldown.js +783 -0
- package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -0
- package/dist/subsystem/c4.d.ts +39 -8
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/declarationRef.d.ts +26 -18
- package/dist/subsystem/declarationRef.d.ts.map +1 -1
- package/dist/subsystem/declarationRef.js +9 -4
- package/dist/subsystem/declarationRef.js.map +1 -1
- package/dist/subsystem/model.d.ts +19 -1
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +12 -2
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +24 -4
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/trailBrief.d.ts.map +1 -1
- package/dist/subsystem/trailBrief.js +6 -1
- package/dist/subsystem/trailBrief.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +9 -2
- package/src/pierre/PierreTrailCodeView.tsx +9 -13
- package/src/stories/Subsystem/C4Graph/C4ToComponentDrill.stories.tsx +496 -0
- package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +7 -7
- package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +19 -25
- package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +8 -10
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +40 -33
- package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +6 -6
- package/src/stories/Subsystem/ComponentGraph/TrailAutoplay.stories.tsx +7 -7
- package/src/stories/Subsystem/ComponentGraph/Trails.stories.tsx +27 -30
- package/src/stories/Subsystem/ComponentGraph/fixtures.ts +1 -2
- package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +10 -13
- package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +3 -4
- package/src/stories/Subsystem/IssueIcons.stories.tsx +65 -0
- package/src/stories/Subsystem/Sidebar/GraphSidebar.stories.tsx +206 -0
- package/src/subsystem/C4Graph.groups.test.ts +96 -2
- package/src/subsystem/C4Graph.tsx +1377 -165
- package/src/subsystem/C4NodeCard.test.ts +1 -2
- package/src/subsystem/C4NodeCard.tsx +24 -2
- package/src/subsystem/ComponentDeclaration.tsx +18 -8
- package/src/subsystem/ConstructsCatalog.tsx +23 -8
- package/src/subsystem/GraphSidebar.test.tsx +152 -0
- package/src/subsystem/GraphSidebar.tsx +300 -0
- package/src/subsystem/GraphWithSidebar.test.tsx +78 -0
- package/src/subsystem/GraphWithSidebar.tsx +60 -0
- package/src/subsystem/IssueList.tsx +34 -29
- package/src/subsystem/SubsystemComponentGraph.tsx +290 -376
- package/src/subsystem/TrailStepsNav.test.tsx +179 -0
- package/src/subsystem/TrailStepsNav.tsx +372 -0
- package/src/subsystem/TrailsPanel.tsx +3 -4
- package/src/subsystem/__fixtures__/c4Drilldown.test.ts +338 -0
- package/src/subsystem/__fixtures__/c4Drilldown.ts +847 -0
- package/src/subsystem/c4.ts +41 -8
- package/src/subsystem/declarationRef.ts +28 -19
- package/src/subsystem/model.test.ts +32 -12
- package/src/subsystem/model.ts +30 -1
- package/src/subsystem/nodes.group.test.tsx +82 -0
- package/src/subsystem/nodes.tsx +30 -2
- package/src/subsystem/trailBrief.test.ts +2 -2
- package/src/subsystem/trailBrief.ts +6 -1
|
@@ -18,12 +18,18 @@ import { Box, Minimize2 } from 'lucide-react';
|
|
|
18
18
|
import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
19
19
|
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
|
|
20
20
|
import { deriveC4Groups, protocolColor } from './c4';
|
|
21
|
-
import { C4NodeCard, NODE_H, NODE_W, OPEN_FOOTER_PAD, OPEN_HEADER_PAD, OPEN_SIDE_PAD, nodeSize, nodeStyle, } from './C4NodeCard';
|
|
21
|
+
import { C4NodeCard, COMPONENT_FOOTER_PAD, COMPONENT_HEADER_PAD, COMPONENT_W, NODE_H, NODE_W, OPEN_FOOTER_PAD, OPEN_HEADER_PAD, OPEN_SIDE_PAD, nodeSize, nodeStyle, } from './C4NodeCard';
|
|
22
22
|
import { TechMark, technologyBrand } from './techIcons';
|
|
23
23
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
24
24
|
import { openContainerSet, openStateKey } from './c4';
|
|
25
|
+
import { EDGE_DIM_ALPHA, hexWithAlpha, SubsystemCallbacksProvider, SubsystemComponentNode, SubsystemEdge, } from './nodes';
|
|
26
|
+
import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
|
|
27
|
+
import { constructBadgeLabel, convertSubsystemToEdges, convertSubsystemToNodes, deriveNameFromSymbol, edgeColor, MECHANISM_DESCRIPTIONS, rightBadgeLabel, } from './model';
|
|
28
|
+
import { generateDeclarationString } from './formatDeclaration';
|
|
29
|
+
import { componentColor } from '../pierre/constructColors';
|
|
30
|
+
import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
|
|
25
31
|
function C4NodeView(props) {
|
|
26
|
-
const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } = props.data;
|
|
32
|
+
const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect, dimmed, entering, leaving, moveDelayMs = 0, } = props.data;
|
|
27
33
|
const grown = open === true;
|
|
28
34
|
// Position-only FLIP. The card resizes through its own `width`/`height`
|
|
29
35
|
// transition, so scaling here would distort the text and fight that transition.
|
|
@@ -31,6 +37,11 @@ function C4NodeView(props) {
|
|
|
31
37
|
// transition, so its resize is the card's own and nothing competes with it.
|
|
32
38
|
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
33
39
|
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
40
|
+
const fade = useFade(entering, leaving);
|
|
41
|
+
const transitions = [
|
|
42
|
+
...(fade.transition ? [fade.transition] : []),
|
|
43
|
+
...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
|
|
44
|
+
];
|
|
34
45
|
// The card takes its box from the React Flow node rather than from its own
|
|
35
46
|
// `nodeSize`, so on the opened container ELK's fitted size drives it — and
|
|
36
47
|
// because the element persists across the swap, the card's own width/height
|
|
@@ -47,8 +58,15 @@ function C4NodeView(props) {
|
|
|
47
58
|
// compose without either fighting the other.
|
|
48
59
|
transformOrigin: 'top left',
|
|
49
60
|
transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
|
|
50
|
-
transition:
|
|
51
|
-
|
|
61
|
+
transition: transitions.length > 0 ? transitions.join(', ') : 'none',
|
|
62
|
+
opacity: fade.opacity,
|
|
63
|
+
}, children: _jsx("div", { style: {
|
|
64
|
+
// The dim is a view concern, deliberately below the FLIP transform
|
|
65
|
+
// (which owns position) so the two never fight. The whole card fades —
|
|
66
|
+
// if we ever want to keep a badge lit, that is a different element.
|
|
67
|
+
opacity: dimmed ? 0.22 : 1,
|
|
68
|
+
transition: 'opacity 220ms ease',
|
|
69
|
+
}, children: _jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, onExpand: onExpand, width: props.width, height: props.height, showDescription: showDescriptionFor(open, settled), transitionDelayMs: moveDelayMs, sizeTransitionMs: MORPH_MS, handles: _jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", position: Position.Left, style: { opacity: 0 } }), _jsx(Handle, { type: "source", position: Position.Right, style: { opacity: 0 } })] }) }) }) }));
|
|
52
70
|
}
|
|
53
71
|
/**
|
|
54
72
|
* Edge-label overlay.
|
|
@@ -58,7 +76,7 @@ function C4NodeView(props) {
|
|
|
58
76
|
* never drift from the edges it sits on. Positions are ELK's computed label
|
|
59
77
|
* anchors (`edgeLabelPositions`), in flow coordinates.
|
|
60
78
|
*/
|
|
61
|
-
function C4EdgeLabels({ positions, edges, }) {
|
|
79
|
+
function C4EdgeLabels({ positions, edges, selectedEdgeId, dimmedEdgeIds, hiddenEdgeIds, }) {
|
|
62
80
|
const { theme } = useTheme();
|
|
63
81
|
const viewport = useViewport();
|
|
64
82
|
const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
@@ -73,8 +91,12 @@ function C4EdgeLabels({ positions, edges, }) {
|
|
|
73
91
|
const edge = byId.get(id);
|
|
74
92
|
if (!edge)
|
|
75
93
|
return null;
|
|
94
|
+
if (hiddenEdgeIds?.has(edge.id))
|
|
95
|
+
return null;
|
|
76
96
|
const color = protocolColor(edge.protocol);
|
|
77
97
|
const text = edge.protocol ?? edge.label;
|
|
98
|
+
const selected = edge.id === selectedEdgeId;
|
|
99
|
+
const dimmed = dimmedEdgeIds?.has(edge.id) === true;
|
|
78
100
|
return (_jsxs("div", { style: {
|
|
79
101
|
position: 'absolute',
|
|
80
102
|
left: p.x * viewport.zoom + viewport.x,
|
|
@@ -88,12 +110,17 @@ function C4EdgeLabels({ positions, edges, }) {
|
|
|
88
110
|
height: EDGE_LABEL_HEIGHT,
|
|
89
111
|
boxSizing: 'border-box',
|
|
90
112
|
padding: '7px 8px',
|
|
113
|
+
// Fades with its line, on the same beat.
|
|
114
|
+
opacity: dimmed ? 0.25 : 1,
|
|
115
|
+
transition: 'opacity 220ms ease',
|
|
91
116
|
}, children: [_jsx("span", { "aria-hidden": true, style: {
|
|
92
117
|
position: 'absolute',
|
|
93
118
|
inset: 0,
|
|
94
119
|
borderRadius: 4,
|
|
95
120
|
border: `0.5px solid ${color}`,
|
|
96
121
|
background: surface,
|
|
122
|
+
// The selected chip echoes the edge's heavier stroke.
|
|
123
|
+
outline: selected ? `1.5px solid ${color}` : 'none',
|
|
97
124
|
} }), _jsx("span", { style: {
|
|
98
125
|
position: 'relative',
|
|
99
126
|
maxWidth: '100%',
|
|
@@ -103,11 +130,96 @@ function C4EdgeLabels({ positions, edges, }) {
|
|
|
103
130
|
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
104
131
|
lineHeight: 1,
|
|
105
132
|
fontFamily: theme.fonts.monospace,
|
|
106
|
-
fontWeight: 500,
|
|
133
|
+
fontWeight: selected ? 700 : 500,
|
|
107
134
|
color,
|
|
108
135
|
}, children: text })] }, id));
|
|
109
136
|
}) }));
|
|
110
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Fallback drawer body when the host supplies no file/trail viewer: the step's
|
|
140
|
+
* seam — `from → to`, mechanism, site, symbol, and any annotation. A host with a
|
|
141
|
+
* real code viewer passes `renderFileViewer` / `renderTrailViewer` instead.
|
|
142
|
+
*/
|
|
143
|
+
function StepDrawerFallback({ step }) {
|
|
144
|
+
const { theme } = useTheme();
|
|
145
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
146
|
+
return (_jsxs("div", { style: {
|
|
147
|
+
padding: 12,
|
|
148
|
+
display: 'flex',
|
|
149
|
+
flexDirection: 'column',
|
|
150
|
+
gap: 6,
|
|
151
|
+
fontFamily: theme.fonts.monospace,
|
|
152
|
+
fontSize: theme.fontSizes[0],
|
|
153
|
+
}, children: [_jsxs("div", { style: { color: theme.colors.text }, children: [step.from, " \u2192 ", step.to] }), _jsxs("div", { style: { color: muted }, children: [step.mechanism, " \u00B7 ", step.file, ":", step.span?.startLine ?? '?'] }), step.symbol && _jsx("div", { style: { color: muted }, children: step.symbol }), step.annotation && (_jsx("div", { style: { color: theme.colors.text, fontFamily: theme.fonts.body, marginTop: 4 }, children: step.annotation }))] }));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Construct-edge label overlay — the mechanism chips for the reused subsystem
|
|
157
|
+
* edges.
|
|
158
|
+
*
|
|
159
|
+
* `SubsystemEdge` draws only the line; the component graph renders its mechanism
|
|
160
|
+
* labels in a separate overlay. So the C4 canvas supplies one too, from the ELK
|
|
161
|
+
* label anchors stamped onto each edge's data (`labelX/labelY`).
|
|
162
|
+
*/
|
|
163
|
+
function SubsystemEdgeLabels({ edges }) {
|
|
164
|
+
const { theme } = useTheme();
|
|
165
|
+
const viewport = useViewport();
|
|
166
|
+
const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
167
|
+
return (_jsx("div", { style: {
|
|
168
|
+
position: 'absolute',
|
|
169
|
+
inset: 0,
|
|
170
|
+
overflow: 'hidden',
|
|
171
|
+
pointerEvents: 'none',
|
|
172
|
+
zIndex: 5,
|
|
173
|
+
}, children: edges.map((e) => {
|
|
174
|
+
const d = e.data;
|
|
175
|
+
if (d?.labelX == null || d?.labelY == null)
|
|
176
|
+
return null;
|
|
177
|
+
const mechanism = d.mechanism ?? 'uses';
|
|
178
|
+
const text = mechanism;
|
|
179
|
+
// The same hue the component graph gives its mechanism chips, so a verb
|
|
180
|
+
// reads the same colour on either canvas.
|
|
181
|
+
const color = edgeColor({ mechanism, provenance: d.provenance });
|
|
182
|
+
const labelTitle = d.provenance === 'graphify'
|
|
183
|
+
? 'Derived from graphify (static symbol graph)'
|
|
184
|
+
: MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] === false
|
|
185
|
+
? 'Not directly verifiable with graphify'
|
|
186
|
+
: undefined;
|
|
187
|
+
return (_jsxs("div", { title: labelTitle, style: {
|
|
188
|
+
position: 'absolute',
|
|
189
|
+
left: d.labelX * viewport.zoom + viewport.x,
|
|
190
|
+
top: d.labelY * viewport.zoom + viewport.y,
|
|
191
|
+
transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
|
|
192
|
+
transformOrigin: 'center center',
|
|
193
|
+
display: 'flex',
|
|
194
|
+
alignItems: 'center',
|
|
195
|
+
justifyContent: 'center',
|
|
196
|
+
width: estimateEdgeLabelWidth(text),
|
|
197
|
+
height: EDGE_LABEL_HEIGHT,
|
|
198
|
+
boxSizing: 'border-box',
|
|
199
|
+
padding: '7px 8px',
|
|
200
|
+
// Fades with its line, on the same beat.
|
|
201
|
+
opacity: d.dimmed === true ? 0.15 : 1,
|
|
202
|
+
transition: 'opacity 220ms ease',
|
|
203
|
+
}, children: [_jsx("span", { "aria-hidden": true, style: {
|
|
204
|
+
position: 'absolute',
|
|
205
|
+
inset: 0,
|
|
206
|
+
borderRadius: 4,
|
|
207
|
+
border: `0.5px solid ${color}`,
|
|
208
|
+
background: surface,
|
|
209
|
+
} }), _jsx("span", { style: {
|
|
210
|
+
position: 'relative',
|
|
211
|
+
maxWidth: '100%',
|
|
212
|
+
overflow: 'hidden',
|
|
213
|
+
textOverflow: 'ellipsis',
|
|
214
|
+
whiteSpace: 'nowrap',
|
|
215
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
216
|
+
lineHeight: 1,
|
|
217
|
+
fontFamily: theme.fonts.monospace,
|
|
218
|
+
fontWeight: 500,
|
|
219
|
+
color,
|
|
220
|
+
}, children: text })] }, e.id));
|
|
221
|
+
}) }));
|
|
222
|
+
}
|
|
111
223
|
/**
|
|
112
224
|
* C4 edge — draws ELK's routed polyline, not React Flow's default bezier.
|
|
113
225
|
*
|
|
@@ -115,12 +227,18 @@ function C4EdgeLabels({ positions, edges, }) {
|
|
|
115
227
|
* the same `elkPathPoints` this renders. React Flow's built-in edge would curve
|
|
116
228
|
* between handles while the chip sat on ELK's orthogonal route, which is why
|
|
117
229
|
* labels read as "off the line".
|
|
230
|
+
*
|
|
231
|
+
* Two strokes: an invisible wide hit path (the drawn line is 1px, hopeless to
|
|
232
|
+
* click) and the visible one. The selection emphasis is a heavier stroke on
|
|
233
|
+
* the same geometry, so the selected line reads without redrawing the route.
|
|
118
234
|
*/
|
|
119
|
-
function C4EdgeView({ data, markerEnd }) {
|
|
235
|
+
function C4EdgeView({ data, markerEnd, }) {
|
|
120
236
|
const path = data?.path ?? '';
|
|
121
237
|
if (!path)
|
|
122
238
|
return null;
|
|
123
|
-
|
|
239
|
+
const selected = data?.selected === true;
|
|
240
|
+
const dimmed = data?.dimmed === true;
|
|
241
|
+
return (_jsxs(_Fragment, { children: [_jsx("path", { d: path, fill: "none", stroke: "transparent", strokeWidth: 14, style: { pointerEvents: 'auto', cursor: 'pointer' } }), _jsx("path", { d: path, fill: "none", stroke: data?.color, strokeWidth: selected ? 2.5 : 1, strokeDasharray: "6 4", markerEnd: markerEnd, style: { pointerEvents: 'none', opacity: dimmed ? 0.18 : 1, transition: 'opacity 220ms ease' } })] }));
|
|
124
242
|
}
|
|
125
243
|
function C4GroupView(props) {
|
|
126
244
|
const { theme } = useTheme();
|
|
@@ -128,13 +246,14 @@ function C4GroupView(props) {
|
|
|
128
246
|
const isSystem = props.data.kind === 'system';
|
|
129
247
|
const onCollapse = props.data.onCollapse;
|
|
130
248
|
const brand = props.data.brand;
|
|
131
|
-
const { growFrom, frameRect } = props.data;
|
|
249
|
+
const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data;
|
|
132
250
|
// Position-only FLIP, same as the cards. The frame resizes through its own
|
|
133
251
|
// width/height transition below, so scaling here would fight it *and* distort
|
|
134
252
|
// what it draws: a scaled dashed border changes weight as it grows, and the
|
|
135
253
|
// label chip's border and text scale with it.
|
|
136
254
|
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
137
255
|
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
256
|
+
const fade = useFade(entering, leaving);
|
|
138
257
|
// A container frame shows its technology mark (React/Bun/Node…) so the
|
|
139
258
|
// boundary says what it is built with, same as the node it wraps. The system
|
|
140
259
|
// frame has no single technology, so it keeps a neutral `Box`.
|
|
@@ -150,10 +269,12 @@ function C4GroupView(props) {
|
|
|
150
269
|
// Size is always transitioned; the transform only while sliding, and
|
|
151
270
|
// `transform: none` needs no transition.
|
|
152
271
|
transition: [
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
...(
|
|
272
|
+
`width ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
|
|
273
|
+
`height ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
|
|
274
|
+
...(fade.transition ? [fade.transition] : []),
|
|
275
|
+
...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
|
|
156
276
|
].join(', '),
|
|
277
|
+
opacity: fade.opacity,
|
|
157
278
|
boxSizing: 'border-box',
|
|
158
279
|
borderRadius: 12,
|
|
159
280
|
border: `2px dashed ${color}`,
|
|
@@ -203,18 +324,53 @@ function C4GroupView(props) {
|
|
|
203
324
|
lineHeight: 1,
|
|
204
325
|
}, children: _jsx(Minimize2, { size: 12, strokeWidth: 2.5 }) }))] }) }));
|
|
205
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* The component graph's own node, wrapped in the C4 canvas's FLIP so a construct
|
|
329
|
+
* glides to its new slot when the layout reflows. On its own it would only mount
|
|
330
|
+
* at the destination — a jump. Position-only, matching the C4 cards.
|
|
331
|
+
*/
|
|
332
|
+
const SubsystemNode = SubsystemComponentNode;
|
|
333
|
+
function C4SubsystemNodeView(props) {
|
|
334
|
+
const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data;
|
|
335
|
+
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
336
|
+
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
337
|
+
const fade = useFade(entering, leaving);
|
|
338
|
+
const transitions = [
|
|
339
|
+
...(fade.transition ? [fade.transition] : []),
|
|
340
|
+
...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
|
|
341
|
+
];
|
|
342
|
+
return (_jsx("div", { style: {
|
|
343
|
+
transformOrigin: 'top left',
|
|
344
|
+
transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
|
|
345
|
+
transition: transitions.length > 0 ? transitions.join(', ') : 'none',
|
|
346
|
+
opacity: fade.opacity,
|
|
347
|
+
}, children: _jsx(SubsystemNode, { ...props }) }));
|
|
348
|
+
}
|
|
206
349
|
const nodeTypes = {
|
|
207
350
|
'c4-node': C4NodeView,
|
|
208
351
|
'c4-group': C4GroupView,
|
|
352
|
+
// The component graph's own renderers, reused for the constructs a container
|
|
353
|
+
// hosts. They are context-light (ThemeProvider + a React Flow ancestor), which
|
|
354
|
+
// is why they can ride this canvas instead of a nested one.
|
|
355
|
+
'subsystem-component': C4SubsystemNodeView,
|
|
209
356
|
};
|
|
210
357
|
const edgeTypes = {
|
|
211
358
|
'c4-edge': C4EdgeView,
|
|
359
|
+
'subsystem-edge': SubsystemEdge,
|
|
212
360
|
};
|
|
213
361
|
/** Drill-down animation durations, in ms. */
|
|
214
362
|
const MORPH_MS = 420;
|
|
215
363
|
const CAMERA_MS = 520;
|
|
216
364
|
/**
|
|
217
|
-
*
|
|
365
|
+
* How long cards arriving or leaving take to fade, in ms. The morph is
|
|
366
|
+
* sequenced: the fade runs first, and the surviving cards wait it out before
|
|
367
|
+
* they move and grow (`moveDelayMs` below). So the reveal clock is
|
|
368
|
+
* `FADE_MS + MORPH_MS`, not just `MORPH_MS`.
|
|
369
|
+
*/
|
|
370
|
+
const FADE_MS = 200;
|
|
371
|
+
/**
|
|
372
|
+
* When to re-frame the camera after a drill-down: the sequenced morph (fade,
|
|
373
|
+
* then move), plus a beat.
|
|
218
374
|
*
|
|
219
375
|
* The extra 60ms is not padding for looks. `fitView` reads the system frame's
|
|
220
376
|
* *measured* size, and that frame's own height transition runs on the same
|
|
@@ -222,7 +378,36 @@ const CAMERA_MS = 520;
|
|
|
222
378
|
* height that never existed. The beat is for React Flow's resize observer to
|
|
223
379
|
* report the final geometry.
|
|
224
380
|
*/
|
|
225
|
-
const CAMERA_AFTER_MORPH_MS = MORPH_MS + 60;
|
|
381
|
+
const CAMERA_AFTER_MORPH_MS = FADE_MS + MORPH_MS + 60;
|
|
382
|
+
/**
|
|
383
|
+
* A node's rect in **absolute** flow space, walking the parent chain.
|
|
384
|
+
*
|
|
385
|
+
* `groupBounds` is already absolute; a leaf's `position` is relative to its
|
|
386
|
+
* parent, so each ancestor's origin is added in turn.
|
|
387
|
+
*
|
|
388
|
+
* The FLIP needs absolute rects, not parent-relative ones. React Flow places a
|
|
389
|
+
* node at its new absolute position (parent origin + relative position) and
|
|
390
|
+
* moves children along with the parent's *wrapper* — while our FLIP transform
|
|
391
|
+
* lives on the node's inner div, which the children do not inherit. So a node's
|
|
392
|
+
* own transform must cover its full absolute delta; rebasing it to the parent
|
|
393
|
+
* would leave it short by exactly the parent's movement (the "subtle drift"
|
|
394
|
+
* before a leaving box disappears).
|
|
395
|
+
*/
|
|
396
|
+
export function absoluteRectOf(layout, id) {
|
|
397
|
+
const frame = layout.groupBounds.get(id);
|
|
398
|
+
if (frame)
|
|
399
|
+
return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
|
|
400
|
+
const node = layout.nodes.find((n) => n.id === id);
|
|
401
|
+
if (!node)
|
|
402
|
+
return null;
|
|
403
|
+
const origin = node.parentId ? absoluteRectOf(layout, node.parentId) : null;
|
|
404
|
+
return {
|
|
405
|
+
x: (origin?.x ?? 0) + node.position.x,
|
|
406
|
+
y: (origin?.y ?? 0) + node.position.y,
|
|
407
|
+
width: node.width ?? 0,
|
|
408
|
+
height: node.height ?? 0,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
226
411
|
/**
|
|
227
412
|
* Whether committing a layout should also move the camera.
|
|
228
413
|
*
|
|
@@ -271,9 +456,9 @@ export function showDescriptionFor(open, settled) {
|
|
|
271
456
|
* `result.groupBounds` is absolute for a root-level group and *parent-relative*
|
|
272
457
|
* for a nested one — two spaces in one map, which is the same trap as
|
|
273
458
|
* `result.nodes`. The opened container is nested (inside the system frame), and
|
|
274
|
-
* `
|
|
275
|
-
* be handed to the morph offset by the frame's origin: the container would
|
|
276
|
-
* to a place it was never drawn.
|
|
459
|
+
* `absoluteRectOf` reads this map before the node box, so an unrebased entry
|
|
460
|
+
* would be handed to the morph offset by the frame's origin: the container would
|
|
461
|
+
* glide to a place it was never drawn.
|
|
277
462
|
*/
|
|
278
463
|
export function rebaseGroupBoundsAbsolute(result) {
|
|
279
464
|
const rebased = new Map();
|
|
@@ -301,7 +486,7 @@ export function rebaseGroupBoundsAbsolute(result) {
|
|
|
301
486
|
* gives) purely so the group list — and therefore the layout — is deterministic
|
|
302
487
|
* for a given set.
|
|
303
488
|
*/
|
|
304
|
-
export function c4GroupDefs(model, openIds) {
|
|
489
|
+
export function c4GroupDefs(model, openIds, constructGraphByContainer) {
|
|
305
490
|
// Normalized here rather than trusted from the caller. This function's output is
|
|
306
491
|
// cached under the open *set*, so a repeated id would emit the same group twice
|
|
307
492
|
// (ELK rejects a duplicate id) and a different order would produce a different
|
|
@@ -329,11 +514,28 @@ export function c4GroupDefs(model, openIds) {
|
|
|
329
514
|
const openMembers = model.nodes
|
|
330
515
|
.filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
|
|
331
516
|
.map((n) => n.id);
|
|
332
|
-
|
|
517
|
+
// The constructs this container hosts, grouped under the authored component
|
|
518
|
+
// that claims each (which grows into its own ELK parent below); a construct
|
|
519
|
+
// no component claims nests directly under the container.
|
|
520
|
+
const cg = constructGraphByContainer?.get(openId);
|
|
521
|
+
const byComponent = new Map();
|
|
522
|
+
const orphans = [];
|
|
523
|
+
for (const c of cg?.components ?? []) {
|
|
524
|
+
const owner = cg?.componentOf?.get(c.alias);
|
|
525
|
+
if (owner) {
|
|
526
|
+
const list = byComponent.get(owner) ?? [];
|
|
527
|
+
list.push(c.alias);
|
|
528
|
+
byComponent.set(owner, list);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
orphans.push(c.alias);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (openMembers.length === 0 && orphans.length === 0)
|
|
333
535
|
continue;
|
|
334
536
|
groups.push({
|
|
335
537
|
id: openId,
|
|
336
|
-
memberIds: openMembers,
|
|
538
|
+
memberIds: [...openMembers, ...orphans],
|
|
337
539
|
// INSIDE the system frame, not beside it. The boundary *is* the system, so
|
|
338
540
|
// a sibling here would draw the opened container outside the very boundary
|
|
339
541
|
// it belongs to.
|
|
@@ -353,27 +555,29 @@ export function c4GroupDefs(model, openIds) {
|
|
|
353
555
|
// knows which one a bare member id means.
|
|
354
556
|
drawnAsCard: true,
|
|
355
557
|
});
|
|
558
|
+
// A component that claims constructs grows into an ELK parent as well, so
|
|
559
|
+
// its constructs nest inside it (container → component → construct) rather
|
|
560
|
+
// than sitting beside it.
|
|
561
|
+
for (const [componentId, memberIds] of byComponent) {
|
|
562
|
+
if (memberIds.length === 0)
|
|
563
|
+
continue;
|
|
564
|
+
groups.push({
|
|
565
|
+
id: componentId,
|
|
566
|
+
memberIds,
|
|
567
|
+
parentId: openId,
|
|
568
|
+
minWidth: COMPONENT_W,
|
|
569
|
+
// A component card's header is its label slot + padding; it has no
|
|
570
|
+
// technology row and no collapse affordance.
|
|
571
|
+
padTop: COMPONENT_HEADER_PAD,
|
|
572
|
+
padBottom: COMPONENT_FOOTER_PAD,
|
|
573
|
+
padX: OPEN_SIDE_PAD,
|
|
574
|
+
drawnAsCard: true,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
356
577
|
}
|
|
357
578
|
}
|
|
358
579
|
return { frames, groups };
|
|
359
580
|
}
|
|
360
|
-
/** The container's rect in a laid-out state: its box node, or its frame bounds. */
|
|
361
|
-
function containerRect(layout, id) {
|
|
362
|
-
if (!layout)
|
|
363
|
-
return null;
|
|
364
|
-
const frame = layout.groupBounds.get(id);
|
|
365
|
-
if (frame)
|
|
366
|
-
return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
|
|
367
|
-
const node = layout.nodes.find((n) => n.id === id);
|
|
368
|
-
if (!node)
|
|
369
|
-
return null;
|
|
370
|
-
return {
|
|
371
|
-
x: node.position.x,
|
|
372
|
-
y: node.position.y,
|
|
373
|
-
width: node.width ?? 0,
|
|
374
|
-
height: node.height ?? 0,
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
581
|
/**
|
|
378
582
|
* FLIP between two absolute flow rects: render transformed back onto `from`,
|
|
379
583
|
* then release onto `to`.
|
|
@@ -409,6 +613,24 @@ function useFlip(from, to) {
|
|
|
409
613
|
dy: from && to ? from.y - to.y : 0,
|
|
410
614
|
};
|
|
411
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Opacity for a card arriving or leaving the layout. An arriving card mounts
|
|
618
|
+
* hidden and fades in; a leaving card is kept (out of the new layout) for the
|
|
619
|
+
* morph, then fades out — a removal reads as a fade, not a pop.
|
|
620
|
+
*/
|
|
621
|
+
function useFade(entering, leaving) {
|
|
622
|
+
const animate = entering === true || leaving === true;
|
|
623
|
+
const [shown, setShown] = useState(entering !== true);
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
if (!animate)
|
|
626
|
+
return;
|
|
627
|
+
const raf = requestAnimationFrame(() => setShown(leaving !== true));
|
|
628
|
+
return () => cancelAnimationFrame(raf);
|
|
629
|
+
}, [animate, entering, leaving]);
|
|
630
|
+
if (!animate)
|
|
631
|
+
return { opacity: 1, transition: undefined };
|
|
632
|
+
return { opacity: shown ? 1 : 0, transition: `opacity ${FADE_MS}ms ease` };
|
|
633
|
+
}
|
|
412
634
|
/**
|
|
413
635
|
* Whether a nested component is withheld while the diagram is mid-morph.
|
|
414
636
|
*
|
|
@@ -429,14 +651,23 @@ export function hidesComponentDuringMorph(owner, openIds, morphTargets, settled)
|
|
|
429
651
|
return false;
|
|
430
652
|
return morphTargets.has(owner);
|
|
431
653
|
}
|
|
432
|
-
function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }) {
|
|
433
|
-
const { theme } = useTheme();
|
|
434
|
-
const { fitView } = useReactFlow();
|
|
654
|
+
function Inner({ model, onSelectNode, onSelectEdge, onTrailClick, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28, dimmedElementIds, dimmedEdgeIds, hiddenElementIds, hiddenEdgeIds, dimmedConstructIds, dimmedConstructEdgeIds, constructGraphByContainer, focusedStep, onCloseStep, renderFileViewer, renderTrailViewer, hideDrawer = false }) {
|
|
655
|
+
const { theme, mode } = useTheme();
|
|
656
|
+
const { fitView, fitBounds } = useReactFlow();
|
|
435
657
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
436
658
|
const [nodes, setNodes] = useState([]);
|
|
659
|
+
/** Latest rendered nodes, for the step-focus camera fit without re-firing on
|
|
660
|
+
* every layout change. */
|
|
661
|
+
const nodesRef = useRef([]);
|
|
662
|
+
nodesRef.current = nodes;
|
|
437
663
|
const [rfEdges, setRfEdges] = useState([]);
|
|
438
664
|
const [ready, setReady] = useState(false);
|
|
439
665
|
const [selectedId, setSelectedId] = useState(null);
|
|
666
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
|
|
667
|
+
// The construct (subsystem component alias) whose functionality (declaration
|
|
668
|
+
// + purpose) is open. Its own panel, so opening a construct never disturbs the
|
|
669
|
+
// node/edge inspector.
|
|
670
|
+
const [selectedConstructAlias, setSelectedConstructAlias] = useState(null);
|
|
440
671
|
const [labelPositions, setLabelPositions] = useState(() => new Map());
|
|
441
672
|
/** Set by the layout effect; consumed by the `ready` effect to run the fit. */
|
|
442
673
|
const pendingFitRef = useRef(false);
|
|
@@ -464,7 +695,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
464
695
|
break;
|
|
465
696
|
// Never evict what is on screen: a re-layout of the current state mid-morph
|
|
466
697
|
// would swap the geometry out from under the transition.
|
|
467
|
-
if (oldest ===
|
|
698
|
+
if (oldest === currentCacheKeyRef.current)
|
|
468
699
|
continue;
|
|
469
700
|
cache.delete(oldest);
|
|
470
701
|
}
|
|
@@ -487,12 +718,29 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
487
718
|
const settleTimer = useRef(null);
|
|
488
719
|
/** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
|
|
489
720
|
const cameraTimer = useRef(null);
|
|
721
|
+
/** Drops cards that were kept only to fade out. */
|
|
722
|
+
const leaverTimer = useRef(null);
|
|
723
|
+
/**
|
|
724
|
+
* The dim set of the *previous* render, read when a card starts leaving so it
|
|
725
|
+
* fades from the opacity it actually had. A hover preview dims the non-cast
|
|
726
|
+
* boxes; clicking the trail clears the hover, so without this a leaving box
|
|
727
|
+
* would brighten to full before fading.
|
|
728
|
+
*/
|
|
729
|
+
const prevDimmedRef = useRef(null);
|
|
490
730
|
/**
|
|
491
731
|
* The open set rendered last, so a change can morph between the two layouts.
|
|
492
732
|
* The array, not just its key, because the morph needs to know *which*
|
|
493
733
|
* containers changed — see `morphTargetsRef`.
|
|
494
734
|
*/
|
|
495
735
|
const prevOpenRef = useRef([]);
|
|
736
|
+
/**
|
|
737
|
+
* The layout-cache key rendered last, and the one on screen now. The key
|
|
738
|
+
* carries the construct counts too (a card's box grows with its rows), so a
|
|
739
|
+
* focus change that keeps the same containers open still misses the cache and
|
|
740
|
+
* recomputes — and the morph reads the previous entry by its own key.
|
|
741
|
+
*/
|
|
742
|
+
const prevCacheKeyRef = useRef('');
|
|
743
|
+
const currentCacheKeyRef = useRef('');
|
|
496
744
|
/**
|
|
497
745
|
* Containers whose open state is changing *right now* — the symmetric
|
|
498
746
|
* difference of the previous and next open sets.
|
|
@@ -564,11 +812,25 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
564
812
|
// as a change.
|
|
565
813
|
const openIds = useMemo(() => openContainerSet(model), [model]);
|
|
566
814
|
const openKey = openStateKey(openIds);
|
|
567
|
-
|
|
815
|
+
// The constructs a container hosts change its layout, so a focus shift that
|
|
816
|
+
// swaps them (same containers open) must recompute — keyed here or the stale
|
|
817
|
+
// box would stand.
|
|
818
|
+
const constructKey = useMemo(() => constructGraphByContainer
|
|
819
|
+
? [...constructGraphByContainer.entries()]
|
|
820
|
+
.map(([id, cg]) => `${id}\0${cg.components.map((c) => c.alias).sort().join(',')}`)
|
|
821
|
+
.sort()
|
|
822
|
+
.join('\n')
|
|
823
|
+
: '', [constructGraphByContainer]);
|
|
824
|
+
// The model's own shape — which boxes and lines exist. Part of the cache key
|
|
825
|
+
// so a host that filters the model (e.g. down to a focused trail's cast)
|
|
826
|
+
// re-lays-out instead of hitting a stale layout for the same open set.
|
|
827
|
+
const modelKey = useMemo(() => [
|
|
568
828
|
...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
|
|
569
829
|
...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
|
|
570
|
-
|
|
571
|
-
|
|
830
|
+
].join('\n'), [model]);
|
|
831
|
+
const layoutKey = useMemo(() => `${modelKey}\nopen:${openKey}\nconstructs:${constructKey}`, [modelKey, openKey, constructKey]);
|
|
832
|
+
/** The identity of one laid-out state — open set + constructs + model shape. */
|
|
833
|
+
const stateKey = (ids) => `${openStateKey(ids)}|${constructKey}|${modelKey}`;
|
|
572
834
|
/**
|
|
573
835
|
* Build the React Flow inputs for a given open state — everything ELK needs,
|
|
574
836
|
* independent of whether a layout has been run yet. Pure over `openIds`, so it
|
|
@@ -596,6 +858,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
596
858
|
if (parent)
|
|
597
859
|
childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
|
|
598
860
|
}
|
|
861
|
+
// Authored components that claim constructs grow into ELK parents too, so
|
|
862
|
+
// they are drawn as grown cards (absolute box, no description) exactly like
|
|
863
|
+
// an opened container.
|
|
864
|
+
const groupedComponentIds = new Set();
|
|
865
|
+
if (model.view === 'container') {
|
|
866
|
+
for (const openId of openIds) {
|
|
867
|
+
const cg = constructGraphByContainer?.get(openId);
|
|
868
|
+
if (!cg)
|
|
869
|
+
continue;
|
|
870
|
+
for (const c of cg.components) {
|
|
871
|
+
const owner = cg.componentOf?.get(c.alias);
|
|
872
|
+
if (owner)
|
|
873
|
+
groupedComponentIds.add(owner);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
599
877
|
const rfNodes = model.nodes.filter(inView).map((n, i) => {
|
|
600
878
|
const size = nodeSize(n);
|
|
601
879
|
// A component of an opened container nests under *its own* container (a
|
|
@@ -627,10 +905,41 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
627
905
|
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
|
|
628
906
|
? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
|
|
629
907
|
: {}),
|
|
908
|
+
// A component grown to hold its constructs is a drawn-as-card group:
|
|
909
|
+
// no description, absolute box, sized by ELK.
|
|
910
|
+
...(n.kind === 'component' && groupedComponentIds.has(n.id) ? { open: true } : {}),
|
|
630
911
|
},
|
|
631
912
|
};
|
|
632
913
|
});
|
|
633
|
-
|
|
914
|
+
// Constructs: the component graph's own nodes and edges, nested inside their
|
|
915
|
+
// opened container (under the authored component that claims each). Built
|
|
916
|
+
// per container so an edge never crosses containers — a construct edge that
|
|
917
|
+
// would is left to the C4 container-level line.
|
|
918
|
+
const constructEdges = [];
|
|
919
|
+
if (model.view === 'container') {
|
|
920
|
+
for (const openId of openIds) {
|
|
921
|
+
const cg = constructGraphByContainer?.get(openId);
|
|
922
|
+
if (!cg || cg.components.length === 0)
|
|
923
|
+
continue;
|
|
924
|
+
const subNodes = convertSubsystemToNodes({
|
|
925
|
+
components: [...cg.components],
|
|
926
|
+
trails: [...cg.trails],
|
|
927
|
+
});
|
|
928
|
+
for (const sn of subNodes) {
|
|
929
|
+
const owner = cg.componentOf?.get(sn.id) ?? openId;
|
|
930
|
+
rfNodes.push({
|
|
931
|
+
...sn,
|
|
932
|
+
parentId: owner,
|
|
933
|
+
zIndex: 3,
|
|
934
|
+
// `c4Owner` is the container this construct belongs to, read by the
|
|
935
|
+
// morph/dim pass; it is not part of the node's own render.
|
|
936
|
+
data: { ...sn.data, c4Owner: openId },
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
constructEdges.push(...convertSubsystemToEdges({ components: [...cg.components], trails: [...cg.trails] }));
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const { frames, groups } = c4GroupDefs(model, openIds, constructGraphByContainer);
|
|
634
943
|
// Bands come from the *frames* only. The opened container is an ELK parent,
|
|
635
944
|
// not a band — treating it as one would push it into its own layer, away from
|
|
636
945
|
// the containers it belongs beside.
|
|
@@ -659,6 +968,13 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
659
968
|
data: { edge: e, color, path: '' },
|
|
660
969
|
};
|
|
661
970
|
});
|
|
971
|
+
// Construct edges attach only when both endpoints are drawn — a step to an
|
|
972
|
+
// external, or to a construct in another container, is dropped here and left
|
|
973
|
+
// to the C4 container-level line.
|
|
974
|
+
for (const e of constructEdges) {
|
|
975
|
+
if (drawnIds.has(e.source) && drawnIds.has(e.target))
|
|
976
|
+
rfEdges.push(e);
|
|
977
|
+
}
|
|
662
978
|
return { rfNodes, rfEdges, groups, partitionByNode, frames };
|
|
663
979
|
};
|
|
664
980
|
/**
|
|
@@ -702,20 +1018,30 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
702
1018
|
const pts = result.edgePathPoints?.get(e.id);
|
|
703
1019
|
if (!pts || pts.length === 0)
|
|
704
1020
|
return e;
|
|
1021
|
+
const path = pointsToSmoothPath(pts, 12);
|
|
1022
|
+
// The component graph's edge renderer reads `elkPath` (and its label
|
|
1023
|
+
// overlay reads `labelX/labelY`); the C4 renderer reads `path`. Set each
|
|
1024
|
+
// so the right renderer draws its own route and its own label anchor.
|
|
1025
|
+
if (e.type === 'subsystem-edge') {
|
|
1026
|
+
const mid = pointAlongPath(pts, 0.5);
|
|
1027
|
+
return { ...e, data: { ...e.data, elkPath: path, labelX: mid.x, labelY: mid.y } };
|
|
1028
|
+
}
|
|
705
1029
|
labelPositions.set(e.id, pointAlongPath(pts, 0.5));
|
|
706
|
-
return { ...e, data: { ...e.data, path
|
|
1030
|
+
return { ...e, data: { ...e.data, path } };
|
|
707
1031
|
});
|
|
708
1032
|
// `result.groupBounds` mixes coordinate spaces; everything downstream here
|
|
709
1033
|
// reads it as absolute, so normalize it once at the boundary.
|
|
710
1034
|
const groupBounds = rebaseGroupBoundsAbsolute(result);
|
|
711
1035
|
const builtGroups = new Set(groupBounds.keys());
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
1036
|
+
// Every group drawn as a grown *card* (opened containers, and components
|
|
1037
|
+
// grown to hold their constructs) comes back from ELK as a parent, so it
|
|
1038
|
+
// lands in `groupBounds` — but it is drawn as a card, not a frame. Drop each
|
|
1039
|
+
// from the shells; keep them in `groupBounds` so their nested children can
|
|
715
1040
|
// take their coordinates relative to them.
|
|
1041
|
+
const drawnAsCardIds = new Set(groups.filter((g) => g.drawnAsCard).map((g) => g.id));
|
|
716
1042
|
const shellBounds = new Map(groupBounds);
|
|
717
|
-
for (const
|
|
718
|
-
shellBounds.delete(
|
|
1043
|
+
for (const id of drawnAsCardIds)
|
|
1044
|
+
shellBounds.delete(id);
|
|
719
1045
|
const groupById = new Map(frames.map((g) => [g.id, g]));
|
|
720
1046
|
const depthOf = (id) => {
|
|
721
1047
|
let d = 0;
|
|
@@ -775,27 +1101,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
775
1101
|
};
|
|
776
1102
|
});
|
|
777
1103
|
const leaves = rfNodes.map((n) => {
|
|
1104
|
+
// The parent is whatever `buildInputs` already stamped on the node — a
|
|
1105
|
+
// component's container, or a construct's component/container — kept only
|
|
1106
|
+
// when ELK actually built that group (a closed container is not one). This
|
|
1107
|
+
// covers the construct nodes too, whose data carries no `element`.
|
|
778
1108
|
let parentId;
|
|
779
|
-
if (n.
|
|
780
|
-
|
|
781
|
-
// A component nests under its own container when that container is open —
|
|
782
|
-
// the sibling containers and out-of-boundary nodes in the same layout must
|
|
783
|
-
// not. Reads the component's own owner rather than a single open id,
|
|
784
|
-
// because several containers can be open at once.
|
|
785
|
-
const owner = modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
|
|
786
|
-
const nested = owner && opened.has(owner) ? owner : undefined;
|
|
787
|
-
const candidate = nested ?? modelNode?.parentId;
|
|
788
|
-
if (candidate && builtGroups.has(candidate))
|
|
789
|
-
parentId = candidate;
|
|
790
|
-
}
|
|
1109
|
+
if (n.parentId && builtGroups.has(n.parentId))
|
|
1110
|
+
parentId = n.parentId;
|
|
791
1111
|
// `flowPositionOf` covers every node ELK placed; the fallback is for the
|
|
792
1112
|
// pathological case of a node ELK did not return at all.
|
|
793
1113
|
const abs = flowPositionOf(result, n.id, parentId) ??
|
|
794
1114
|
positioned.get(n.id)?.position ??
|
|
795
1115
|
n.position;
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
|
|
1116
|
+
// A grown card (an opened container, or a component holding its
|
|
1117
|
+
// constructs) is an ELK parent, so its box is whatever ELK fitted to its
|
|
1118
|
+
// nested children — `nodeSize` only knows the closed card.
|
|
1119
|
+
const fitted = drawnAsCardIds.has(n.id) ? groupBounds.get(n.id) : undefined;
|
|
799
1120
|
const node = {
|
|
800
1121
|
...n,
|
|
801
1122
|
position: { x: abs.x, y: abs.y },
|
|
@@ -816,17 +1137,36 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
816
1137
|
return nodes;
|
|
817
1138
|
return nodes.map((n) => {
|
|
818
1139
|
const g = grows.get(n.id);
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
if (
|
|
1140
|
+
if (!g)
|
|
1141
|
+
return n;
|
|
1142
|
+
if (n.type !== 'c4-group' && n.type !== 'c4-node' && n.type !== 'subsystem-component') {
|
|
822
1143
|
return n;
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1144
|
+
}
|
|
1145
|
+
// Absolute rects for every node — root or nested. `useFlip` turns them into
|
|
1146
|
+
// the absolute delta the node's inner div must travel; React Flow has
|
|
1147
|
+
// already moved the wrapper (and its children) to the new absolute slot.
|
|
1148
|
+
return { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } };
|
|
826
1149
|
});
|
|
827
1150
|
};
|
|
828
|
-
const renderLayout = (r, grows) => {
|
|
829
|
-
|
|
1151
|
+
const renderLayout = (r, grows, morph) => {
|
|
1152
|
+
// Mark cards that were not in the previous layout so they fade in; keep the
|
|
1153
|
+
// previous layout's orphans (leavers) so they fade out instead of popping.
|
|
1154
|
+
// Every card also carries the move delay, so the survivors' glide and grow
|
|
1155
|
+
// wait for the fade to finish (the sequenced morph).
|
|
1156
|
+
const moveDelayMs = morph?.moveDelayMs ?? 0;
|
|
1157
|
+
const marked = morph
|
|
1158
|
+
? r.nodes.map((n) => {
|
|
1159
|
+
const base = morph.prevNodeIds.has(n.id)
|
|
1160
|
+
? n
|
|
1161
|
+
: { ...n, data: { ...n.data, entering: true } };
|
|
1162
|
+
return { ...base, data: { ...base.data, moveDelayMs } };
|
|
1163
|
+
})
|
|
1164
|
+
: r.nodes;
|
|
1165
|
+
const leavers = (morph?.leavers ?? []).map((n) => ({
|
|
1166
|
+
...n,
|
|
1167
|
+
data: { ...n.data, moveDelayMs },
|
|
1168
|
+
}));
|
|
1169
|
+
setNodes([...injectGrow(marked, grows), ...leavers]);
|
|
830
1170
|
setRfEdges(r.edges);
|
|
831
1171
|
setLabelPositions(r.labelPositions);
|
|
832
1172
|
setReady(true);
|
|
@@ -836,33 +1176,34 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
836
1176
|
// (instant swap), else computes it now.
|
|
837
1177
|
useEffect(() => {
|
|
838
1178
|
let alive = true;
|
|
839
|
-
|
|
1179
|
+
// Blank the canvas only for the very first paint. A relayout keeps the
|
|
1180
|
+
// previous layout on screen until the new one lands — otherwise every trail
|
|
1181
|
+
// pick (which changes the open set and the construct graph, so the cache
|
|
1182
|
+
// misses) flashed the "Laying out…" overlay.
|
|
1183
|
+
if (!paintedRef.current)
|
|
1184
|
+
setReady(false);
|
|
840
1185
|
setSelectedId(null);
|
|
841
1186
|
const openKeyNow = openStateKey(openIds);
|
|
1187
|
+
const cacheKeyNow = stateKey(openIds);
|
|
1188
|
+
currentCacheKeyRef.current = cacheKeyNow;
|
|
842
1189
|
const prevOpen = prevOpenRef.current;
|
|
1190
|
+
const prevCacheKey = prevCacheKeyRef.current;
|
|
1191
|
+
// Captured now, not inside `applyLayout`: the layout may compute async, and
|
|
1192
|
+
// the tracking effect below clears the ref before that runs. This is the dim
|
|
1193
|
+
// set of the render just gone (the hover preview).
|
|
1194
|
+
const prevDimmed = prevDimmedRef.current;
|
|
843
1195
|
const inputs = buildInputs(openIds);
|
|
844
|
-
//
|
|
1196
|
+
// The containers whose open state changed *this* tick — the only ones whose
|
|
1197
|
+
// components genuinely arrive or leave. Recorded as a ref rather than state:
|
|
1198
|
+
// it is read during the next render (to decide what to hide) but must not
|
|
1199
|
+
// itself schedule a render.
|
|
845
1200
|
if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
|
|
846
|
-
// The containers in one set and not the other are the ones whose components
|
|
847
|
-
// actually arrive or leave. Recorded as a ref rather than state: it is read
|
|
848
|
-
// during the next render (to decide what to hide) but must not itself
|
|
849
|
-
// schedule a render.
|
|
850
1201
|
const before = new Set(prevOpen);
|
|
851
1202
|
const after = new Set(openIds);
|
|
852
1203
|
morphTargetsRef.current = new Set([
|
|
853
1204
|
...prevOpen.filter((id) => !after.has(id)),
|
|
854
1205
|
...openIds.filter((id) => !before.has(id)),
|
|
855
1206
|
]);
|
|
856
|
-
setSettled(false);
|
|
857
|
-
if (settleTimer.current)
|
|
858
|
-
clearTimeout(settleTimer.current);
|
|
859
|
-
settleTimer.current = setTimeout(() => {
|
|
860
|
-
setSettled(true);
|
|
861
|
-
// The morph is over. Clearing the targets matters: an unrelated re-render
|
|
862
|
-
// afterwards must not find a container still marked mid-morph and hide
|
|
863
|
-
// components that are merely sitting still.
|
|
864
|
-
morphTargetsRef.current = new Set();
|
|
865
|
-
}, MORPH_MS);
|
|
866
1207
|
// Re-frame once the grow has landed, not while it is running. The drill-down
|
|
867
1208
|
// more than doubles the boundary, so the extent the camera was fitted to no
|
|
868
1209
|
// longer contains the content — but moving *during* the morph compounds with
|
|
@@ -873,35 +1214,39 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
873
1214
|
clearTimeout(cameraTimer.current);
|
|
874
1215
|
cameraTimer.current = setTimeout(fitSystem, CAMERA_AFTER_MORPH_MS);
|
|
875
1216
|
}
|
|
1217
|
+
else {
|
|
1218
|
+
morphTargetsRef.current = new Set();
|
|
1219
|
+
}
|
|
876
1220
|
paintedRef.current = true;
|
|
877
1221
|
/**
|
|
878
1222
|
* Every element whose rect changes between the previous and new layouts, so
|
|
879
1223
|
* boundaries *and* cards grow/shrink and slide together. Empty when nothing
|
|
880
1224
|
* changed (e.g. the initial paint).
|
|
881
1225
|
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
* the
|
|
1226
|
+
* This covers *nested* cards too (a container inside the system frame, a
|
|
1227
|
+
* component inside a grown container). They are React Flow children, so the
|
|
1228
|
+
* FLIP rects are rebased to their parent's origin in `injectGrow` — a
|
|
1229
|
+
* transform on a child then composes with the parent's instead of fighting
|
|
1230
|
+
* it. Without this, filtering the model to a trail's cast reflowed the
|
|
1231
|
+
* surviving containers and they teleported to their new slots.
|
|
887
1232
|
*/
|
|
888
1233
|
const growsFor = (next) => {
|
|
889
|
-
if (
|
|
1234
|
+
if (!paintedRef.current)
|
|
890
1235
|
return null;
|
|
891
|
-
const prevLayout = layoutCache.current.get(
|
|
1236
|
+
const prevLayout = layoutCache.current.get(prevCacheKey);
|
|
892
1237
|
if (!prevLayout)
|
|
893
1238
|
return null;
|
|
894
1239
|
const grows = new Map();
|
|
895
|
-
const
|
|
1240
|
+
const cardIds = (l) => l.nodes.map((n) => n.id);
|
|
896
1241
|
const ids = new Set([
|
|
897
1242
|
...prevLayout.groupBounds.keys(),
|
|
898
1243
|
...next.groupBounds.keys(),
|
|
899
|
-
...
|
|
900
|
-
...
|
|
1244
|
+
...cardIds(prevLayout),
|
|
1245
|
+
...cardIds(next),
|
|
901
1246
|
]);
|
|
902
1247
|
for (const id of ids) {
|
|
903
|
-
const fromRect =
|
|
904
|
-
const to =
|
|
1248
|
+
const fromRect = absoluteRectOf(prevLayout, id);
|
|
1249
|
+
const to = absoluteRectOf(next, id);
|
|
905
1250
|
if (!fromRect || !to)
|
|
906
1251
|
continue;
|
|
907
1252
|
if (fromRect.x === to.x &&
|
|
@@ -914,10 +1259,74 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
914
1259
|
}
|
|
915
1260
|
return grows.size > 0 ? grows : null;
|
|
916
1261
|
};
|
|
917
|
-
|
|
918
|
-
|
|
1262
|
+
// Commit a new layout: keep the survivors gliding (FLIP), fade in cards the
|
|
1263
|
+
// previous layout did not have, and hold the previous layout's orphans for
|
|
1264
|
+
// one morph so they fade out rather than pop. Then advance the refs.
|
|
1265
|
+
const applyLayout = (r) => {
|
|
1266
|
+
const prevLayout = layoutCache.current.get(prevCacheKey);
|
|
1267
|
+
const span = growsFor(r);
|
|
1268
|
+
const prevNodeIds = new Set((prevLayout?.nodes ?? []).map((n) => n.id));
|
|
1269
|
+
const nextIds = new Set(r.nodes.map((n) => n.id));
|
|
1270
|
+
// A leaving card is rendered as a *root* node pinned to its old absolute
|
|
1271
|
+
// rect. Nested under its old parent it would ride that parent's React Flow
|
|
1272
|
+
// wrapper — which moves the instant the new layout commits — and drift
|
|
1273
|
+
// before it faded. As a root it stays put and just fades.
|
|
1274
|
+
const leavers = prevLayout
|
|
1275
|
+
? prevLayout.nodes
|
|
1276
|
+
.filter((n) => !nextIds.has(n.id))
|
|
1277
|
+
.map((n) => {
|
|
1278
|
+
const rect = absoluteRectOf(prevLayout, n.id);
|
|
1279
|
+
// Keep the dim it had while hovered, so the fade starts from the
|
|
1280
|
+
// faded opacity instead of flashing back to full first.
|
|
1281
|
+
const el = n.data?.element;
|
|
1282
|
+
const wasDimmed = el ? prevDimmed?.has(el.id) === true : false;
|
|
1283
|
+
return {
|
|
1284
|
+
...n,
|
|
1285
|
+
parentId: undefined,
|
|
1286
|
+
position: rect ? { x: rect.x, y: rect.y } : n.position,
|
|
1287
|
+
...(rect ? { width: rect.width, height: rect.height } : {}),
|
|
1288
|
+
data: { ...n.data, leaving: true, ...(wasDimmed ? { dimmed: true } : {}) },
|
|
1289
|
+
};
|
|
1290
|
+
})
|
|
1291
|
+
: [];
|
|
1292
|
+
// Only *root* fades (a container arriving or leaving) sequence the move.
|
|
1293
|
+
// Nested cards (components, constructs) are withheld by the morph gate
|
|
1294
|
+
// while their container grows, so their entering/leaving is not a fade the
|
|
1295
|
+
// reader sees — delaying the grow for them would just add a pause.
|
|
1296
|
+
const rootEntering = prevLayout
|
|
1297
|
+
? r.nodes.filter((n) => !n.parentId && !prevNodeIds.has(n.id)).length
|
|
1298
|
+
: 0;
|
|
1299
|
+
const rootLeaving = leavers.filter((n) => !n.parentId).length;
|
|
1300
|
+
const moveDelayMs = rootEntering > 0 || rootLeaving > 0 ? FADE_MS : 0;
|
|
1301
|
+
// The edges are withheld for the whole sequenced morph — fade, then move,
|
|
1302
|
+
// then content. The morphing containers' components are withheld too (see
|
|
1303
|
+
// `morphTargetsRef`).
|
|
1304
|
+
if (span) {
|
|
1305
|
+
setSettled(false);
|
|
1306
|
+
if (settleTimer.current)
|
|
1307
|
+
clearTimeout(settleTimer.current);
|
|
1308
|
+
settleTimer.current = setTimeout(() => {
|
|
1309
|
+
setSettled(true);
|
|
1310
|
+
morphTargetsRef.current = new Set();
|
|
1311
|
+
}, moveDelayMs + MORPH_MS);
|
|
1312
|
+
}
|
|
1313
|
+
if (leaverTimer.current)
|
|
1314
|
+
clearTimeout(leaverTimer.current);
|
|
1315
|
+
if (leavers.length > 0) {
|
|
1316
|
+
const leavingIds = new Set(leavers.map((n) => n.id));
|
|
1317
|
+
// A leaver only fades; it does not move, so it is dropped when the fade
|
|
1318
|
+
// ends, just before the survivors start to glide.
|
|
1319
|
+
leaverTimer.current = setTimeout(() => {
|
|
1320
|
+
setNodes((prev) => prev.filter((n) => !leavingIds.has(n.id)));
|
|
1321
|
+
}, FADE_MS + 40);
|
|
1322
|
+
}
|
|
919
1323
|
prevOpenRef.current = openIds;
|
|
920
|
-
|
|
1324
|
+
prevCacheKeyRef.current = cacheKeyNow;
|
|
1325
|
+
renderLayout(r, span, prevLayout ? { prevNodeIds, leavers, moveDelayMs } : undefined);
|
|
1326
|
+
};
|
|
1327
|
+
const cached = layoutCache.current.get(cacheKeyNow);
|
|
1328
|
+
if (cached) {
|
|
1329
|
+
applyLayout(cached);
|
|
921
1330
|
return () => {
|
|
922
1331
|
alive = false;
|
|
923
1332
|
};
|
|
@@ -926,10 +1335,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
926
1335
|
.then((r) => {
|
|
927
1336
|
if (!alive)
|
|
928
1337
|
return;
|
|
929
|
-
cacheLayout(
|
|
930
|
-
|
|
931
|
-
prevOpenRef.current = openIds;
|
|
932
|
-
renderLayout(r, span);
|
|
1338
|
+
cacheLayout(cacheKeyNow, r);
|
|
1339
|
+
applyLayout(r);
|
|
933
1340
|
})
|
|
934
1341
|
.catch((err) => {
|
|
935
1342
|
if (!alive)
|
|
@@ -954,9 +1361,20 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
954
1361
|
clearTimeout(cameraTimer.current);
|
|
955
1362
|
cameraTimer.current = null;
|
|
956
1363
|
}
|
|
1364
|
+
if (leaverTimer.current) {
|
|
1365
|
+
clearTimeout(leaverTimer.current);
|
|
1366
|
+
leaverTimer.current = null;
|
|
1367
|
+
}
|
|
957
1368
|
};
|
|
958
1369
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
959
1370
|
}, [layoutKey]);
|
|
1371
|
+
// Remember this render's dim set for the *next* commit's leavers. Declared
|
|
1372
|
+
// after the layout effect so it runs after it: the layout effect reads the
|
|
1373
|
+
// value the previous render left here (a hover dim), before this overwrites
|
|
1374
|
+
// it with the now-cleared set.
|
|
1375
|
+
useEffect(() => {
|
|
1376
|
+
prevDimmedRef.current = dimmedElementIds ?? null;
|
|
1377
|
+
});
|
|
960
1378
|
// After the first paint, prefetch the opened layout for every container, so a
|
|
961
1379
|
// click swaps to a cached result instead of waiting on ELK.
|
|
962
1380
|
useEffect(() => {
|
|
@@ -981,7 +1399,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
981
1399
|
for (const target of targets) {
|
|
982
1400
|
if (!alive)
|
|
983
1401
|
return;
|
|
984
|
-
const key =
|
|
1402
|
+
const key = stateKey(target);
|
|
985
1403
|
if (layoutCache.current.has(key))
|
|
986
1404
|
continue;
|
|
987
1405
|
try {
|
|
@@ -1000,7 +1418,88 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1000
1418
|
};
|
|
1001
1419
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1002
1420
|
}, [layoutKey, model.view]);
|
|
1421
|
+
/** Open a construct's functionality, or close it when re-clicked. */
|
|
1422
|
+
const selectConstruct = useCallback((alias) => {
|
|
1423
|
+
setSelectedConstructAlias((cur) => (cur === alias ? null : alias));
|
|
1424
|
+
}, []);
|
|
1425
|
+
// Scoped to this canvas so the reused component nodes dispatch into this
|
|
1426
|
+
// graph's selection instead of a mounted component graph's module callbacks.
|
|
1427
|
+
const subsystemCallbacks = useMemo(() => ({ onSelect: selectConstruct }), [selectConstruct]);
|
|
1003
1428
|
const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
|
|
1429
|
+
/** The rolled-up line under selection, when it is an edge. */
|
|
1430
|
+
const selectedEdge = useMemo(() => model.edges.find((e) => e.id === selectedEdgeId) ?? null, [model, selectedEdgeId]);
|
|
1431
|
+
/**
|
|
1432
|
+
* The step under persistent focus, resolved from the construct graph's trails
|
|
1433
|
+
* (the drawer shows its implementation). The host owns the selection.
|
|
1434
|
+
*/
|
|
1435
|
+
const focusedStepInfo = useMemo(() => {
|
|
1436
|
+
if (!focusedStep)
|
|
1437
|
+
return null;
|
|
1438
|
+
for (const cg of constructGraphByContainer?.values() ?? []) {
|
|
1439
|
+
const trail = cg.trails.find((t) => t.id === focusedStep.trailId);
|
|
1440
|
+
if (!trail)
|
|
1441
|
+
continue;
|
|
1442
|
+
const step = trail.steps[focusedStep.stepIndex];
|
|
1443
|
+
if (!step)
|
|
1444
|
+
continue;
|
|
1445
|
+
return { trail, step, stepIndex: focusedStep.stepIndex };
|
|
1446
|
+
}
|
|
1447
|
+
return null;
|
|
1448
|
+
}, [focusedStep, constructGraphByContainer]);
|
|
1449
|
+
// Frame the focused step's constructs, the way the component graph frames a
|
|
1450
|
+
// focused step's edge: box the `from`/`to` node rects (absolute) and fit.
|
|
1451
|
+
// Waits out the drawer's height transition so the fit uses the reduced canvas.
|
|
1452
|
+
useEffect(() => {
|
|
1453
|
+
if (!focusedStepInfo)
|
|
1454
|
+
return;
|
|
1455
|
+
const rects = [];
|
|
1456
|
+
for (const id of [focusedStepInfo.step.from, focusedStepInfo.step.to]) {
|
|
1457
|
+
const r = absoluteRectOf({ nodes: nodesRef.current, groupBounds: new Map() }, id);
|
|
1458
|
+
if (r)
|
|
1459
|
+
rects.push(r);
|
|
1460
|
+
}
|
|
1461
|
+
if (rects.length === 0)
|
|
1462
|
+
return;
|
|
1463
|
+
const minX = Math.min(...rects.map((r) => r.x));
|
|
1464
|
+
const minY = Math.min(...rects.map((r) => r.y));
|
|
1465
|
+
const maxX = Math.max(...rects.map((r) => r.x + r.width));
|
|
1466
|
+
const maxY = Math.max(...rects.map((r) => r.y + r.height));
|
|
1467
|
+
const bounds = {
|
|
1468
|
+
x: minX,
|
|
1469
|
+
y: minY,
|
|
1470
|
+
width: Math.max(1, maxX - minX),
|
|
1471
|
+
height: Math.max(1, maxY - minY),
|
|
1472
|
+
};
|
|
1473
|
+
const timer = setTimeout(() => {
|
|
1474
|
+
requestAnimationFrame(() => fitBounds(bounds, { padding: 0.2, duration: MORPH_MS }));
|
|
1475
|
+
}, FILE_DRAWER_HEIGHT_MS + 20);
|
|
1476
|
+
return () => clearTimeout(timer);
|
|
1477
|
+
// `nodesRef` is a ref; `settled` re-fires once the constructs have landed.
|
|
1478
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1479
|
+
}, [focusedStepInfo, settled, fitBounds]);
|
|
1480
|
+
/**
|
|
1481
|
+
* The construct under selection, folded to what the functionality panel
|
|
1482
|
+
* shows. Resolved across every container's construct graph (aliases are unique
|
|
1483
|
+
* in the document, so at most one matches).
|
|
1484
|
+
*/
|
|
1485
|
+
const selectedConstruct = useMemo(() => {
|
|
1486
|
+
if (!selectedConstructAlias)
|
|
1487
|
+
return null;
|
|
1488
|
+
for (const cg of constructGraphByContainer?.values() ?? []) {
|
|
1489
|
+
const c = cg.components.find((x) => x.alias === selectedConstructAlias);
|
|
1490
|
+
if (!c)
|
|
1491
|
+
continue;
|
|
1492
|
+
return {
|
|
1493
|
+
component: c,
|
|
1494
|
+
name: deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype),
|
|
1495
|
+
badge: constructBadgeLabel(c),
|
|
1496
|
+
color: componentColor(c, resolvePierreSyntaxThemeName(mode)),
|
|
1497
|
+
role: rightBadgeLabel(c),
|
|
1498
|
+
declaration: generateDeclarationString(c),
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
return null;
|
|
1502
|
+
}, [selectedConstructAlias, constructGraphByContainer, mode]);
|
|
1004
1503
|
/**
|
|
1005
1504
|
* The components authored inside the selected element, if it is a container.
|
|
1006
1505
|
* These are `C4Component`s whose `container` names the selected id (or, once
|
|
@@ -1011,47 +1510,205 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1011
1510
|
? model.nodes.filter((n) => n.kind === 'component' &&
|
|
1012
1511
|
(n.container === selected.id || n.parentId === selected.id))
|
|
1013
1512
|
: [], [model, selected]);
|
|
1513
|
+
/**
|
|
1514
|
+
* A selected container's member aliases, resolved from the construct graph
|
|
1515
|
+
* (a container's members are derived, not stored — see `C4ElementBase`). Only
|
|
1516
|
+
* populated when the container hosts focused constructs.
|
|
1517
|
+
*/
|
|
1518
|
+
const selectedContainerMembers = useMemo(() => selected?.kind === 'container'
|
|
1519
|
+
? (constructGraphByContainer?.get(selected.id)?.components ?? []).map((c) => c.alias)
|
|
1520
|
+
: [], [selected, constructGraphByContainer]);
|
|
1014
1521
|
/** The containers open for drill-down, normalized. */
|
|
1015
1522
|
const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
|
|
1016
1523
|
const displayNodes = useMemo(() => nodes.map((n) => {
|
|
1524
|
+
// A card kept only to fade out is never hidden or dimmed — it is on its
|
|
1525
|
+
// way off screen and must stay visible for the fade.
|
|
1526
|
+
if (n.data?.leaving)
|
|
1527
|
+
return n;
|
|
1528
|
+
// Constructs are the component graph's own nodes. They ride the morph
|
|
1529
|
+
// gate of the container that hosts them (they arrive with its grow) and
|
|
1530
|
+
// dim with it; the node reads `isSelected` from data because it stops
|
|
1531
|
+
// its own click propagation.
|
|
1532
|
+
if (n.type === 'subsystem-component') {
|
|
1533
|
+
const owner = n.data?.c4Owner;
|
|
1534
|
+
const hidden = hidesComponentDuringMorph(owner, openContainerIds, morphTargetsRef.current, settled);
|
|
1535
|
+
// Dim with its container, or on its own when the host spotlights a
|
|
1536
|
+
// step's constructs (the component graph's per-component behaviour).
|
|
1537
|
+
const ownerDimmed = dimmedElementIds && owner ? dimmedElementIds.has(owner) : false;
|
|
1538
|
+
const stepDimmed = dimmedConstructIds?.has(n.id) === true;
|
|
1539
|
+
return {
|
|
1540
|
+
...n,
|
|
1541
|
+
...(hidden ? { hidden: true } : {}),
|
|
1542
|
+
data: {
|
|
1543
|
+
...n.data,
|
|
1544
|
+
dimmed: ownerDimmed || stepDimmed,
|
|
1545
|
+
isSelected: n.id === selectedConstructAlias,
|
|
1546
|
+
},
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1017
1549
|
if (n.type !== 'c4-node')
|
|
1018
1550
|
return n;
|
|
1019
1551
|
const element = n.data?.element;
|
|
1020
1552
|
const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
|
|
1021
|
-
// `hidesComponentDuringMorph` owns the rule; see there for why it
|
|
1022
|
-
// per-container rather than global.
|
|
1023
|
-
|
|
1553
|
+
// `hidesComponentDuringMorph` owns the morph rule; see there for why it
|
|
1554
|
+
// is per-container rather than global. A host `hiddenElementIds` (the
|
|
1555
|
+
// focused-trail cast) removes the box outright.
|
|
1556
|
+
const hidden = hidesComponentDuringMorph(owner, openContainerIds, morphTargetsRef.current, settled) || (element ? hiddenElementIds?.has(element.id) === true : false);
|
|
1024
1557
|
return {
|
|
1025
1558
|
...n,
|
|
1026
1559
|
...(hidden ? { hidden: true } : {}),
|
|
1027
|
-
data: {
|
|
1560
|
+
data: {
|
|
1561
|
+
...n.data,
|
|
1562
|
+
selected: n.id === selectedId,
|
|
1563
|
+
settled,
|
|
1564
|
+
dimmed: dimmedElementIds
|
|
1565
|
+
? (element ? dimmedElementIds.has(element.id) : false)
|
|
1566
|
+
: false,
|
|
1567
|
+
},
|
|
1028
1568
|
};
|
|
1029
1569
|
}),
|
|
1030
1570
|
// `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
|
|
1031
1571
|
// it, because the two always change together — targets are written in the same
|
|
1032
1572
|
// tick that clears `settled`, and cleared in the same tick that restores it.
|
|
1033
|
-
[
|
|
1573
|
+
[
|
|
1574
|
+
nodes,
|
|
1575
|
+
selectedId,
|
|
1576
|
+
settled,
|
|
1577
|
+
openContainerIds,
|
|
1578
|
+
dimmedElementIds,
|
|
1579
|
+
dimmedConstructIds,
|
|
1580
|
+
hiddenElementIds,
|
|
1581
|
+
selectedConstructAlias,
|
|
1582
|
+
]);
|
|
1034
1583
|
/**
|
|
1035
1584
|
* An edge's polyline is routed for the *settled* layout, so while the cards are
|
|
1036
1585
|
* gliding between the two slots the old lines would visibly point at the wrong
|
|
1037
1586
|
* boxes. Hiding them for the morph is honest and cheap; interpolating the
|
|
1038
1587
|
* polylines to match is not.
|
|
1588
|
+
*
|
|
1589
|
+
* The dim set is the union of
|
|
1590
|
+
* (a) the own endpoint rule — a line between two dimmed boxes fades with
|
|
1591
|
+
* them, a line into a lit container stays bright on its own; and
|
|
1592
|
+
* (b) the host's line-relevance set — edges that don't carry the focused
|
|
1593
|
+
* trail / hovered step (a step internal to a container folds into no
|
|
1594
|
+
* line at all, so even lines out of the lit container fade).
|
|
1595
|
+
*
|
|
1596
|
+
* The selected edge never dims: it is the inspector's subject.
|
|
1039
1597
|
*/
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1598
|
+
const dimmedEdgeIdSet = useMemo(() => {
|
|
1599
|
+
if (!dimmedElementIds && !dimmedEdgeIds)
|
|
1600
|
+
return null;
|
|
1601
|
+
const out = new Set();
|
|
1602
|
+
if (dimmedElementIds) {
|
|
1603
|
+
for (const e of rfEdges) {
|
|
1604
|
+
if (dimmedElementIds.has(e.source) && dimmedElementIds.has(e.target))
|
|
1605
|
+
out.add(e.id);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if (dimmedEdgeIds)
|
|
1609
|
+
for (const id of dimmedEdgeIds)
|
|
1610
|
+
out.add(id);
|
|
1611
|
+
if (selectedEdgeId)
|
|
1612
|
+
out.delete(selectedEdgeId);
|
|
1613
|
+
return out;
|
|
1614
|
+
}, [dimmedElementIds, dimmedEdgeIds, rfEdges, selectedEdgeId]);
|
|
1615
|
+
const hiddenEdgeIdSet = useMemo(() => {
|
|
1616
|
+
if (!hiddenElementIds && !hiddenEdgeIds)
|
|
1617
|
+
return null;
|
|
1618
|
+
const out = new Set();
|
|
1619
|
+
if (hiddenElementIds) {
|
|
1620
|
+
// Either endpoint gone → the line has nothing to attach to, so it goes
|
|
1621
|
+
// with the box (unlike the dim rule, which needs both endpoints dimmed).
|
|
1622
|
+
for (const e of rfEdges) {
|
|
1623
|
+
if (hiddenElementIds.has(e.source) || hiddenElementIds.has(e.target))
|
|
1624
|
+
out.add(e.id);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
if (hiddenEdgeIds)
|
|
1628
|
+
for (const id of hiddenEdgeIds)
|
|
1629
|
+
out.add(id);
|
|
1630
|
+
return out;
|
|
1631
|
+
}, [hiddenElementIds, hiddenEdgeIds, rfEdges]);
|
|
1632
|
+
const displayEdges = useMemo(() => rfEdges.map((e) => {
|
|
1633
|
+
const hidden = !settled || hiddenEdgeIdSet?.has(e.id) === true;
|
|
1634
|
+
const dimmed = dimmedEdgeIdSet?.has(e.id) === true ||
|
|
1635
|
+
dimmedConstructEdgeIds?.has(e.id) === true;
|
|
1636
|
+
// The arrowhead is a shared SVG marker: dimming the stroke (path opacity
|
|
1637
|
+
// or colour alpha) leaves it at full colour. Clone the marker with a
|
|
1638
|
+
// dimmed colour, as the component graph does.
|
|
1639
|
+
const markerEnd = e.markerEnd;
|
|
1640
|
+
const nextMarker = dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
1641
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
1642
|
+
: markerEnd;
|
|
1643
|
+
return {
|
|
1644
|
+
...e,
|
|
1645
|
+
...(hidden ? { hidden: true } : {}),
|
|
1646
|
+
markerEnd: nextMarker,
|
|
1647
|
+
data: {
|
|
1648
|
+
...e.data,
|
|
1649
|
+
selected: e.id === selectedEdgeId,
|
|
1650
|
+
dimmed,
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
}), [rfEdges, settled, selectedEdgeId, dimmedEdgeIdSet, hiddenEdgeIdSet, dimmedConstructEdgeIds]);
|
|
1654
|
+
/** The construct edges, for their mechanism-label overlay (carries the dim flag). */
|
|
1655
|
+
const subsystemLabelEdges = useMemo(() => displayEdges.filter((e) => e.type === 'subsystem-edge'), [displayEdges]);
|
|
1656
|
+
// The step drawer: open while a step is focused and a viewer is not hidden.
|
|
1657
|
+
const drawerOpen = focusedStepInfo != null && !hideDrawer;
|
|
1658
|
+
const drawerTitle = focusedStepInfo
|
|
1659
|
+
? renderTrailViewer
|
|
1660
|
+
? focusedStepInfo.trail.title
|
|
1661
|
+
: focusedStepInfo.step.file
|
|
1662
|
+
: null;
|
|
1663
|
+
const drawerProgress = focusedStepInfo && focusedStepInfo.trail.steps.length > 1
|
|
1664
|
+
? { index: focusedStepInfo.stepIndex, total: focusedStepInfo.trail.steps.length }
|
|
1665
|
+
: null;
|
|
1666
|
+
// The selection panel floats over the canvas in the top-right corner. A host
|
|
1667
|
+
// that wants the panel (or anything else) beside the graph wraps the graph in
|
|
1668
|
+
// `GraphWithSidebar` itself, so the graph stays overlay-only.
|
|
1669
|
+
const selectionPanelStyle = {
|
|
1670
|
+
position: 'absolute',
|
|
1671
|
+
top: 12,
|
|
1672
|
+
right: 12,
|
|
1673
|
+
// Above the edge-label overlay (zIndex 5), or chips paint over the panel.
|
|
1674
|
+
zIndex: 10,
|
|
1675
|
+
width: 280,
|
|
1676
|
+
maxHeight: 'calc(100% - 24px)',
|
|
1677
|
+
overflowY: 'auto',
|
|
1678
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1679
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
1680
|
+
borderRadius: 8,
|
|
1681
|
+
padding: '10px 12px',
|
|
1682
|
+
};
|
|
1683
|
+
const canvasRoot = (_jsxs(_Fragment, { children: [_jsx(GraphLayerStyle, {}), _jsx(SubsystemCallbacksProvider, { value: subsystemCallbacks, children: _jsx(ReactFlow, { nodes: displayNodes, edges: displayEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS, ...GRAPH_NAV_PROPS, onNodeClick: (_e, node) => {
|
|
1684
|
+
if (node.type !== 'c4-node')
|
|
1685
|
+
return;
|
|
1686
|
+
const next = node.id === selectedId ? null : node.id;
|
|
1687
|
+
setSelectedId(next);
|
|
1688
|
+
setSelectedEdgeId(null);
|
|
1689
|
+
setSelectedConstructAlias(null);
|
|
1690
|
+
onSelectNode?.(next);
|
|
1691
|
+
onSelectEdge?.(null);
|
|
1692
|
+
}, onEdgeClick: (_e, edge) => {
|
|
1693
|
+
const next = edge.id === selectedEdgeId ? null : edge.id;
|
|
1694
|
+
setSelectedEdgeId(next);
|
|
1695
|
+
// An edge selection replaces the node panel — the two inspectors
|
|
1696
|
+
// sharing the corner would fight for it.
|
|
1697
|
+
setSelectedId(null);
|
|
1698
|
+
setSelectedConstructAlias(null);
|
|
1699
|
+
onSelectNode?.(null);
|
|
1700
|
+
onSelectEdge?.(next ? (model.edges.find((e) => e.id === next) ?? null) : null);
|
|
1701
|
+
}, onPaneClick: () => {
|
|
1702
|
+
setSelectedId(null);
|
|
1703
|
+
setSelectedEdgeId(null);
|
|
1704
|
+
setSelectedConstructAlias(null);
|
|
1705
|
+
onSelectNode?.(null);
|
|
1706
|
+
onSelectEdge?.(null);
|
|
1707
|
+
}, proOptions: { hideAttribution: true },
|
|
1708
|
+
// Pin the flow to its container. The label overlay is a sibling sized
|
|
1709
|
+
// `inset: 0`, so the two must share an origin or `viewport.y` (measured
|
|
1710
|
+
// in the flow's own box) lands the chips off vertically.
|
|
1711
|
+
style: { width: '100%', height: '100%' }, children: _jsx(GraphChrome, {}) }) }), ready && settled && labelPositions.size > 0 && (_jsx(C4EdgeLabels, { positions: labelPositions, edges: model.edges, selectedEdgeId: selectedEdgeId, dimmedEdgeIds: dimmedEdgeIdSet, hiddenEdgeIds: hiddenEdgeIdSet })), ready && settled && subsystemLabelEdges.length > 0 && (_jsx(SubsystemEdgeLabels, { edges: subsystemLabelEdges })), !ready && (_jsx("div", { style: {
|
|
1055
1712
|
position: 'absolute',
|
|
1056
1713
|
inset: 0,
|
|
1057
1714
|
display: 'flex',
|
|
@@ -1061,21 +1718,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1061
1718
|
color: muted,
|
|
1062
1719
|
fontFamily: theme.fonts.monospace,
|
|
1063
1720
|
fontSize: theme.fontSizes[0],
|
|
1064
|
-
}, children: "Laying out C4 view\u2026" }))
|
|
1065
|
-
|
|
1066
|
-
top: 12,
|
|
1067
|
-
right: 12,
|
|
1068
|
-
// Above the edge-label overlay (zIndex 5), or chips paint over the
|
|
1069
|
-
// panel.
|
|
1070
|
-
zIndex: 10,
|
|
1071
|
-
width: 280,
|
|
1072
|
-
maxHeight: 'calc(100% - 24px)',
|
|
1073
|
-
overflowY: 'auto',
|
|
1074
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1075
|
-
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
1076
|
-
borderRadius: 8,
|
|
1077
|
-
padding: '10px 12px',
|
|
1078
|
-
}, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { style: { fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: selected.label }), _jsx("button", { type: "button", onClick: () => {
|
|
1721
|
+
}, children: "Laying out C4 view\u2026" }))] }));
|
|
1722
|
+
const selectionPanels = (_jsxs(_Fragment, { children: [selected && (_jsxs("div", { style: selectionPanelStyle, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { style: { fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: selected.label }), _jsx("button", { type: "button", onClick: () => {
|
|
1079
1723
|
setSelectedId(null);
|
|
1080
1724
|
onSelectNode?.(null);
|
|
1081
1725
|
}, "aria-label": "Clear selection", style: { border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }, children: "\u00D7" })] }), selected.kind !== 'person' &&
|
|
@@ -1121,7 +1765,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1121
1765
|
overflow: 'hidden',
|
|
1122
1766
|
textOverflow: 'ellipsis',
|
|
1123
1767
|
whiteSpace: 'nowrap',
|
|
1124
|
-
}, children: [
|
|
1768
|
+
}, children: [c.technology || c.kind, (c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''] })] }, c.id)))] })), selected.kind === 'container' && selectedComponents.length === 0 && (_jsxs("div", { style: {
|
|
1125
1769
|
display: 'flex',
|
|
1126
1770
|
flexDirection: 'column',
|
|
1127
1771
|
gap: 6,
|
|
@@ -1133,7 +1777,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1133
1777
|
color: muted,
|
|
1134
1778
|
textTransform: 'uppercase',
|
|
1135
1779
|
letterSpacing: 0.5,
|
|
1136
|
-
}, children: "Components" }),
|
|
1780
|
+
}, children: "Components" }), selectedContainerMembers.length > 0 ? (selectedContainerMembers.map((alias) => (_jsx("span", { title: alias, style: {
|
|
1137
1781
|
fontFamily: theme.fonts.monospace,
|
|
1138
1782
|
fontSize: theme.fontSizes[0],
|
|
1139
1783
|
color: theme.colors.text,
|
|
@@ -1144,7 +1788,139 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1144
1788
|
fontFamily: theme.fonts.monospace,
|
|
1145
1789
|
fontSize: theme.fontSizes[0],
|
|
1146
1790
|
color: muted,
|
|
1147
|
-
}, children: "No components" }))] }))] }))
|
|
1791
|
+
}, children: "No components" }))] }))] })), selectedEdge && ready && (_jsxs("div", { style: selectionPanelStyle, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { "aria-hidden": true, style: {
|
|
1792
|
+
width: 8,
|
|
1793
|
+
height: 8,
|
|
1794
|
+
borderRadius: '50%',
|
|
1795
|
+
background: protocolColor(selectedEdge.protocol),
|
|
1796
|
+
flexShrink: 0,
|
|
1797
|
+
} }), _jsx("span", { style: {
|
|
1798
|
+
fontFamily: theme.fonts.monospace,
|
|
1799
|
+
fontWeight: 600,
|
|
1800
|
+
color: theme.colors.text,
|
|
1801
|
+
flex: 1,
|
|
1802
|
+
minWidth: 0,
|
|
1803
|
+
overflow: 'hidden',
|
|
1804
|
+
textOverflow: 'ellipsis',
|
|
1805
|
+
whiteSpace: 'nowrap',
|
|
1806
|
+
}, children: selectedEdge.label }), _jsx("button", { type: "button", onClick: () => {
|
|
1807
|
+
setSelectedEdgeId(null);
|
|
1808
|
+
onSelectEdge?.(null);
|
|
1809
|
+
}, "aria-label": "Clear selection", style: { border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }, children: "\u00D7" })] }), _jsxs("div", { style: {
|
|
1810
|
+
display: 'flex',
|
|
1811
|
+
alignItems: 'center',
|
|
1812
|
+
gap: 6,
|
|
1813
|
+
flexWrap: 'wrap',
|
|
1814
|
+
marginTop: 6,
|
|
1815
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
1816
|
+
paddingTop: 6,
|
|
1817
|
+
}, children: [selectedEdge.mechanisms.map((m) => (_jsx("span", { style: {
|
|
1818
|
+
fontFamily: theme.fonts.monospace,
|
|
1819
|
+
fontSize: theme.fontSizes[0],
|
|
1820
|
+
color: theme.colors.text,
|
|
1821
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
1822
|
+
borderRadius: 4,
|
|
1823
|
+
padding: '1px 6px',
|
|
1824
|
+
}, children: m }, m))), _jsxs("span", { style: { fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }, children: [selectedEdge.count, " ", selectedEdge.count === 1 ? 'step' : 'steps'] })] }), _jsxs("div", { style: {
|
|
1825
|
+
display: 'flex',
|
|
1826
|
+
flexDirection: 'column',
|
|
1827
|
+
gap: 4,
|
|
1828
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
1829
|
+
paddingTop: 6,
|
|
1830
|
+
marginTop: 6,
|
|
1831
|
+
}, children: [_jsx("span", { style: {
|
|
1832
|
+
fontFamily: theme.fonts.monospace,
|
|
1833
|
+
fontSize: theme.fontSizes[0],
|
|
1834
|
+
color: muted,
|
|
1835
|
+
textTransform: 'uppercase',
|
|
1836
|
+
letterSpacing: 0.5,
|
|
1837
|
+
}, children: "Backed by trails" }), (selectedEdge.trailRefs ?? []).length > 0 ? ((selectedEdge.trailRefs ?? []).map((ref) => (_jsxs("button", { type: "button", onClick: () => onTrailClick?.(ref), disabled: !onTrailClick, title: onTrailClick
|
|
1838
|
+
? `Focus this trail in the component graph`
|
|
1839
|
+
: 'Trail drill-down needs onTrailClick', style: {
|
|
1840
|
+
display: 'flex',
|
|
1841
|
+
alignItems: 'center',
|
|
1842
|
+
gap: 6,
|
|
1843
|
+
textAlign: 'left',
|
|
1844
|
+
border: 'none',
|
|
1845
|
+
background: 'transparent',
|
|
1846
|
+
padding: 0,
|
|
1847
|
+
cursor: onTrailClick ? 'pointer' : 'default',
|
|
1848
|
+
fontFamily: theme.fonts.monospace,
|
|
1849
|
+
fontSize: theme.fontSizes[0],
|
|
1850
|
+
}, children: [_jsx("span", { style: { color: theme.colors.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: ref.trailId }), _jsxs("span", { style: { color: muted, flexShrink: 0 }, children: [ref.stepIndices.length, " ", ref.stepIndices.length === 1 ? 'step' : 'steps', ref.stepIndices.length > 0 ? ` · #${ref.stepIndices.map((i) => i + 1).join(', #')}` : ''] })] }, ref.trailId)))) : (_jsx("span", { style: { fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }, children: "Authored line \u2014 no trail provenance yet" }))] })] })), selectedConstruct && (_jsxs("div", { style: {
|
|
1851
|
+
...selectionPanelStyle,
|
|
1852
|
+
top: 'auto',
|
|
1853
|
+
bottom: 12,
|
|
1854
|
+
left: 12,
|
|
1855
|
+
right: 'auto',
|
|
1856
|
+
width: 320,
|
|
1857
|
+
maxHeight: 'calc(50% - 18px)',
|
|
1858
|
+
}, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { "aria-hidden": true, style: {
|
|
1859
|
+
width: 8,
|
|
1860
|
+
height: 8,
|
|
1861
|
+
borderRadius: '50%',
|
|
1862
|
+
background: selectedConstruct.color,
|
|
1863
|
+
flexShrink: 0,
|
|
1864
|
+
} }), _jsx("span", { style: {
|
|
1865
|
+
fontFamily: theme.fonts.monospace,
|
|
1866
|
+
fontWeight: 600,
|
|
1867
|
+
color: theme.colors.text,
|
|
1868
|
+
flex: 1,
|
|
1869
|
+
minWidth: 0,
|
|
1870
|
+
overflow: 'hidden',
|
|
1871
|
+
textOverflow: 'ellipsis',
|
|
1872
|
+
whiteSpace: 'nowrap',
|
|
1873
|
+
}, children: selectedConstruct.name }), _jsx("span", { style: {
|
|
1874
|
+
flexShrink: 0,
|
|
1875
|
+
fontFamily: theme.fonts.monospace,
|
|
1876
|
+
fontSize: theme.fontSizes[0],
|
|
1877
|
+
color: muted,
|
|
1878
|
+
textTransform: 'uppercase',
|
|
1879
|
+
letterSpacing: 0.4,
|
|
1880
|
+
}, children: selectedConstruct.badge }), _jsx("button", { type: "button", onClick: () => setSelectedConstructAlias(null), "aria-label": "Clear construct", style: { border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }, children: "\u00D7" })] }), selectedConstruct.component.purpose && (_jsx("div", { style: {
|
|
1881
|
+
marginTop: 6,
|
|
1882
|
+
fontFamily: theme.fonts.body,
|
|
1883
|
+
fontSize: theme.fontSizes[0],
|
|
1884
|
+
color: theme.colors.text,
|
|
1885
|
+
lineHeight: 1.4,
|
|
1886
|
+
}, children: selectedConstruct.component.purpose })), selectedConstruct.declaration && (_jsx("pre", { style: {
|
|
1887
|
+
marginTop: 6,
|
|
1888
|
+
marginBottom: 0,
|
|
1889
|
+
padding: '6px 8px',
|
|
1890
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
1891
|
+
background: theme.colors.background ?? 'transparent',
|
|
1892
|
+
color: theme.colors.text,
|
|
1893
|
+
fontFamily: theme.fonts.monospace,
|
|
1894
|
+
fontSize: theme.fontSizes[0],
|
|
1895
|
+
lineHeight: 1.45,
|
|
1896
|
+
whiteSpace: 'pre-wrap',
|
|
1897
|
+
overflowWrap: 'anywhere',
|
|
1898
|
+
}, children: selectedConstruct.declaration })), _jsxs("div", { style: {
|
|
1899
|
+
marginTop: 6,
|
|
1900
|
+
fontFamily: theme.fonts.monospace,
|
|
1901
|
+
fontSize: theme.fontSizes[0],
|
|
1902
|
+
color: muted,
|
|
1903
|
+
}, children: [selectedConstruct.component.alias, selectedConstruct.role ? ` · ${selectedConstruct.role}` : '', selectedConstruct.component.proposed ? ' · proposed' : ''] })] }))] }));
|
|
1904
|
+
return (_jsxs("div", { style: {
|
|
1905
|
+
position: 'relative',
|
|
1906
|
+
width: '100%',
|
|
1907
|
+
height: '100%',
|
|
1908
|
+
display: 'flex',
|
|
1909
|
+
flexDirection: 'column',
|
|
1910
|
+
background: theme.colors.background,
|
|
1911
|
+
}, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minHeight: 0 }, children: [canvasRoot, selectionPanels] }), _jsx(FileDrawer, { title: drawerOpen ? drawerTitle : null, onClose: () => onCloseStep?.(), hidden: hideDrawer, progress: drawerProgress, children: drawerOpen && focusedStepInfo
|
|
1912
|
+
? renderTrailViewer
|
|
1913
|
+
? renderTrailViewer({
|
|
1914
|
+
trail: focusedStepInfo.trail,
|
|
1915
|
+
stepIndex: focusedStepInfo.stepIndex,
|
|
1916
|
+
})
|
|
1917
|
+
: renderFileViewer
|
|
1918
|
+
? renderFileViewer(focusedStepInfo.step.file, {
|
|
1919
|
+
startLine: focusedStepInfo.step.span?.startLine,
|
|
1920
|
+
endLine: focusedStepInfo.step.span?.endLine,
|
|
1921
|
+
})
|
|
1922
|
+
: _jsx(StepDrawerFallback, { step: focusedStepInfo.step })
|
|
1923
|
+
: null })] }));
|
|
1148
1924
|
}
|
|
1149
1925
|
export function C4Graph(props) {
|
|
1150
1926
|
return (_jsx(ReactFlowProvider, { children: _jsx(Inner, { ...props }) }));
|