@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
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import type { ComponentType, CSSProperties, ReactNode } from 'react';
|
|
15
16
|
import {
|
|
16
17
|
Edge,
|
|
17
18
|
EdgeProps,
|
|
@@ -33,6 +34,9 @@ import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLa
|
|
|
33
34
|
import { deriveC4Groups, protocolColor, type C4Group } from './c4';
|
|
34
35
|
import {
|
|
35
36
|
C4NodeCard,
|
|
37
|
+
COMPONENT_FOOTER_PAD,
|
|
38
|
+
COMPONENT_HEADER_PAD,
|
|
39
|
+
COMPONENT_W,
|
|
36
40
|
NODE_H,
|
|
37
41
|
NODE_W,
|
|
38
42
|
OPEN_FOOTER_PAD,
|
|
@@ -44,12 +48,131 @@ import {
|
|
|
44
48
|
import { TechMark, technologyBrand } from './techIcons';
|
|
45
49
|
import type { TechBrand } from './techIcons';
|
|
46
50
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
47
|
-
import type { C4Element, C4Model } from './c4';
|
|
51
|
+
import type { C4Component, C4Edge, C4Element, C4Model, C4TrailRef } from './c4';
|
|
48
52
|
import { openContainerSet, openStateKey } from './c4';
|
|
53
|
+
import {
|
|
54
|
+
EDGE_DIM_ALPHA,
|
|
55
|
+
hexWithAlpha,
|
|
56
|
+
SubsystemCallbacksProvider,
|
|
57
|
+
SubsystemComponentNode,
|
|
58
|
+
SubsystemEdge,
|
|
59
|
+
} from './nodes';
|
|
60
|
+
import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
|
|
61
|
+
import {
|
|
62
|
+
constructBadgeLabel,
|
|
63
|
+
convertSubsystemToEdges,
|
|
64
|
+
convertSubsystemToNodes,
|
|
65
|
+
deriveNameFromSymbol,
|
|
66
|
+
edgeColor,
|
|
67
|
+
MECHANISM_DESCRIPTIONS,
|
|
68
|
+
rightBadgeLabel,
|
|
69
|
+
} from './model';
|
|
70
|
+
import type { SubsystemComponent, SubsystemEdgeProvenance, SubsystemTrail } from './model';
|
|
71
|
+
import { generateDeclarationString } from './formatDeclaration';
|
|
72
|
+
import { componentColor } from '../pierre/constructColors';
|
|
73
|
+
import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The construct-level subgraph a C4 container hosts when it is opened — the
|
|
77
|
+
* component graph's own material (subsystem components + trail steps), so the
|
|
78
|
+
* constructs render as real `subsystem-component` nodes and `subsystem-edge`
|
|
79
|
+
* edges rather than a summary. Host-resolved and keyed by container id, so the
|
|
80
|
+
* C4 view stays free of the document.
|
|
81
|
+
*
|
|
82
|
+
* The hierarchy it expresses is container → C4 component → construct: a
|
|
83
|
+
* construct whose alias an authored component claims nests under that
|
|
84
|
+
* component (which grows into an ELK parent); one no component claims nests
|
|
85
|
+
* directly under the container.
|
|
86
|
+
*/
|
|
87
|
+
export interface C4ConstructGraph {
|
|
88
|
+
/** The document components to draw inside this container. */
|
|
89
|
+
components: readonly SubsystemComponent[];
|
|
90
|
+
/** The trails whose steps become the construct edges (restrict to this container). */
|
|
91
|
+
trails: readonly SubsystemTrail[];
|
|
92
|
+
/** Construct alias → the authored C4 component element id that claims it. */
|
|
93
|
+
componentOf?: ReadonlyMap<string, string>;
|
|
94
|
+
}
|
|
49
95
|
|
|
50
96
|
export interface C4GraphProps {
|
|
51
97
|
model: C4Model;
|
|
52
98
|
onSelectNode?: (id: string | null) => void;
|
|
99
|
+
/**
|
|
100
|
+
* Click an edge. Fires with the rolled-up line (and its `trailRefs`
|
|
101
|
+
* provenance when a builder supplied it), or `null` when the selection is
|
|
102
|
+
* cleared — pane click, or clicking a node. This is the drill-down
|
|
103
|
+
* affordance: the host switches the component graph to the contributing
|
|
104
|
+
* trails.
|
|
105
|
+
*/
|
|
106
|
+
onSelectEdge?: (edge: C4Edge | null) => void;
|
|
107
|
+
/**
|
|
108
|
+
* Click a trail row in the edge inspector. Hands the ref back so the host
|
|
109
|
+
* can deep-link the component graph to that trail — e.g. via
|
|
110
|
+
* `SubsystemComponentGraph.initialTrailId`. Without this the refs render
|
|
111
|
+
* inert.
|
|
112
|
+
*/
|
|
113
|
+
onTrailClick?: (ref: C4TrailRef) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Element ids to fade — everything the focused trail does NOT touch. The
|
|
116
|
+
* host computes the set (trail step aliases → owning element via its
|
|
117
|
+
* membership map); this graph renders the named elements (and the lines
|
|
118
|
+
* between them) at low opacity so the players in the trail stay bright.
|
|
119
|
+
* `null` (or absent) renders everything at full weight.
|
|
120
|
+
*/
|
|
121
|
+
dimmedElementIds?: ReadonlySet<string> | null;
|
|
122
|
+
/**
|
|
123
|
+
* Edge ids to fade beyond the endpoint rule — the host's line-level
|
|
124
|
+
* relevance set (edges that do NOT carry the focused trail / hovered step).
|
|
125
|
+
* A step entirely internal to a container folds into no line, so the host
|
|
126
|
+
* can dim every edge even though lit containers remain. Unioned with the
|
|
127
|
+
* own both-endpoints-dimmed rule; the selected edge never dims.
|
|
128
|
+
*/
|
|
129
|
+
dimmedEdgeIds?: ReadonlySet<string> | null;
|
|
130
|
+
/**
|
|
131
|
+
* Element ids to remove from the canvas entirely — the focused trail's cast,
|
|
132
|
+
* the C4 analogue of the component graph's opened-flow hide. Unlike
|
|
133
|
+
* `dimmedElementIds` (faded but still drawn), these boxes are not rendered at
|
|
134
|
+
* all, and a line touching one is hidden with it. `null`/absent draws
|
|
135
|
+
* everything. Hover previews dim; an expanded trail hides non-members.
|
|
136
|
+
*/
|
|
137
|
+
hiddenElementIds?: ReadonlySet<string> | null;
|
|
138
|
+
/** Edge ids to remove entirely — lines the focused trail does not fold. */
|
|
139
|
+
hiddenEdgeIds?: ReadonlySet<string> | null;
|
|
140
|
+
/**
|
|
141
|
+
* Subsystem-component aliases to fade beyond the container rule — the
|
|
142
|
+
* construct-level spotlight for a hovered step. A construct whose alias is in
|
|
143
|
+
* the set dims even when its container is lit. Absent → constructs dim only
|
|
144
|
+
* with their container.
|
|
145
|
+
*/
|
|
146
|
+
dimmedConstructIds?: ReadonlySet<string> | null;
|
|
147
|
+
/** Subsystem edge ids to fade — the construct-level counterpart of `dimmedEdgeIds`. */
|
|
148
|
+
dimmedConstructEdgeIds?: ReadonlySet<string> | null;
|
|
149
|
+
/**
|
|
150
|
+
* The construct-level subgraph to draw inside each opened container — the
|
|
151
|
+
* component graph's subsystem nodes and edges, nested container → C4
|
|
152
|
+
* component → construct. The host resolves it from the model document and the
|
|
153
|
+
* focused trail (restricting edges to the container; a construct edge that
|
|
154
|
+
* crosses containers stays a C4 container-level line). Absent → the container
|
|
155
|
+
* opens as before, with no constructs.
|
|
156
|
+
*/
|
|
157
|
+
constructGraphByContainer?: ReadonlyMap<string, C4ConstructGraph>;
|
|
158
|
+
/**
|
|
159
|
+
* The step under persistent focus — the host sets it from a trails-panel step
|
|
160
|
+
* click. The C4 canvas opens a code drawer showing that step's implementation
|
|
161
|
+
* (the trail viewer when supplied, else the file at its line). `null` → no
|
|
162
|
+
* drawer.
|
|
163
|
+
*/
|
|
164
|
+
focusedStep?: { trailId: string; stepIndex: number } | null;
|
|
165
|
+
/** Close the step drawer — the host clears `focusedStep`. */
|
|
166
|
+
onCloseStep?: () => void;
|
|
167
|
+
/** Host code viewer for a step's file — the drawer's content. */
|
|
168
|
+
renderFileViewer?: (
|
|
169
|
+
file: string,
|
|
170
|
+
opts?: { startLine?: number; endLine?: number },
|
|
171
|
+
) => ReactNode;
|
|
172
|
+
/** Host trail viewer; when supplied it replaces the file viewer for a step. */
|
|
173
|
+
renderTrailViewer?: (ctx: { trail: SubsystemTrail; stepIndex: number | null }) => ReactNode;
|
|
174
|
+
/** Suppress the drawer entirely (embeds). */
|
|
175
|
+
hideDrawer?: boolean;
|
|
53
176
|
/**
|
|
54
177
|
* Drill-down: add `id` to `model.openContainerIds`, or remove it if it is
|
|
55
178
|
* already open. One verb for both directions, because with several containers
|
|
@@ -102,11 +225,35 @@ function C4NodeView(
|
|
|
102
225
|
/** FLIP: absolute flow rects — where this card was, and where it now is. */
|
|
103
226
|
growFrom?: FlowRect;
|
|
104
227
|
frameRect?: FlowRect;
|
|
228
|
+
/**
|
|
229
|
+
* True while the host's focused trail does not touch this element — the
|
|
230
|
+
* "players stay bright, the rest fades" emphasis layer. Applied to the
|
|
231
|
+
* whole card (badges and all), never to a single badge on it.
|
|
232
|
+
*/
|
|
233
|
+
dimmed?: boolean;
|
|
234
|
+
/** Arriving in this layout — fades in. */
|
|
235
|
+
entering?: boolean;
|
|
236
|
+
/** Gone from this layout — kept for the morph, fades out. */
|
|
237
|
+
leaving?: boolean;
|
|
238
|
+
/** The move/grow waits this long for the fade to finish, in ms. */
|
|
239
|
+
moveDelayMs?: number;
|
|
105
240
|
}>
|
|
106
241
|
>,
|
|
107
242
|
) {
|
|
108
|
-
const {
|
|
109
|
-
|
|
243
|
+
const {
|
|
244
|
+
element,
|
|
245
|
+
selected,
|
|
246
|
+
componentCount,
|
|
247
|
+
onExpand,
|
|
248
|
+
open,
|
|
249
|
+
settled,
|
|
250
|
+
growFrom,
|
|
251
|
+
frameRect,
|
|
252
|
+
dimmed,
|
|
253
|
+
entering,
|
|
254
|
+
leaving,
|
|
255
|
+
moveDelayMs = 0,
|
|
256
|
+
} = props.data;
|
|
110
257
|
const grown = open === true;
|
|
111
258
|
|
|
112
259
|
// Position-only FLIP. The card resizes through its own `width`/`height`
|
|
@@ -115,6 +262,11 @@ function C4NodeView(
|
|
|
115
262
|
// transition, so its resize is the card's own and nothing competes with it.
|
|
116
263
|
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
117
264
|
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
265
|
+
const fade = useFade(entering, leaving);
|
|
266
|
+
const transitions = [
|
|
267
|
+
...(fade.transition ? [fade.transition] : []),
|
|
268
|
+
...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
|
|
269
|
+
];
|
|
118
270
|
// The card takes its box from the React Flow node rather than from its own
|
|
119
271
|
// `nodeSize`, so on the opened container ELK's fitted size drives it — and
|
|
120
272
|
// because the element persists across the swap, the card's own width/height
|
|
@@ -133,24 +285,37 @@ function C4NodeView(
|
|
|
133
285
|
// compose without either fighting the other.
|
|
134
286
|
transformOrigin: 'top left',
|
|
135
287
|
transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
|
|
136
|
-
transition:
|
|
288
|
+
transition: transitions.length > 0 ? transitions.join(', ') : 'none',
|
|
289
|
+
opacity: fade.opacity,
|
|
137
290
|
}}
|
|
138
291
|
>
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
292
|
+
<div
|
|
293
|
+
style={{
|
|
294
|
+
// The dim is a view concern, deliberately below the FLIP transform
|
|
295
|
+
// (which owns position) so the two never fight. The whole card fades —
|
|
296
|
+
// if we ever want to keep a badge lit, that is a different element.
|
|
297
|
+
opacity: dimmed ? 0.22 : 1,
|
|
298
|
+
transition: 'opacity 220ms ease',
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<C4NodeCard
|
|
302
|
+
node={element}
|
|
303
|
+
selected={selected}
|
|
304
|
+
componentCount={componentCount ?? 0}
|
|
305
|
+
onExpand={onExpand}
|
|
306
|
+
width={props.width}
|
|
307
|
+
height={props.height}
|
|
308
|
+
showDescription={showDescriptionFor(open, settled)}
|
|
309
|
+
transitionDelayMs={moveDelayMs}
|
|
310
|
+
sizeTransitionMs={MORPH_MS}
|
|
311
|
+
handles={
|
|
312
|
+
<>
|
|
313
|
+
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
314
|
+
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
|
315
|
+
</>
|
|
316
|
+
}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
154
319
|
</div>
|
|
155
320
|
);
|
|
156
321
|
}
|
|
@@ -166,9 +331,15 @@ function C4NodeView(
|
|
|
166
331
|
function C4EdgeLabels({
|
|
167
332
|
positions,
|
|
168
333
|
edges,
|
|
334
|
+
selectedEdgeId,
|
|
335
|
+
dimmedEdgeIds,
|
|
336
|
+
hiddenEdgeIds,
|
|
169
337
|
}: {
|
|
170
338
|
positions: Map<string, { x: number; y: number }>;
|
|
171
339
|
edges: C4Model['edges'];
|
|
340
|
+
selectedEdgeId?: string | null;
|
|
341
|
+
dimmedEdgeIds?: ReadonlySet<string> | null;
|
|
342
|
+
hiddenEdgeIds?: ReadonlySet<string> | null;
|
|
172
343
|
}) {
|
|
173
344
|
const { theme } = useTheme();
|
|
174
345
|
const viewport = useViewport();
|
|
@@ -188,8 +359,11 @@ function C4EdgeLabels({
|
|
|
188
359
|
{[...positions].map(([id, p]) => {
|
|
189
360
|
const edge = byId.get(id);
|
|
190
361
|
if (!edge) return null;
|
|
362
|
+
if (hiddenEdgeIds?.has(edge.id)) return null;
|
|
191
363
|
const color = protocolColor(edge.protocol);
|
|
192
364
|
const text = edge.protocol ?? edge.label;
|
|
365
|
+
const selected = edge.id === selectedEdgeId;
|
|
366
|
+
const dimmed = dimmedEdgeIds?.has(edge.id) === true;
|
|
193
367
|
return (
|
|
194
368
|
<div
|
|
195
369
|
key={id}
|
|
@@ -206,6 +380,145 @@ function C4EdgeLabels({
|
|
|
206
380
|
height: EDGE_LABEL_HEIGHT,
|
|
207
381
|
boxSizing: 'border-box',
|
|
208
382
|
padding: '7px 8px',
|
|
383
|
+
// Fades with its line, on the same beat.
|
|
384
|
+
opacity: dimmed ? 0.25 : 1,
|
|
385
|
+
transition: 'opacity 220ms ease',
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
<span
|
|
389
|
+
aria-hidden
|
|
390
|
+
style={{
|
|
391
|
+
position: 'absolute',
|
|
392
|
+
inset: 0,
|
|
393
|
+
borderRadius: 4,
|
|
394
|
+
border: `0.5px solid ${color}`,
|
|
395
|
+
background: surface,
|
|
396
|
+
// The selected chip echoes the edge's heavier stroke.
|
|
397
|
+
outline: selected ? `1.5px solid ${color}` : 'none',
|
|
398
|
+
}}
|
|
399
|
+
/>
|
|
400
|
+
<span
|
|
401
|
+
style={{
|
|
402
|
+
position: 'relative',
|
|
403
|
+
maxWidth: '100%',
|
|
404
|
+
overflow: 'hidden',
|
|
405
|
+
textOverflow: 'ellipsis',
|
|
406
|
+
whiteSpace: 'nowrap',
|
|
407
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
408
|
+
lineHeight: 1,
|
|
409
|
+
fontFamily: theme.fonts.monospace,
|
|
410
|
+
fontWeight: selected ? 700 : 500,
|
|
411
|
+
color,
|
|
412
|
+
}}
|
|
413
|
+
>
|
|
414
|
+
{text}
|
|
415
|
+
</span>
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
})}
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Fallback drawer body when the host supplies no file/trail viewer: the step's
|
|
425
|
+
* seam — `from → to`, mechanism, site, symbol, and any annotation. A host with a
|
|
426
|
+
* real code viewer passes `renderFileViewer` / `renderTrailViewer` instead.
|
|
427
|
+
*/
|
|
428
|
+
function StepDrawerFallback({ step }: { step: SubsystemTrail['steps'][number] }) {
|
|
429
|
+
const { theme } = useTheme();
|
|
430
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
431
|
+
return (
|
|
432
|
+
<div
|
|
433
|
+
style={{
|
|
434
|
+
padding: 12,
|
|
435
|
+
display: 'flex',
|
|
436
|
+
flexDirection: 'column',
|
|
437
|
+
gap: 6,
|
|
438
|
+
fontFamily: theme.fonts.monospace,
|
|
439
|
+
fontSize: theme.fontSizes[0],
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
<div style={{ color: theme.colors.text }}>
|
|
443
|
+
{step.from} → {step.to}
|
|
444
|
+
</div>
|
|
445
|
+
<div style={{ color: muted }}>
|
|
446
|
+
{step.mechanism} · {step.file}:{step.span?.startLine ?? '?'}
|
|
447
|
+
</div>
|
|
448
|
+
{step.symbol && <div style={{ color: muted }}>{step.symbol}</div>}
|
|
449
|
+
{step.annotation && (
|
|
450
|
+
<div style={{ color: theme.colors.text, fontFamily: theme.fonts.body, marginTop: 4 }}>
|
|
451
|
+
{step.annotation}
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Construct-edge label overlay — the mechanism chips for the reused subsystem
|
|
460
|
+
* edges.
|
|
461
|
+
*
|
|
462
|
+
* `SubsystemEdge` draws only the line; the component graph renders its mechanism
|
|
463
|
+
* labels in a separate overlay. So the C4 canvas supplies one too, from the ELK
|
|
464
|
+
* label anchors stamped onto each edge's data (`labelX/labelY`).
|
|
465
|
+
*/
|
|
466
|
+
function SubsystemEdgeLabels({ edges }: { edges: Edge[] }) {
|
|
467
|
+
const { theme } = useTheme();
|
|
468
|
+
const viewport = useViewport();
|
|
469
|
+
const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
470
|
+
return (
|
|
471
|
+
<div
|
|
472
|
+
style={{
|
|
473
|
+
position: 'absolute',
|
|
474
|
+
inset: 0,
|
|
475
|
+
overflow: 'hidden',
|
|
476
|
+
pointerEvents: 'none',
|
|
477
|
+
zIndex: 5,
|
|
478
|
+
}}
|
|
479
|
+
>
|
|
480
|
+
{edges.map((e) => {
|
|
481
|
+
const d = e.data as
|
|
482
|
+
| {
|
|
483
|
+
mechanism?: string;
|
|
484
|
+
provenance?: SubsystemEdgeProvenance;
|
|
485
|
+
labelX?: number;
|
|
486
|
+
labelY?: number;
|
|
487
|
+
dimmed?: boolean;
|
|
488
|
+
}
|
|
489
|
+
| undefined;
|
|
490
|
+
if (d?.labelX == null || d?.labelY == null) return null;
|
|
491
|
+
const mechanism = d.mechanism ?? 'uses';
|
|
492
|
+
const text = mechanism;
|
|
493
|
+
// The same hue the component graph gives its mechanism chips, so a verb
|
|
494
|
+
// reads the same colour on either canvas.
|
|
495
|
+
const color = edgeColor({ mechanism, provenance: d.provenance });
|
|
496
|
+
const labelTitle =
|
|
497
|
+
d.provenance === 'graphify'
|
|
498
|
+
? 'Derived from graphify (static symbol graph)'
|
|
499
|
+
: MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] === false
|
|
500
|
+
? 'Not directly verifiable with graphify'
|
|
501
|
+
: undefined;
|
|
502
|
+
return (
|
|
503
|
+
<div
|
|
504
|
+
key={e.id}
|
|
505
|
+
title={labelTitle}
|
|
506
|
+
style={{
|
|
507
|
+
position: 'absolute',
|
|
508
|
+
left: d.labelX * viewport.zoom + viewport.x,
|
|
509
|
+
top: d.labelY * viewport.zoom + viewport.y,
|
|
510
|
+
transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
|
|
511
|
+
transformOrigin: 'center center',
|
|
512
|
+
display: 'flex',
|
|
513
|
+
alignItems: 'center',
|
|
514
|
+
justifyContent: 'center',
|
|
515
|
+
width: estimateEdgeLabelWidth(text),
|
|
516
|
+
height: EDGE_LABEL_HEIGHT,
|
|
517
|
+
boxSizing: 'border-box',
|
|
518
|
+
padding: '7px 8px',
|
|
519
|
+
// Fades with its line, on the same beat.
|
|
520
|
+
opacity: d.dimmed === true ? 0.15 : 1,
|
|
521
|
+
transition: 'opacity 220ms ease',
|
|
209
522
|
}}
|
|
210
523
|
>
|
|
211
524
|
<span
|
|
@@ -248,20 +561,38 @@ function C4EdgeLabels({
|
|
|
248
561
|
* the same `elkPathPoints` this renders. React Flow's built-in edge would curve
|
|
249
562
|
* between handles while the chip sat on ELK's orthogonal route, which is why
|
|
250
563
|
* labels read as "off the line".
|
|
564
|
+
*
|
|
565
|
+
* Two strokes: an invisible wide hit path (the drawn line is 1px, hopeless to
|
|
566
|
+
* click) and the visible one. The selection emphasis is a heavier stroke on
|
|
567
|
+
* the same geometry, so the selected line reads without redrawing the route.
|
|
251
568
|
*/
|
|
252
|
-
function C4EdgeView({
|
|
569
|
+
function C4EdgeView({
|
|
570
|
+
data,
|
|
571
|
+
markerEnd,
|
|
572
|
+
}: EdgeProps<Edge<{ path?: string; color?: string; selected?: boolean; dimmed?: boolean }>>) {
|
|
253
573
|
const path = data?.path ?? '';
|
|
254
574
|
if (!path) return null;
|
|
575
|
+
const selected = data?.selected === true;
|
|
576
|
+
const dimmed = data?.dimmed === true;
|
|
255
577
|
return (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
578
|
+
<>
|
|
579
|
+
<path
|
|
580
|
+
d={path}
|
|
581
|
+
fill="none"
|
|
582
|
+
stroke="transparent"
|
|
583
|
+
strokeWidth={14}
|
|
584
|
+
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
|
|
585
|
+
/>
|
|
586
|
+
<path
|
|
587
|
+
d={path}
|
|
588
|
+
fill="none"
|
|
589
|
+
stroke={data?.color}
|
|
590
|
+
strokeWidth={selected ? 2.5 : 1}
|
|
591
|
+
strokeDasharray="6 4"
|
|
592
|
+
markerEnd={markerEnd}
|
|
593
|
+
style={{ pointerEvents: 'none', opacity: dimmed ? 0.18 : 1, transition: 'opacity 220ms ease' }}
|
|
594
|
+
/>
|
|
595
|
+
</>
|
|
265
596
|
);
|
|
266
597
|
}
|
|
267
598
|
|
|
@@ -276,6 +607,12 @@ function C4GroupView(
|
|
|
276
607
|
/** FLIP: absolute flow rects — where to grow from, and the frame's own. */
|
|
277
608
|
growFrom?: FlowRect;
|
|
278
609
|
frameRect?: FlowRect;
|
|
610
|
+
/** Arriving in this layout — fades in. */
|
|
611
|
+
entering?: boolean;
|
|
612
|
+
/** Gone from this layout — kept for the morph, fades out. */
|
|
613
|
+
leaving?: boolean;
|
|
614
|
+
/** The move/grow waits this long for the fade to finish, in ms. */
|
|
615
|
+
moveDelayMs?: number;
|
|
279
616
|
}>
|
|
280
617
|
>,
|
|
281
618
|
) {
|
|
@@ -284,7 +621,7 @@ function C4GroupView(
|
|
|
284
621
|
const isSystem = props.data.kind === 'system';
|
|
285
622
|
const onCollapse = props.data.onCollapse;
|
|
286
623
|
const brand = props.data.brand;
|
|
287
|
-
const { growFrom, frameRect } = props.data;
|
|
624
|
+
const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data;
|
|
288
625
|
|
|
289
626
|
// Position-only FLIP, same as the cards. The frame resizes through its own
|
|
290
627
|
// width/height transition below, so scaling here would fight it *and* distort
|
|
@@ -292,6 +629,7 @@ function C4GroupView(
|
|
|
292
629
|
// label chip's border and text scale with it.
|
|
293
630
|
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
294
631
|
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
632
|
+
const fade = useFade(entering, leaving);
|
|
295
633
|
|
|
296
634
|
// A container frame shows its technology mark (React/Bun/Node…) so the
|
|
297
635
|
// boundary says what it is built with, same as the node it wraps. The system
|
|
@@ -310,10 +648,12 @@ function C4GroupView(
|
|
|
310
648
|
// Size is always transitioned; the transform only while sliding, and
|
|
311
649
|
// `transform: none` needs no transition.
|
|
312
650
|
transition: [
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
...(
|
|
651
|
+
`width ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
|
|
652
|
+
`height ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
|
|
653
|
+
...(fade.transition ? [fade.transition] : []),
|
|
654
|
+
...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
|
|
316
655
|
].join(', '),
|
|
656
|
+
opacity: fade.opacity,
|
|
317
657
|
boxSizing: 'border-box',
|
|
318
658
|
borderRadius: 12,
|
|
319
659
|
border: `2px dashed ${color}`,
|
|
@@ -387,19 +727,68 @@ function C4GroupView(
|
|
|
387
727
|
);
|
|
388
728
|
}
|
|
389
729
|
|
|
730
|
+
/**
|
|
731
|
+
* The component graph's own node, wrapped in the C4 canvas's FLIP so a construct
|
|
732
|
+
* glides to its new slot when the layout reflows. On its own it would only mount
|
|
733
|
+
* at the destination — a jump. Position-only, matching the C4 cards.
|
|
734
|
+
*/
|
|
735
|
+
const SubsystemNode = SubsystemComponentNode as unknown as ComponentType<NodeProps>;
|
|
736
|
+
|
|
737
|
+
function C4SubsystemNodeView(props: NodeProps) {
|
|
738
|
+
const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data as {
|
|
739
|
+
growFrom?: FlowRect;
|
|
740
|
+
frameRect?: FlowRect;
|
|
741
|
+
entering?: boolean;
|
|
742
|
+
leaving?: boolean;
|
|
743
|
+
moveDelayMs?: number;
|
|
744
|
+
};
|
|
745
|
+
const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
|
|
746
|
+
const sliding = flipping && (dx !== 0 || dy !== 0);
|
|
747
|
+
const fade = useFade(entering, leaving);
|
|
748
|
+
const transitions = [
|
|
749
|
+
...(fade.transition ? [fade.transition] : []),
|
|
750
|
+
...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
|
|
751
|
+
];
|
|
752
|
+
return (
|
|
753
|
+
<div
|
|
754
|
+
style={{
|
|
755
|
+
transformOrigin: 'top left',
|
|
756
|
+
transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
|
|
757
|
+
transition: transitions.length > 0 ? transitions.join(', ') : 'none',
|
|
758
|
+
opacity: fade.opacity,
|
|
759
|
+
}}
|
|
760
|
+
>
|
|
761
|
+
<SubsystemNode {...props} />
|
|
762
|
+
</div>
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
390
766
|
const nodeTypes = {
|
|
391
767
|
'c4-node': C4NodeView,
|
|
392
768
|
'c4-group': C4GroupView,
|
|
769
|
+
// The component graph's own renderers, reused for the constructs a container
|
|
770
|
+
// hosts. They are context-light (ThemeProvider + a React Flow ancestor), which
|
|
771
|
+
// is why they can ride this canvas instead of a nested one.
|
|
772
|
+
'subsystem-component': C4SubsystemNodeView as never,
|
|
393
773
|
};
|
|
394
774
|
const edgeTypes = {
|
|
395
775
|
'c4-edge': C4EdgeView,
|
|
776
|
+
'subsystem-edge': SubsystemEdge as never,
|
|
396
777
|
};
|
|
397
778
|
|
|
398
779
|
/** Drill-down animation durations, in ms. */
|
|
399
780
|
const MORPH_MS = 420;
|
|
400
781
|
const CAMERA_MS = 520;
|
|
401
782
|
/**
|
|
402
|
-
*
|
|
783
|
+
* How long cards arriving or leaving take to fade, in ms. The morph is
|
|
784
|
+
* sequenced: the fade runs first, and the surviving cards wait it out before
|
|
785
|
+
* they move and grow (`moveDelayMs` below). So the reveal clock is
|
|
786
|
+
* `FADE_MS + MORPH_MS`, not just `MORPH_MS`.
|
|
787
|
+
*/
|
|
788
|
+
const FADE_MS = 200;
|
|
789
|
+
/**
|
|
790
|
+
* When to re-frame the camera after a drill-down: the sequenced morph (fade,
|
|
791
|
+
* then move), plus a beat.
|
|
403
792
|
*
|
|
404
793
|
* The extra 60ms is not padding for looks. `fitView` reads the system frame's
|
|
405
794
|
* *measured* size, and that frame's own height transition runs on the same
|
|
@@ -407,7 +796,7 @@ const CAMERA_MS = 520;
|
|
|
407
796
|
* height that never existed. The beat is for React Flow's resize observer to
|
|
408
797
|
* report the final geometry.
|
|
409
798
|
*/
|
|
410
|
-
const CAMERA_AFTER_MORPH_MS = MORPH_MS + 60;
|
|
799
|
+
const CAMERA_AFTER_MORPH_MS = FADE_MS + MORPH_MS + 60;
|
|
411
800
|
|
|
412
801
|
/** A fully laid-out open state: the RF nodes/edges and label anchors. */
|
|
413
802
|
interface Rendered {
|
|
@@ -418,10 +807,50 @@ interface Rendered {
|
|
|
418
807
|
}
|
|
419
808
|
|
|
420
809
|
/** Flow-space rect of a box node or a frame, for the drill-down morph. */
|
|
421
|
-
interface FlowRect { x: number; y: number; width: number; height: number }
|
|
810
|
+
export interface FlowRect { x: number; y: number; width: number; height: number }
|
|
422
811
|
|
|
423
812
|
/** A frame's growth for the drill-down morph: its flow rect before → after. */
|
|
424
|
-
interface FrameGrow { from: FlowRect; to: FlowRect }
|
|
813
|
+
export interface FrameGrow { from: FlowRect; to: FlowRect }
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* A node's rect in **absolute** flow space, walking the parent chain.
|
|
817
|
+
*
|
|
818
|
+
* `groupBounds` is already absolute; a leaf's `position` is relative to its
|
|
819
|
+
* parent, so each ancestor's origin is added in turn.
|
|
820
|
+
*
|
|
821
|
+
* The FLIP needs absolute rects, not parent-relative ones. React Flow places a
|
|
822
|
+
* node at its new absolute position (parent origin + relative position) and
|
|
823
|
+
* moves children along with the parent's *wrapper* — while our FLIP transform
|
|
824
|
+
* lives on the node's inner div, which the children do not inherit. So a node's
|
|
825
|
+
* own transform must cover its full absolute delta; rebasing it to the parent
|
|
826
|
+
* would leave it short by exactly the parent's movement (the "subtle drift"
|
|
827
|
+
* before a leaving box disappears).
|
|
828
|
+
*/
|
|
829
|
+
export function absoluteRectOf(
|
|
830
|
+
layout: {
|
|
831
|
+
nodes: ReadonlyArray<{
|
|
832
|
+
id: string;
|
|
833
|
+
position: { x: number; y: number };
|
|
834
|
+
parentId?: string;
|
|
835
|
+
width?: number | null;
|
|
836
|
+
height?: number | null;
|
|
837
|
+
}>;
|
|
838
|
+
groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
839
|
+
},
|
|
840
|
+
id: string,
|
|
841
|
+
): FlowRect | null {
|
|
842
|
+
const frame = layout.groupBounds.get(id);
|
|
843
|
+
if (frame) return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
|
|
844
|
+
const node = layout.nodes.find((n) => n.id === id);
|
|
845
|
+
if (!node) return null;
|
|
846
|
+
const origin = node.parentId ? absoluteRectOf(layout, node.parentId) : null;
|
|
847
|
+
return {
|
|
848
|
+
x: (origin?.x ?? 0) + node.position.x,
|
|
849
|
+
y: (origin?.y ?? 0) + node.position.y,
|
|
850
|
+
width: node.width ?? 0,
|
|
851
|
+
height: node.height ?? 0,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
425
854
|
|
|
426
855
|
/**
|
|
427
856
|
* Whether committing a layout should also move the camera.
|
|
@@ -473,9 +902,9 @@ export function showDescriptionFor(open: boolean | undefined, settled: boolean |
|
|
|
473
902
|
* `result.groupBounds` is absolute for a root-level group and *parent-relative*
|
|
474
903
|
* for a nested one — two spaces in one map, which is the same trap as
|
|
475
904
|
* `result.nodes`. The opened container is nested (inside the system frame), and
|
|
476
|
-
* `
|
|
477
|
-
* be handed to the morph offset by the frame's origin: the container would
|
|
478
|
-
* to a place it was never drawn.
|
|
905
|
+
* `absoluteRectOf` reads this map before the node box, so an unrebased entry
|
|
906
|
+
* would be handed to the morph offset by the frame's origin: the container would
|
|
907
|
+
* glide to a place it was never drawn.
|
|
479
908
|
*/
|
|
480
909
|
export function rebaseGroupBoundsAbsolute(
|
|
481
910
|
result: Pick<ElkLayoutResult, 'absoluteRects' | 'groupBounds'>,
|
|
@@ -509,6 +938,7 @@ export function rebaseGroupBoundsAbsolute(
|
|
|
509
938
|
export function c4GroupDefs(
|
|
510
939
|
model: C4Model,
|
|
511
940
|
openIds?: readonly string[],
|
|
941
|
+
constructGraphByContainer?: ReadonlyMap<string, C4ConstructGraph>,
|
|
512
942
|
): { frames: C4Group[]; groups: CompoundGroupDef[] } {
|
|
513
943
|
// Normalized here rather than trusted from the caller. This function's output is
|
|
514
944
|
// cached under the open *set*, so a repeated id would emit the same group twice
|
|
@@ -542,10 +972,26 @@ export function c4GroupDefs(
|
|
|
542
972
|
const openMembers = model.nodes
|
|
543
973
|
.filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
|
|
544
974
|
.map((n) => n.id);
|
|
545
|
-
|
|
975
|
+
// The constructs this container hosts, grouped under the authored component
|
|
976
|
+
// that claims each (which grows into its own ELK parent below); a construct
|
|
977
|
+
// no component claims nests directly under the container.
|
|
978
|
+
const cg = constructGraphByContainer?.get(openId);
|
|
979
|
+
const byComponent = new Map<string, string[]>();
|
|
980
|
+
const orphans: string[] = [];
|
|
981
|
+
for (const c of cg?.components ?? []) {
|
|
982
|
+
const owner = cg?.componentOf?.get(c.alias);
|
|
983
|
+
if (owner) {
|
|
984
|
+
const list = byComponent.get(owner) ?? [];
|
|
985
|
+
list.push(c.alias);
|
|
986
|
+
byComponent.set(owner, list);
|
|
987
|
+
} else {
|
|
988
|
+
orphans.push(c.alias);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (openMembers.length === 0 && orphans.length === 0) continue;
|
|
546
992
|
groups.push({
|
|
547
993
|
id: openId,
|
|
548
|
-
memberIds: openMembers,
|
|
994
|
+
memberIds: [...openMembers, ...orphans],
|
|
549
995
|
// INSIDE the system frame, not beside it. The boundary *is* the system, so
|
|
550
996
|
// a sibling here would draw the opened container outside the very boundary
|
|
551
997
|
// it belongs to.
|
|
@@ -565,27 +1011,30 @@ export function c4GroupDefs(
|
|
|
565
1011
|
// knows which one a bare member id means.
|
|
566
1012
|
drawnAsCard: true,
|
|
567
1013
|
});
|
|
1014
|
+
// A component that claims constructs grows into an ELK parent as well, so
|
|
1015
|
+
// its constructs nest inside it (container → component → construct) rather
|
|
1016
|
+
// than sitting beside it.
|
|
1017
|
+
for (const [componentId, memberIds] of byComponent) {
|
|
1018
|
+
if (memberIds.length === 0) continue;
|
|
1019
|
+
groups.push({
|
|
1020
|
+
id: componentId,
|
|
1021
|
+
memberIds,
|
|
1022
|
+
parentId: openId,
|
|
1023
|
+
minWidth: COMPONENT_W,
|
|
1024
|
+
// A component card's header is its label slot + padding; it has no
|
|
1025
|
+
// technology row and no collapse affordance.
|
|
1026
|
+
padTop: COMPONENT_HEADER_PAD,
|
|
1027
|
+
padBottom: COMPONENT_FOOTER_PAD,
|
|
1028
|
+
padX: OPEN_SIDE_PAD,
|
|
1029
|
+
drawnAsCard: true,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
568
1032
|
}
|
|
569
1033
|
}
|
|
570
1034
|
|
|
571
1035
|
return { frames, groups };
|
|
572
1036
|
}
|
|
573
1037
|
|
|
574
|
-
/** The container's rect in a laid-out state: its box node, or its frame bounds. */
|
|
575
|
-
function containerRect(layout: Rendered | undefined, id: string): FlowRect | null {
|
|
576
|
-
if (!layout) return null;
|
|
577
|
-
const frame = layout.groupBounds.get(id);
|
|
578
|
-
if (frame) return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
|
|
579
|
-
const node = layout.nodes.find((n) => n.id === id);
|
|
580
|
-
if (!node) return null;
|
|
581
|
-
return {
|
|
582
|
-
x: node.position.x,
|
|
583
|
-
y: node.position.y,
|
|
584
|
-
width: node.width ?? 0,
|
|
585
|
-
height: node.height ?? 0,
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
|
|
589
1038
|
/**
|
|
590
1039
|
* FLIP between two absolute flow rects: render transformed back onto `from`,
|
|
591
1040
|
* then release onto `to`.
|
|
@@ -621,6 +1070,23 @@ function useFlip(from: FlowRect | undefined, to: FlowRect | undefined) {
|
|
|
621
1070
|
};
|
|
622
1071
|
}
|
|
623
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* Opacity for a card arriving or leaving the layout. An arriving card mounts
|
|
1075
|
+
* hidden and fades in; a leaving card is kept (out of the new layout) for the
|
|
1076
|
+
* morph, then fades out — a removal reads as a fade, not a pop.
|
|
1077
|
+
*/
|
|
1078
|
+
function useFade(entering?: boolean, leaving?: boolean) {
|
|
1079
|
+
const animate = entering === true || leaving === true;
|
|
1080
|
+
const [shown, setShown] = useState(entering !== true);
|
|
1081
|
+
useEffect(() => {
|
|
1082
|
+
if (!animate) return;
|
|
1083
|
+
const raf = requestAnimationFrame(() => setShown(leaving !== true));
|
|
1084
|
+
return () => cancelAnimationFrame(raf);
|
|
1085
|
+
}, [animate, entering, leaving]);
|
|
1086
|
+
if (!animate) return { opacity: 1, transition: undefined as string | undefined };
|
|
1087
|
+
return { opacity: shown ? 1 : 0, transition: `opacity ${FADE_MS}ms ease` };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
624
1090
|
/**
|
|
625
1091
|
* Whether a nested component is withheld while the diagram is mid-morph.
|
|
626
1092
|
*
|
|
@@ -646,14 +1112,23 @@ export function hidesComponentDuringMorph(
|
|
|
646
1112
|
return morphTargets.has(owner);
|
|
647
1113
|
}
|
|
648
1114
|
|
|
649
|
-
function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
|
|
650
|
-
const { theme } = useTheme();
|
|
651
|
-
const { fitView } = useReactFlow();
|
|
1115
|
+
function Inner({ model, onSelectNode, onSelectEdge, onTrailClick, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28, dimmedElementIds, dimmedEdgeIds, hiddenElementIds, hiddenEdgeIds, dimmedConstructIds, dimmedConstructEdgeIds, constructGraphByContainer, focusedStep, onCloseStep, renderFileViewer, renderTrailViewer, hideDrawer = false }: C4GraphProps) {
|
|
1116
|
+
const { theme, mode } = useTheme();
|
|
1117
|
+
const { fitView, fitBounds } = useReactFlow();
|
|
652
1118
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
653
1119
|
const [nodes, setNodes] = useState<Node[]>([]);
|
|
1120
|
+
/** Latest rendered nodes, for the step-focus camera fit without re-firing on
|
|
1121
|
+
* every layout change. */
|
|
1122
|
+
const nodesRef = useRef<Node[]>([]);
|
|
1123
|
+
nodesRef.current = nodes;
|
|
654
1124
|
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
655
1125
|
const [ready, setReady] = useState(false);
|
|
656
1126
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
1127
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
1128
|
+
// The construct (subsystem component alias) whose functionality (declaration
|
|
1129
|
+
// + purpose) is open. Its own panel, so opening a construct never disturbs the
|
|
1130
|
+
// node/edge inspector.
|
|
1131
|
+
const [selectedConstructAlias, setSelectedConstructAlias] = useState<string | null>(null);
|
|
657
1132
|
const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
|
|
658
1133
|
() => new Map(),
|
|
659
1134
|
);
|
|
@@ -682,7 +1157,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
682
1157
|
if (cache.size <= LAYOUT_CACHE_MAX) break;
|
|
683
1158
|
// Never evict what is on screen: a re-layout of the current state mid-morph
|
|
684
1159
|
// would swap the geometry out from under the transition.
|
|
685
|
-
if (oldest ===
|
|
1160
|
+
if (oldest === currentCacheKeyRef.current) continue;
|
|
686
1161
|
cache.delete(oldest);
|
|
687
1162
|
}
|
|
688
1163
|
};
|
|
@@ -704,12 +1179,29 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
704
1179
|
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
705
1180
|
/** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
|
|
706
1181
|
const cameraTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1182
|
+
/** Drops cards that were kept only to fade out. */
|
|
1183
|
+
const leaverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1184
|
+
/**
|
|
1185
|
+
* The dim set of the *previous* render, read when a card starts leaving so it
|
|
1186
|
+
* fades from the opacity it actually had. A hover preview dims the non-cast
|
|
1187
|
+
* boxes; clicking the trail clears the hover, so without this a leaving box
|
|
1188
|
+
* would brighten to full before fading.
|
|
1189
|
+
*/
|
|
1190
|
+
const prevDimmedRef = useRef<ReadonlySet<string> | null>(null);
|
|
707
1191
|
/**
|
|
708
1192
|
* The open set rendered last, so a change can morph between the two layouts.
|
|
709
1193
|
* The array, not just its key, because the morph needs to know *which*
|
|
710
1194
|
* containers changed — see `morphTargetsRef`.
|
|
711
1195
|
*/
|
|
712
1196
|
const prevOpenRef = useRef<string[]>([]);
|
|
1197
|
+
/**
|
|
1198
|
+
* The layout-cache key rendered last, and the one on screen now. The key
|
|
1199
|
+
* carries the construct counts too (a card's box grows with its rows), so a
|
|
1200
|
+
* focus change that keeps the same containers open still misses the cache and
|
|
1201
|
+
* recomputes — and the morph reads the previous entry by its own key.
|
|
1202
|
+
*/
|
|
1203
|
+
const prevCacheKeyRef = useRef('');
|
|
1204
|
+
const currentCacheKeyRef = useRef('');
|
|
713
1205
|
/**
|
|
714
1206
|
* Containers whose open state is changing *right now* — the symmetric
|
|
715
1207
|
* difference of the previous and next open sets.
|
|
@@ -787,16 +1279,44 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
787
1279
|
const openIds = useMemo(() => openContainerSet(model), [model]);
|
|
788
1280
|
const openKey = openStateKey(openIds);
|
|
789
1281
|
|
|
790
|
-
|
|
1282
|
+
// The constructs a container hosts change its layout, so a focus shift that
|
|
1283
|
+
// swaps them (same containers open) must recompute — keyed here or the stale
|
|
1284
|
+
// box would stand.
|
|
1285
|
+
const constructKey = useMemo(
|
|
1286
|
+
() =>
|
|
1287
|
+
constructGraphByContainer
|
|
1288
|
+
? [...constructGraphByContainer.entries()]
|
|
1289
|
+
.map(
|
|
1290
|
+
([id, cg]) =>
|
|
1291
|
+
`${id}\0${cg.components.map((c) => c.alias).sort().join(',')}`,
|
|
1292
|
+
)
|
|
1293
|
+
.sort()
|
|
1294
|
+
.join('\n')
|
|
1295
|
+
: '',
|
|
1296
|
+
[constructGraphByContainer],
|
|
1297
|
+
);
|
|
1298
|
+
|
|
1299
|
+
// The model's own shape — which boxes and lines exist. Part of the cache key
|
|
1300
|
+
// so a host that filters the model (e.g. down to a focused trail's cast)
|
|
1301
|
+
// re-lays-out instead of hitting a stale layout for the same open set.
|
|
1302
|
+
const modelKey = useMemo(
|
|
791
1303
|
() =>
|
|
792
1304
|
[
|
|
793
1305
|
...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
|
|
794
1306
|
...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
|
|
795
|
-
`open:${openKey}`,
|
|
796
1307
|
].join('\n'),
|
|
797
|
-
[model
|
|
1308
|
+
[model],
|
|
1309
|
+
);
|
|
1310
|
+
|
|
1311
|
+
const layoutKey = useMemo(
|
|
1312
|
+
() => `${modelKey}\nopen:${openKey}\nconstructs:${constructKey}`,
|
|
1313
|
+
[modelKey, openKey, constructKey],
|
|
798
1314
|
);
|
|
799
1315
|
|
|
1316
|
+
/** The identity of one laid-out state — open set + constructs + model shape. */
|
|
1317
|
+
const stateKey = (ids: readonly string[]) =>
|
|
1318
|
+
`${openStateKey(ids)}|${constructKey}|${modelKey}`;
|
|
1319
|
+
|
|
800
1320
|
/**
|
|
801
1321
|
* Build the React Flow inputs for a given open state — everything ELK needs,
|
|
802
1322
|
* independent of whether a layout has been run yet. Pure over `openIds`, so it
|
|
@@ -822,6 +1342,21 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
822
1342
|
if (parent) childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
|
|
823
1343
|
}
|
|
824
1344
|
|
|
1345
|
+
// Authored components that claim constructs grow into ELK parents too, so
|
|
1346
|
+
// they are drawn as grown cards (absolute box, no description) exactly like
|
|
1347
|
+
// an opened container.
|
|
1348
|
+
const groupedComponentIds = new Set<string>();
|
|
1349
|
+
if (model.view === 'container') {
|
|
1350
|
+
for (const openId of openIds) {
|
|
1351
|
+
const cg = constructGraphByContainer?.get(openId);
|
|
1352
|
+
if (!cg) continue;
|
|
1353
|
+
for (const c of cg.components) {
|
|
1354
|
+
const owner = cg.componentOf?.get(c.alias);
|
|
1355
|
+
if (owner) groupedComponentIds.add(owner);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
825
1360
|
const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
|
|
826
1361
|
const size = nodeSize(n);
|
|
827
1362
|
// A component of an opened container nests under *its own* container (a
|
|
@@ -853,11 +1388,44 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
853
1388
|
...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
|
|
854
1389
|
? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
|
|
855
1390
|
: {}),
|
|
1391
|
+
// A component grown to hold its constructs is a drawn-as-card group:
|
|
1392
|
+
// no description, absolute box, sized by ELK.
|
|
1393
|
+
...(n.kind === 'component' && groupedComponentIds.has(n.id) ? { open: true } : {}),
|
|
856
1394
|
},
|
|
857
1395
|
};
|
|
858
1396
|
});
|
|
859
1397
|
|
|
860
|
-
|
|
1398
|
+
// Constructs: the component graph's own nodes and edges, nested inside their
|
|
1399
|
+
// opened container (under the authored component that claims each). Built
|
|
1400
|
+
// per container so an edge never crosses containers — a construct edge that
|
|
1401
|
+
// would is left to the C4 container-level line.
|
|
1402
|
+
const constructEdges: Edge[] = [];
|
|
1403
|
+
if (model.view === 'container') {
|
|
1404
|
+
for (const openId of openIds) {
|
|
1405
|
+
const cg = constructGraphByContainer?.get(openId);
|
|
1406
|
+
if (!cg || cg.components.length === 0) continue;
|
|
1407
|
+
const subNodes = convertSubsystemToNodes({
|
|
1408
|
+
components: [...cg.components],
|
|
1409
|
+
trails: [...cg.trails],
|
|
1410
|
+
});
|
|
1411
|
+
for (const sn of subNodes) {
|
|
1412
|
+
const owner = cg.componentOf?.get(sn.id) ?? openId;
|
|
1413
|
+
rfNodes.push({
|
|
1414
|
+
...sn,
|
|
1415
|
+
parentId: owner,
|
|
1416
|
+
zIndex: 3,
|
|
1417
|
+
// `c4Owner` is the container this construct belongs to, read by the
|
|
1418
|
+
// morph/dim pass; it is not part of the node's own render.
|
|
1419
|
+
data: { ...sn.data, c4Owner: openId },
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
constructEdges.push(
|
|
1423
|
+
...convertSubsystemToEdges({ components: [...cg.components], trails: [...cg.trails] }),
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const { frames, groups } = c4GroupDefs(model, openIds, constructGraphByContainer);
|
|
861
1429
|
|
|
862
1430
|
// Bands come from the *frames* only. The opened container is an ELK parent,
|
|
863
1431
|
// not a band — treating it as one would push it into its own layer, away from
|
|
@@ -888,6 +1456,12 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
888
1456
|
data: { edge: e, color, path: '' },
|
|
889
1457
|
};
|
|
890
1458
|
});
|
|
1459
|
+
// Construct edges attach only when both endpoints are drawn — a step to an
|
|
1460
|
+
// external, or to a construct in another container, is dropped here and left
|
|
1461
|
+
// to the C4 container-level line.
|
|
1462
|
+
for (const e of constructEdges) {
|
|
1463
|
+
if (drawnIds.has(e.source) && drawnIds.has(e.target)) rfEdges.push(e);
|
|
1464
|
+
}
|
|
891
1465
|
|
|
892
1466
|
return { rfNodes, rfEdges, groups, partitionByNode, frames };
|
|
893
1467
|
};
|
|
@@ -936,20 +1510,30 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
936
1510
|
const withPaths = rfEdges.map((e) => {
|
|
937
1511
|
const pts = result.edgePathPoints?.get(e.id);
|
|
938
1512
|
if (!pts || pts.length === 0) return e;
|
|
1513
|
+
const path = pointsToSmoothPath(pts, 12);
|
|
1514
|
+
// The component graph's edge renderer reads `elkPath` (and its label
|
|
1515
|
+
// overlay reads `labelX/labelY`); the C4 renderer reads `path`. Set each
|
|
1516
|
+
// so the right renderer draws its own route and its own label anchor.
|
|
1517
|
+
if (e.type === 'subsystem-edge') {
|
|
1518
|
+
const mid = pointAlongPath(pts, 0.5);
|
|
1519
|
+
return { ...e, data: { ...e.data, elkPath: path, labelX: mid.x, labelY: mid.y } };
|
|
1520
|
+
}
|
|
939
1521
|
labelPositions.set(e.id, pointAlongPath(pts, 0.5));
|
|
940
|
-
return { ...e, data: { ...e.data, path
|
|
1522
|
+
return { ...e, data: { ...e.data, path } };
|
|
941
1523
|
});
|
|
942
1524
|
|
|
943
1525
|
// `result.groupBounds` mixes coordinate spaces; everything downstream here
|
|
944
1526
|
// reads it as absolute, so normalize it once at the boundary.
|
|
945
1527
|
const groupBounds = rebaseGroupBoundsAbsolute(result);
|
|
946
1528
|
const builtGroups = new Set(groupBounds.keys());
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
1529
|
+
// Every group drawn as a grown *card* (opened containers, and components
|
|
1530
|
+
// grown to hold their constructs) comes back from ELK as a parent, so it
|
|
1531
|
+
// lands in `groupBounds` — but it is drawn as a card, not a frame. Drop each
|
|
1532
|
+
// from the shells; keep them in `groupBounds` so their nested children can
|
|
950
1533
|
// take their coordinates relative to them.
|
|
1534
|
+
const drawnAsCardIds = new Set(groups.filter((g) => g.drawnAsCard).map((g) => g.id));
|
|
951
1535
|
const shellBounds = new Map(groupBounds);
|
|
952
|
-
for (const
|
|
1536
|
+
for (const id of drawnAsCardIds) shellBounds.delete(id);
|
|
953
1537
|
|
|
954
1538
|
const groupById = new Map(frames.map((g) => [g.id, g]));
|
|
955
1539
|
const depthOf = (id: string): number => {
|
|
@@ -1013,28 +1597,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1013
1597
|
});
|
|
1014
1598
|
|
|
1015
1599
|
const leaves = rfNodes.map((n) => {
|
|
1600
|
+
// The parent is whatever `buildInputs` already stamped on the node — a
|
|
1601
|
+
// component's container, or a construct's component/container — kept only
|
|
1602
|
+
// when ELK actually built that group (a closed container is not one). This
|
|
1603
|
+
// covers the construct nodes too, whose data carries no `element`.
|
|
1016
1604
|
let parentId: string | undefined;
|
|
1017
|
-
if (n.
|
|
1018
|
-
const modelNode = (n.data as { element?: C4Element }).element;
|
|
1019
|
-
// A component nests under its own container when that container is open —
|
|
1020
|
-
// the sibling containers and out-of-boundary nodes in the same layout must
|
|
1021
|
-
// not. Reads the component's own owner rather than a single open id,
|
|
1022
|
-
// because several containers can be open at once.
|
|
1023
|
-
const owner =
|
|
1024
|
-
modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
|
|
1025
|
-
const nested = owner && opened.has(owner) ? owner : undefined;
|
|
1026
|
-
const candidate = nested ?? modelNode?.parentId;
|
|
1027
|
-
if (candidate && builtGroups.has(candidate)) parentId = candidate;
|
|
1028
|
-
}
|
|
1605
|
+
if (n.parentId && builtGroups.has(n.parentId)) parentId = n.parentId;
|
|
1029
1606
|
// `flowPositionOf` covers every node ELK placed; the fallback is for the
|
|
1030
1607
|
// pathological case of a node ELK did not return at all.
|
|
1031
1608
|
const abs =
|
|
1032
1609
|
flowPositionOf(result, n.id, parentId) ??
|
|
1033
1610
|
positioned.get(n.id)?.position ??
|
|
1034
1611
|
n.position;
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1612
|
+
// A grown card (an opened container, or a component holding its
|
|
1613
|
+
// constructs) is an ELK parent, so its box is whatever ELK fitted to its
|
|
1614
|
+
// nested children — `nodeSize` only knows the closed card.
|
|
1615
|
+
const fitted = drawnAsCardIds.has(n.id) ? groupBounds.get(n.id) : undefined;
|
|
1038
1616
|
const node = {
|
|
1039
1617
|
...n,
|
|
1040
1618
|
position: { x: abs.x, y: abs.y },
|
|
@@ -1056,17 +1634,40 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1056
1634
|
if (!grows || grows.size === 0) return nodes;
|
|
1057
1635
|
return nodes.map((n) => {
|
|
1058
1636
|
const g = grows.get(n.id);
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1637
|
+
if (!g) return n;
|
|
1638
|
+
if (n.type !== 'c4-group' && n.type !== 'c4-node' && n.type !== 'subsystem-component') {
|
|
1639
|
+
return n;
|
|
1640
|
+
}
|
|
1641
|
+
// Absolute rects for every node — root or nested. `useFlip` turns them into
|
|
1642
|
+
// the absolute delta the node's inner div must travel; React Flow has
|
|
1643
|
+
// already moved the wrapper (and its children) to the new absolute slot.
|
|
1644
|
+
return { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } };
|
|
1065
1645
|
});
|
|
1066
1646
|
};
|
|
1067
1647
|
|
|
1068
|
-
const renderLayout = (
|
|
1069
|
-
|
|
1648
|
+
const renderLayout = (
|
|
1649
|
+
r: Rendered,
|
|
1650
|
+
grows: Map<string, FrameGrow> | null,
|
|
1651
|
+
morph?: { prevNodeIds: Set<string>; leavers: Node[]; moveDelayMs: number },
|
|
1652
|
+
) => {
|
|
1653
|
+
// Mark cards that were not in the previous layout so they fade in; keep the
|
|
1654
|
+
// previous layout's orphans (leavers) so they fade out instead of popping.
|
|
1655
|
+
// Every card also carries the move delay, so the survivors' glide and grow
|
|
1656
|
+
// wait for the fade to finish (the sequenced morph).
|
|
1657
|
+
const moveDelayMs = morph?.moveDelayMs ?? 0;
|
|
1658
|
+
const marked = morph
|
|
1659
|
+
? r.nodes.map((n) => {
|
|
1660
|
+
const base = morph.prevNodeIds.has(n.id)
|
|
1661
|
+
? n
|
|
1662
|
+
: { ...n, data: { ...n.data, entering: true } };
|
|
1663
|
+
return { ...base, data: { ...base.data, moveDelayMs } };
|
|
1664
|
+
})
|
|
1665
|
+
: r.nodes;
|
|
1666
|
+
const leavers = (morph?.leavers ?? []).map((n) => ({
|
|
1667
|
+
...n,
|
|
1668
|
+
data: { ...n.data, moveDelayMs },
|
|
1669
|
+
}));
|
|
1670
|
+
setNodes([...injectGrow(marked, grows), ...leavers]);
|
|
1070
1671
|
setRfEdges(r.edges);
|
|
1071
1672
|
setLabelPositions(r.labelPositions);
|
|
1072
1673
|
setReady(true);
|
|
@@ -1077,33 +1678,34 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1077
1678
|
// (instant swap), else computes it now.
|
|
1078
1679
|
useEffect(() => {
|
|
1079
1680
|
let alive = true;
|
|
1080
|
-
|
|
1681
|
+
// Blank the canvas only for the very first paint. A relayout keeps the
|
|
1682
|
+
// previous layout on screen until the new one lands — otherwise every trail
|
|
1683
|
+
// pick (which changes the open set and the construct graph, so the cache
|
|
1684
|
+
// misses) flashed the "Laying out…" overlay.
|
|
1685
|
+
if (!paintedRef.current) setReady(false);
|
|
1081
1686
|
setSelectedId(null);
|
|
1082
1687
|
const openKeyNow = openStateKey(openIds);
|
|
1688
|
+
const cacheKeyNow = stateKey(openIds);
|
|
1689
|
+
currentCacheKeyRef.current = cacheKeyNow;
|
|
1083
1690
|
const prevOpen = prevOpenRef.current;
|
|
1691
|
+
const prevCacheKey = prevCacheKeyRef.current;
|
|
1692
|
+
// Captured now, not inside `applyLayout`: the layout may compute async, and
|
|
1693
|
+
// the tracking effect below clears the ref before that runs. This is the dim
|
|
1694
|
+
// set of the render just gone (the hover preview).
|
|
1695
|
+
const prevDimmed = prevDimmedRef.current;
|
|
1084
1696
|
const inputs = buildInputs(openIds);
|
|
1085
1697
|
|
|
1086
|
-
//
|
|
1698
|
+
// The containers whose open state changed *this* tick — the only ones whose
|
|
1699
|
+
// components genuinely arrive or leave. Recorded as a ref rather than state:
|
|
1700
|
+
// it is read during the next render (to decide what to hide) but must not
|
|
1701
|
+
// itself schedule a render.
|
|
1087
1702
|
if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
|
|
1088
|
-
// The containers in one set and not the other are the ones whose components
|
|
1089
|
-
// actually arrive or leave. Recorded as a ref rather than state: it is read
|
|
1090
|
-
// during the next render (to decide what to hide) but must not itself
|
|
1091
|
-
// schedule a render.
|
|
1092
1703
|
const before = new Set(prevOpen);
|
|
1093
1704
|
const after = new Set(openIds);
|
|
1094
1705
|
morphTargetsRef.current = new Set([
|
|
1095
1706
|
...prevOpen.filter((id) => !after.has(id)),
|
|
1096
1707
|
...openIds.filter((id) => !before.has(id)),
|
|
1097
1708
|
]);
|
|
1098
|
-
setSettled(false);
|
|
1099
|
-
if (settleTimer.current) clearTimeout(settleTimer.current);
|
|
1100
|
-
settleTimer.current = setTimeout(() => {
|
|
1101
|
-
setSettled(true);
|
|
1102
|
-
// The morph is over. Clearing the targets matters: an unrelated re-render
|
|
1103
|
-
// afterwards must not find a container still marked mid-morph and hide
|
|
1104
|
-
// components that are merely sitting still.
|
|
1105
|
-
morphTargetsRef.current = new Set();
|
|
1106
|
-
}, MORPH_MS);
|
|
1107
1709
|
// Re-frame once the grow has landed, not while it is running. The drill-down
|
|
1108
1710
|
// more than doubles the boundary, so the extent the camera was fitted to no
|
|
1109
1711
|
// longer contains the content — but moving *during* the morph compounds with
|
|
@@ -1112,6 +1714,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1112
1714
|
// out the morph is the honest way to get both.
|
|
1113
1715
|
if (cameraTimer.current) clearTimeout(cameraTimer.current);
|
|
1114
1716
|
cameraTimer.current = setTimeout(fitSystem, CAMERA_AFTER_MORPH_MS);
|
|
1717
|
+
} else {
|
|
1718
|
+
morphTargetsRef.current = new Set();
|
|
1115
1719
|
}
|
|
1116
1720
|
paintedRef.current = true;
|
|
1117
1721
|
|
|
@@ -1120,28 +1724,28 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1120
1724
|
* boundaries *and* cards grow/shrink and slide together. Empty when nothing
|
|
1121
1725
|
* changed (e.g. the initial paint).
|
|
1122
1726
|
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
* the
|
|
1727
|
+
* This covers *nested* cards too (a container inside the system frame, a
|
|
1728
|
+
* component inside a grown container). They are React Flow children, so the
|
|
1729
|
+
* FLIP rects are rebased to their parent's origin in `injectGrow` — a
|
|
1730
|
+
* transform on a child then composes with the parent's instead of fighting
|
|
1731
|
+
* it. Without this, filtering the model to a trail's cast reflowed the
|
|
1732
|
+
* surviving containers and they teleported to their new slots.
|
|
1128
1733
|
*/
|
|
1129
1734
|
const growsFor = (next: Rendered): Map<string, FrameGrow> | null => {
|
|
1130
|
-
if (
|
|
1131
|
-
const prevLayout = layoutCache.current.get(
|
|
1735
|
+
if (!paintedRef.current) return null;
|
|
1736
|
+
const prevLayout = layoutCache.current.get(prevCacheKey);
|
|
1132
1737
|
if (!prevLayout) return null;
|
|
1133
1738
|
const grows = new Map<string, FrameGrow>();
|
|
1134
|
-
const
|
|
1135
|
-
l.nodes.filter((n) => !n.parentId).map((n) => n.id);
|
|
1739
|
+
const cardIds = (l: Rendered) => l.nodes.map((n) => n.id);
|
|
1136
1740
|
const ids = new Set([
|
|
1137
1741
|
...prevLayout.groupBounds.keys(),
|
|
1138
1742
|
...next.groupBounds.keys(),
|
|
1139
|
-
...
|
|
1140
|
-
...
|
|
1743
|
+
...cardIds(prevLayout),
|
|
1744
|
+
...cardIds(next),
|
|
1141
1745
|
]);
|
|
1142
1746
|
for (const id of ids) {
|
|
1143
|
-
const fromRect =
|
|
1144
|
-
const to =
|
|
1747
|
+
const fromRect = absoluteRectOf(prevLayout, id);
|
|
1748
|
+
const to = absoluteRectOf(next, id);
|
|
1145
1749
|
if (!fromRect || !to) continue;
|
|
1146
1750
|
if (
|
|
1147
1751
|
fromRect.x === to.x &&
|
|
@@ -1156,10 +1760,77 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1156
1760
|
return grows.size > 0 ? grows : null;
|
|
1157
1761
|
};
|
|
1158
1762
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1763
|
+
// Commit a new layout: keep the survivors gliding (FLIP), fade in cards the
|
|
1764
|
+
// previous layout did not have, and hold the previous layout's orphans for
|
|
1765
|
+
// one morph so they fade out rather than pop. Then advance the refs.
|
|
1766
|
+
const applyLayout = (r: Rendered) => {
|
|
1767
|
+
const prevLayout = layoutCache.current.get(prevCacheKey);
|
|
1768
|
+
const span = growsFor(r);
|
|
1769
|
+
const prevNodeIds = new Set((prevLayout?.nodes ?? []).map((n) => n.id));
|
|
1770
|
+
const nextIds = new Set(r.nodes.map((n) => n.id));
|
|
1771
|
+
// A leaving card is rendered as a *root* node pinned to its old absolute
|
|
1772
|
+
// rect. Nested under its old parent it would ride that parent's React Flow
|
|
1773
|
+
// wrapper — which moves the instant the new layout commits — and drift
|
|
1774
|
+
// before it faded. As a root it stays put and just fades.
|
|
1775
|
+
const leavers: Node[] = prevLayout
|
|
1776
|
+
? prevLayout.nodes
|
|
1777
|
+
.filter((n) => !nextIds.has(n.id))
|
|
1778
|
+
.map((n) => {
|
|
1779
|
+
const rect = absoluteRectOf(prevLayout, n.id);
|
|
1780
|
+
// Keep the dim it had while hovered, so the fade starts from the
|
|
1781
|
+
// faded opacity instead of flashing back to full first.
|
|
1782
|
+
const el = (n.data as { element?: C4Element } | undefined)?.element;
|
|
1783
|
+
const wasDimmed = el ? prevDimmed?.has(el.id) === true : false;
|
|
1784
|
+
return {
|
|
1785
|
+
...n,
|
|
1786
|
+
parentId: undefined,
|
|
1787
|
+
position: rect ? { x: rect.x, y: rect.y } : n.position,
|
|
1788
|
+
...(rect ? { width: rect.width, height: rect.height } : {}),
|
|
1789
|
+
data: { ...n.data, leaving: true, ...(wasDimmed ? { dimmed: true } : {}) },
|
|
1790
|
+
};
|
|
1791
|
+
})
|
|
1792
|
+
: [];
|
|
1793
|
+
// Only *root* fades (a container arriving or leaving) sequence the move.
|
|
1794
|
+
// Nested cards (components, constructs) are withheld by the morph gate
|
|
1795
|
+
// while their container grows, so their entering/leaving is not a fade the
|
|
1796
|
+
// reader sees — delaying the grow for them would just add a pause.
|
|
1797
|
+
const rootEntering = prevLayout
|
|
1798
|
+
? r.nodes.filter((n) => !n.parentId && !prevNodeIds.has(n.id)).length
|
|
1799
|
+
: 0;
|
|
1800
|
+
const rootLeaving = leavers.filter((n) => !n.parentId).length;
|
|
1801
|
+
const moveDelayMs = rootEntering > 0 || rootLeaving > 0 ? FADE_MS : 0;
|
|
1802
|
+
// The edges are withheld for the whole sequenced morph — fade, then move,
|
|
1803
|
+
// then content. The morphing containers' components are withheld too (see
|
|
1804
|
+
// `morphTargetsRef`).
|
|
1805
|
+
if (span) {
|
|
1806
|
+
setSettled(false);
|
|
1807
|
+
if (settleTimer.current) clearTimeout(settleTimer.current);
|
|
1808
|
+
settleTimer.current = setTimeout(() => {
|
|
1809
|
+
setSettled(true);
|
|
1810
|
+
morphTargetsRef.current = new Set();
|
|
1811
|
+
}, moveDelayMs + MORPH_MS);
|
|
1812
|
+
}
|
|
1813
|
+
if (leaverTimer.current) clearTimeout(leaverTimer.current);
|
|
1814
|
+
if (leavers.length > 0) {
|
|
1815
|
+
const leavingIds = new Set(leavers.map((n) => n.id));
|
|
1816
|
+
// A leaver only fades; it does not move, so it is dropped when the fade
|
|
1817
|
+
// ends, just before the survivors start to glide.
|
|
1818
|
+
leaverTimer.current = setTimeout(() => {
|
|
1819
|
+
setNodes((prev) => prev.filter((n) => !leavingIds.has(n.id)));
|
|
1820
|
+
}, FADE_MS + 40);
|
|
1821
|
+
}
|
|
1161
1822
|
prevOpenRef.current = openIds;
|
|
1162
|
-
|
|
1823
|
+
prevCacheKeyRef.current = cacheKeyNow;
|
|
1824
|
+
renderLayout(
|
|
1825
|
+
r,
|
|
1826
|
+
span,
|
|
1827
|
+
prevLayout ? { prevNodeIds, leavers, moveDelayMs } : undefined,
|
|
1828
|
+
);
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
const cached = layoutCache.current.get(cacheKeyNow);
|
|
1832
|
+
if (cached) {
|
|
1833
|
+
applyLayout(cached);
|
|
1163
1834
|
return () => {
|
|
1164
1835
|
alive = false;
|
|
1165
1836
|
};
|
|
@@ -1167,10 +1838,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1167
1838
|
void layoutFor(openIds, inputs)
|
|
1168
1839
|
.then((r) => {
|
|
1169
1840
|
if (!alive) return;
|
|
1170
|
-
cacheLayout(
|
|
1171
|
-
|
|
1172
|
-
prevOpenRef.current = openIds;
|
|
1173
|
-
renderLayout(r, span);
|
|
1841
|
+
cacheLayout(cacheKeyNow, r);
|
|
1842
|
+
applyLayout(r);
|
|
1174
1843
|
})
|
|
1175
1844
|
.catch((err) => {
|
|
1176
1845
|
if (!alive) return;
|
|
@@ -1194,10 +1863,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1194
1863
|
clearTimeout(cameraTimer.current);
|
|
1195
1864
|
cameraTimer.current = null;
|
|
1196
1865
|
}
|
|
1866
|
+
if (leaverTimer.current) {
|
|
1867
|
+
clearTimeout(leaverTimer.current);
|
|
1868
|
+
leaverTimer.current = null;
|
|
1869
|
+
}
|
|
1197
1870
|
};
|
|
1198
1871
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1199
1872
|
}, [layoutKey]);
|
|
1200
1873
|
|
|
1874
|
+
// Remember this render's dim set for the *next* commit's leavers. Declared
|
|
1875
|
+
// after the layout effect so it runs after it: the layout effect reads the
|
|
1876
|
+
// value the previous render left here (a hover dim), before this overwrites
|
|
1877
|
+
// it with the now-cleared set.
|
|
1878
|
+
useEffect(() => {
|
|
1879
|
+
prevDimmedRef.current = dimmedElementIds ?? null;
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1201
1882
|
// After the first paint, prefetch the opened layout for every container, so a
|
|
1202
1883
|
// click swaps to a cached result instead of waiting on ELK.
|
|
1203
1884
|
useEffect(() => {
|
|
@@ -1222,7 +1903,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1222
1903
|
void (async () => {
|
|
1223
1904
|
for (const target of targets) {
|
|
1224
1905
|
if (!alive) return;
|
|
1225
|
-
const key =
|
|
1906
|
+
const key = stateKey(target);
|
|
1226
1907
|
if (layoutCache.current.has(key)) continue;
|
|
1227
1908
|
try {
|
|
1228
1909
|
const r = await layoutFor(target, buildInputs(target));
|
|
@@ -1239,11 +1920,93 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1239
1920
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1240
1921
|
}, [layoutKey, model.view]);
|
|
1241
1922
|
|
|
1923
|
+
/** Open a construct's functionality, or close it when re-clicked. */
|
|
1924
|
+
const selectConstruct = useCallback((alias: string) => {
|
|
1925
|
+
setSelectedConstructAlias((cur) => (cur === alias ? null : alias));
|
|
1926
|
+
}, []);
|
|
1927
|
+
|
|
1928
|
+
// Scoped to this canvas so the reused component nodes dispatch into this
|
|
1929
|
+
// graph's selection instead of a mounted component graph's module callbacks.
|
|
1930
|
+
const subsystemCallbacks = useMemo(() => ({ onSelect: selectConstruct }), [selectConstruct]);
|
|
1931
|
+
|
|
1242
1932
|
const selected = useMemo(
|
|
1243
1933
|
() => model.nodes.find((n) => n.id === selectedId) ?? null,
|
|
1244
1934
|
[model, selectedId],
|
|
1245
1935
|
);
|
|
1246
1936
|
|
|
1937
|
+
/** The rolled-up line under selection, when it is an edge. */
|
|
1938
|
+
const selectedEdge = useMemo(
|
|
1939
|
+
() => model.edges.find((e) => e.id === selectedEdgeId) ?? null,
|
|
1940
|
+
[model, selectedEdgeId],
|
|
1941
|
+
);
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* The step under persistent focus, resolved from the construct graph's trails
|
|
1945
|
+
* (the drawer shows its implementation). The host owns the selection.
|
|
1946
|
+
*/
|
|
1947
|
+
const focusedStepInfo = useMemo(() => {
|
|
1948
|
+
if (!focusedStep) return null;
|
|
1949
|
+
for (const cg of constructGraphByContainer?.values() ?? []) {
|
|
1950
|
+
const trail = cg.trails.find((t) => t.id === focusedStep.trailId);
|
|
1951
|
+
if (!trail) continue;
|
|
1952
|
+
const step = trail.steps[focusedStep.stepIndex];
|
|
1953
|
+
if (!step) continue;
|
|
1954
|
+
return { trail, step, stepIndex: focusedStep.stepIndex };
|
|
1955
|
+
}
|
|
1956
|
+
return null;
|
|
1957
|
+
}, [focusedStep, constructGraphByContainer]);
|
|
1958
|
+
|
|
1959
|
+
// Frame the focused step's constructs, the way the component graph frames a
|
|
1960
|
+
// focused step's edge: box the `from`/`to` node rects (absolute) and fit.
|
|
1961
|
+
// Waits out the drawer's height transition so the fit uses the reduced canvas.
|
|
1962
|
+
useEffect(() => {
|
|
1963
|
+
if (!focusedStepInfo) return;
|
|
1964
|
+
const rects: FlowRect[] = [];
|
|
1965
|
+
for (const id of [focusedStepInfo.step.from, focusedStepInfo.step.to]) {
|
|
1966
|
+
const r = absoluteRectOf({ nodes: nodesRef.current, groupBounds: new Map() }, id);
|
|
1967
|
+
if (r) rects.push(r);
|
|
1968
|
+
}
|
|
1969
|
+
if (rects.length === 0) return;
|
|
1970
|
+
const minX = Math.min(...rects.map((r) => r.x));
|
|
1971
|
+
const minY = Math.min(...rects.map((r) => r.y));
|
|
1972
|
+
const maxX = Math.max(...rects.map((r) => r.x + r.width));
|
|
1973
|
+
const maxY = Math.max(...rects.map((r) => r.y + r.height));
|
|
1974
|
+
const bounds = {
|
|
1975
|
+
x: minX,
|
|
1976
|
+
y: minY,
|
|
1977
|
+
width: Math.max(1, maxX - minX),
|
|
1978
|
+
height: Math.max(1, maxY - minY),
|
|
1979
|
+
};
|
|
1980
|
+
const timer = setTimeout(() => {
|
|
1981
|
+
requestAnimationFrame(() => fitBounds(bounds, { padding: 0.2, duration: MORPH_MS }));
|
|
1982
|
+
}, FILE_DRAWER_HEIGHT_MS + 20);
|
|
1983
|
+
return () => clearTimeout(timer);
|
|
1984
|
+
// `nodesRef` is a ref; `settled` re-fires once the constructs have landed.
|
|
1985
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1986
|
+
}, [focusedStepInfo, settled, fitBounds]);
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* The construct under selection, folded to what the functionality panel
|
|
1990
|
+
* shows. Resolved across every container's construct graph (aliases are unique
|
|
1991
|
+
* in the document, so at most one matches).
|
|
1992
|
+
*/
|
|
1993
|
+
const selectedConstruct = useMemo(() => {
|
|
1994
|
+
if (!selectedConstructAlias) return null;
|
|
1995
|
+
for (const cg of constructGraphByContainer?.values() ?? []) {
|
|
1996
|
+
const c = cg.components.find((x) => x.alias === selectedConstructAlias);
|
|
1997
|
+
if (!c) continue;
|
|
1998
|
+
return {
|
|
1999
|
+
component: c,
|
|
2000
|
+
name: deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype),
|
|
2001
|
+
badge: constructBadgeLabel(c),
|
|
2002
|
+
color: componentColor(c, resolvePierreSyntaxThemeName(mode)),
|
|
2003
|
+
role: rightBadgeLabel(c),
|
|
2004
|
+
declaration: generateDeclarationString(c),
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
return null;
|
|
2008
|
+
}, [selectedConstructAlias, constructGraphByContainer, mode]);
|
|
2009
|
+
|
|
1247
2010
|
/**
|
|
1248
2011
|
* The components authored inside the selected element, if it is a container.
|
|
1249
2012
|
* These are `C4Component`s whose `container` names the selected id (or, once
|
|
@@ -1254,7 +2017,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1254
2017
|
() =>
|
|
1255
2018
|
selected
|
|
1256
2019
|
? model.nodes.filter(
|
|
1257
|
-
(n) =>
|
|
2020
|
+
(n): n is C4Component =>
|
|
1258
2021
|
n.kind === 'component' &&
|
|
1259
2022
|
(n.container === selected.id || n.parentId === selected.id),
|
|
1260
2023
|
)
|
|
@@ -1262,33 +2025,93 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1262
2025
|
[model, selected],
|
|
1263
2026
|
);
|
|
1264
2027
|
|
|
2028
|
+
/**
|
|
2029
|
+
* A selected container's member aliases, resolved from the construct graph
|
|
2030
|
+
* (a container's members are derived, not stored — see `C4ElementBase`). Only
|
|
2031
|
+
* populated when the container hosts focused constructs.
|
|
2032
|
+
*/
|
|
2033
|
+
const selectedContainerMembers = useMemo(
|
|
2034
|
+
() =>
|
|
2035
|
+
selected?.kind === 'container'
|
|
2036
|
+
? (constructGraphByContainer?.get(selected.id)?.components ?? []).map((c) => c.alias)
|
|
2037
|
+
: [],
|
|
2038
|
+
[selected, constructGraphByContainer],
|
|
2039
|
+
);
|
|
2040
|
+
|
|
1265
2041
|
/** The containers open for drill-down, normalized. */
|
|
1266
2042
|
const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
|
|
1267
2043
|
|
|
1268
2044
|
const displayNodes = useMemo(
|
|
1269
2045
|
() =>
|
|
1270
2046
|
nodes.map((n) => {
|
|
2047
|
+
// A card kept only to fade out is never hidden or dimmed — it is on its
|
|
2048
|
+
// way off screen and must stay visible for the fade.
|
|
2049
|
+
if ((n.data as { leaving?: boolean } | undefined)?.leaving) return n;
|
|
2050
|
+
// Constructs are the component graph's own nodes. They ride the morph
|
|
2051
|
+
// gate of the container that hosts them (they arrive with its grow) and
|
|
2052
|
+
// dim with it; the node reads `isSelected` from data because it stops
|
|
2053
|
+
// its own click propagation.
|
|
2054
|
+
if (n.type === 'subsystem-component') {
|
|
2055
|
+
const owner = (n.data as { c4Owner?: string } | undefined)?.c4Owner;
|
|
2056
|
+
const hidden = hidesComponentDuringMorph(
|
|
2057
|
+
owner,
|
|
2058
|
+
openContainerIds,
|
|
2059
|
+
morphTargetsRef.current,
|
|
2060
|
+
settled,
|
|
2061
|
+
);
|
|
2062
|
+
// Dim with its container, or on its own when the host spotlights a
|
|
2063
|
+
// step's constructs (the component graph's per-component behaviour).
|
|
2064
|
+
const ownerDimmed = dimmedElementIds && owner ? dimmedElementIds.has(owner) : false;
|
|
2065
|
+
const stepDimmed = dimmedConstructIds?.has(n.id) === true;
|
|
2066
|
+
return {
|
|
2067
|
+
...n,
|
|
2068
|
+
...(hidden ? { hidden: true } : {}),
|
|
2069
|
+
data: {
|
|
2070
|
+
...n.data,
|
|
2071
|
+
dimmed: ownerDimmed || stepDimmed,
|
|
2072
|
+
isSelected: n.id === selectedConstructAlias,
|
|
2073
|
+
},
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
1271
2076
|
if (n.type !== 'c4-node') return n;
|
|
1272
2077
|
const element = (n.data as { element?: C4Element } | undefined)?.element;
|
|
1273
2078
|
const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
|
|
1274
|
-
// `hidesComponentDuringMorph` owns the rule; see there for why it
|
|
1275
|
-
// per-container rather than global.
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
2079
|
+
// `hidesComponentDuringMorph` owns the morph rule; see there for why it
|
|
2080
|
+
// is per-container rather than global. A host `hiddenElementIds` (the
|
|
2081
|
+
// focused-trail cast) removes the box outright.
|
|
2082
|
+
const hidden =
|
|
2083
|
+
hidesComponentDuringMorph(
|
|
2084
|
+
owner,
|
|
2085
|
+
openContainerIds,
|
|
2086
|
+
morphTargetsRef.current,
|
|
2087
|
+
settled,
|
|
2088
|
+
) || (element ? hiddenElementIds?.has(element.id) === true : false);
|
|
1282
2089
|
return {
|
|
1283
2090
|
...n,
|
|
1284
2091
|
...(hidden ? { hidden: true } : {}),
|
|
1285
|
-
data: {
|
|
2092
|
+
data: {
|
|
2093
|
+
...n.data,
|
|
2094
|
+
selected: n.id === selectedId,
|
|
2095
|
+
settled,
|
|
2096
|
+
dimmed: dimmedElementIds
|
|
2097
|
+
? (element ? dimmedElementIds.has(element.id) : false)
|
|
2098
|
+
: false,
|
|
2099
|
+
},
|
|
1286
2100
|
};
|
|
1287
2101
|
}),
|
|
1288
2102
|
// `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
|
|
1289
2103
|
// it, because the two always change together — targets are written in the same
|
|
1290
2104
|
// tick that clears `settled`, and cleared in the same tick that restores it.
|
|
1291
|
-
[
|
|
2105
|
+
[
|
|
2106
|
+
nodes,
|
|
2107
|
+
selectedId,
|
|
2108
|
+
settled,
|
|
2109
|
+
openContainerIds,
|
|
2110
|
+
dimmedElementIds,
|
|
2111
|
+
dimmedConstructIds,
|
|
2112
|
+
hiddenElementIds,
|
|
2113
|
+
selectedConstructAlias,
|
|
2114
|
+
],
|
|
1292
2115
|
);
|
|
1293
2116
|
|
|
1294
2117
|
/**
|
|
@@ -1296,15 +2119,112 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1296
2119
|
* gliding between the two slots the old lines would visibly point at the wrong
|
|
1297
2120
|
* boxes. Hiding them for the morph is honest and cheap; interpolating the
|
|
1298
2121
|
* polylines to match is not.
|
|
2122
|
+
*
|
|
2123
|
+
* The dim set is the union of
|
|
2124
|
+
* (a) the own endpoint rule — a line between two dimmed boxes fades with
|
|
2125
|
+
* them, a line into a lit container stays bright on its own; and
|
|
2126
|
+
* (b) the host's line-relevance set — edges that don't carry the focused
|
|
2127
|
+
* trail / hovered step (a step internal to a container folds into no
|
|
2128
|
+
* line at all, so even lines out of the lit container fade).
|
|
2129
|
+
*
|
|
2130
|
+
* The selected edge never dims: it is the inspector's subject.
|
|
1299
2131
|
*/
|
|
2132
|
+
const dimmedEdgeIdSet = useMemo(() => {
|
|
2133
|
+
if (!dimmedElementIds && !dimmedEdgeIds) return null;
|
|
2134
|
+
const out = new Set<string>();
|
|
2135
|
+
if (dimmedElementIds) {
|
|
2136
|
+
for (const e of rfEdges) {
|
|
2137
|
+
if (dimmedElementIds.has(e.source) && dimmedElementIds.has(e.target)) out.add(e.id);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
if (dimmedEdgeIds) for (const id of dimmedEdgeIds) out.add(id);
|
|
2141
|
+
if (selectedEdgeId) out.delete(selectedEdgeId);
|
|
2142
|
+
return out;
|
|
2143
|
+
}, [dimmedElementIds, dimmedEdgeIds, rfEdges, selectedEdgeId]);
|
|
2144
|
+
|
|
2145
|
+
const hiddenEdgeIdSet = useMemo(() => {
|
|
2146
|
+
if (!hiddenElementIds && !hiddenEdgeIds) return null;
|
|
2147
|
+
const out = new Set<string>();
|
|
2148
|
+
if (hiddenElementIds) {
|
|
2149
|
+
// Either endpoint gone → the line has nothing to attach to, so it goes
|
|
2150
|
+
// with the box (unlike the dim rule, which needs both endpoints dimmed).
|
|
2151
|
+
for (const e of rfEdges) {
|
|
2152
|
+
if (hiddenElementIds.has(e.source) || hiddenElementIds.has(e.target)) out.add(e.id);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
if (hiddenEdgeIds) for (const id of hiddenEdgeIds) out.add(id);
|
|
2156
|
+
return out;
|
|
2157
|
+
}, [hiddenElementIds, hiddenEdgeIds, rfEdges]);
|
|
2158
|
+
|
|
1300
2159
|
const displayEdges = useMemo(
|
|
1301
|
-
() =>
|
|
1302
|
-
|
|
2160
|
+
() =>
|
|
2161
|
+
rfEdges.map((e) => {
|
|
2162
|
+
const hidden = !settled || hiddenEdgeIdSet?.has(e.id) === true;
|
|
2163
|
+
const dimmed =
|
|
2164
|
+
dimmedEdgeIdSet?.has(e.id) === true ||
|
|
2165
|
+
dimmedConstructEdgeIds?.has(e.id) === true;
|
|
2166
|
+
// The arrowhead is a shared SVG marker: dimming the stroke (path opacity
|
|
2167
|
+
// or colour alpha) leaves it at full colour. Clone the marker with a
|
|
2168
|
+
// dimmed colour, as the component graph does.
|
|
2169
|
+
const markerEnd = e.markerEnd;
|
|
2170
|
+
const nextMarker =
|
|
2171
|
+
dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
2172
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
2173
|
+
: markerEnd;
|
|
2174
|
+
return {
|
|
2175
|
+
...e,
|
|
2176
|
+
...(hidden ? { hidden: true } : {}),
|
|
2177
|
+
markerEnd: nextMarker,
|
|
2178
|
+
data: {
|
|
2179
|
+
...e.data,
|
|
2180
|
+
selected: e.id === selectedEdgeId,
|
|
2181
|
+
dimmed,
|
|
2182
|
+
},
|
|
2183
|
+
};
|
|
2184
|
+
}),
|
|
2185
|
+
[rfEdges, settled, selectedEdgeId, dimmedEdgeIdSet, hiddenEdgeIdSet, dimmedConstructEdgeIds],
|
|
1303
2186
|
);
|
|
1304
2187
|
|
|
1305
|
-
|
|
1306
|
-
|
|
2188
|
+
/** The construct edges, for their mechanism-label overlay (carries the dim flag). */
|
|
2189
|
+
const subsystemLabelEdges = useMemo(
|
|
2190
|
+
() => displayEdges.filter((e) => e.type === 'subsystem-edge'),
|
|
2191
|
+
[displayEdges],
|
|
2192
|
+
);
|
|
2193
|
+
|
|
2194
|
+
// The step drawer: open while a step is focused and a viewer is not hidden.
|
|
2195
|
+
const drawerOpen = focusedStepInfo != null && !hideDrawer;
|
|
2196
|
+
const drawerTitle = focusedStepInfo
|
|
2197
|
+
? renderTrailViewer
|
|
2198
|
+
? focusedStepInfo.trail.title
|
|
2199
|
+
: focusedStepInfo.step.file
|
|
2200
|
+
: null;
|
|
2201
|
+
const drawerProgress =
|
|
2202
|
+
focusedStepInfo && focusedStepInfo.trail.steps.length > 1
|
|
2203
|
+
? { index: focusedStepInfo.stepIndex, total: focusedStepInfo.trail.steps.length }
|
|
2204
|
+
: null;
|
|
2205
|
+
|
|
2206
|
+
// The selection panel floats over the canvas in the top-right corner. A host
|
|
2207
|
+
// that wants the panel (or anything else) beside the graph wraps the graph in
|
|
2208
|
+
// `GraphWithSidebar` itself, so the graph stays overlay-only.
|
|
2209
|
+
const selectionPanelStyle: CSSProperties = {
|
|
2210
|
+
position: 'absolute',
|
|
2211
|
+
top: 12,
|
|
2212
|
+
right: 12,
|
|
2213
|
+
// Above the edge-label overlay (zIndex 5), or chips paint over the panel.
|
|
2214
|
+
zIndex: 10,
|
|
2215
|
+
width: 280,
|
|
2216
|
+
maxHeight: 'calc(100% - 24px)',
|
|
2217
|
+
overflowY: 'auto',
|
|
2218
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
2219
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
2220
|
+
borderRadius: 8,
|
|
2221
|
+
padding: '10px 12px',
|
|
2222
|
+
};
|
|
2223
|
+
|
|
2224
|
+
const canvasRoot = (
|
|
2225
|
+
<>
|
|
1307
2226
|
<GraphLayerStyle />
|
|
2227
|
+
<SubsystemCallbacksProvider value={subsystemCallbacks}>
|
|
1308
2228
|
<ReactFlow
|
|
1309
2229
|
nodes={displayNodes}
|
|
1310
2230
|
edges={displayEdges}
|
|
@@ -1316,11 +2236,27 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1316
2236
|
if (node.type !== 'c4-node') return;
|
|
1317
2237
|
const next = node.id === selectedId ? null : node.id;
|
|
1318
2238
|
setSelectedId(next);
|
|
2239
|
+
setSelectedEdgeId(null);
|
|
2240
|
+
setSelectedConstructAlias(null);
|
|
1319
2241
|
onSelectNode?.(next);
|
|
2242
|
+
onSelectEdge?.(null);
|
|
2243
|
+
}}
|
|
2244
|
+
onEdgeClick={(_e, edge) => {
|
|
2245
|
+
const next = edge.id === selectedEdgeId ? null : edge.id;
|
|
2246
|
+
setSelectedEdgeId(next);
|
|
2247
|
+
// An edge selection replaces the node panel — the two inspectors
|
|
2248
|
+
// sharing the corner would fight for it.
|
|
2249
|
+
setSelectedId(null);
|
|
2250
|
+
setSelectedConstructAlias(null);
|
|
2251
|
+
onSelectNode?.(null);
|
|
2252
|
+
onSelectEdge?.(next ? (model.edges.find((e) => e.id === next) ?? null) : null);
|
|
1320
2253
|
}}
|
|
1321
2254
|
onPaneClick={() => {
|
|
1322
2255
|
setSelectedId(null);
|
|
2256
|
+
setSelectedEdgeId(null);
|
|
2257
|
+
setSelectedConstructAlias(null);
|
|
1323
2258
|
onSelectNode?.(null);
|
|
2259
|
+
onSelectEdge?.(null);
|
|
1324
2260
|
}}
|
|
1325
2261
|
proOptions={{ hideAttribution: true }}
|
|
1326
2262
|
// Pin the flow to its container. The label overlay is a sibling sized
|
|
@@ -1330,12 +2266,16 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1330
2266
|
>
|
|
1331
2267
|
<GraphChrome />
|
|
1332
2268
|
</ReactFlow>
|
|
2269
|
+
</SubsystemCallbacksProvider>
|
|
1333
2270
|
{/* Gated on `settled`, like the edges themselves: a chip is anchored to the
|
|
1334
2271
|
*settled* midpoint of its edge, so showing one while the cards glide leaves
|
|
1335
2272
|
it pointing at the space between two layouts. It rejoins them, the nested
|
|
1336
2273
|
components and the descriptions in one reveal. */}
|
|
1337
2274
|
{ready && settled && labelPositions.size > 0 && (
|
|
1338
|
-
<C4EdgeLabels positions={labelPositions} edges={model.edges} />
|
|
2275
|
+
<C4EdgeLabels positions={labelPositions} edges={model.edges} selectedEdgeId={selectedEdgeId} dimmedEdgeIds={dimmedEdgeIdSet} hiddenEdgeIds={hiddenEdgeIdSet} />
|
|
2276
|
+
)}
|
|
2277
|
+
{ready && settled && subsystemLabelEdges.length > 0 && (
|
|
2278
|
+
<SubsystemEdgeLabels edges={subsystemLabelEdges} />
|
|
1339
2279
|
)}
|
|
1340
2280
|
{!ready && (
|
|
1341
2281
|
<div
|
|
@@ -1354,24 +2294,12 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1354
2294
|
Laying out C4 view…
|
|
1355
2295
|
</div>
|
|
1356
2296
|
)}
|
|
2297
|
+
</>
|
|
2298
|
+
);
|
|
2299
|
+
const selectionPanels = (
|
|
2300
|
+
<>
|
|
1357
2301
|
{selected && (
|
|
1358
|
-
<div
|
|
1359
|
-
style={{
|
|
1360
|
-
position: 'absolute',
|
|
1361
|
-
top: 12,
|
|
1362
|
-
right: 12,
|
|
1363
|
-
// Above the edge-label overlay (zIndex 5), or chips paint over the
|
|
1364
|
-
// panel.
|
|
1365
|
-
zIndex: 10,
|
|
1366
|
-
width: 280,
|
|
1367
|
-
maxHeight: 'calc(100% - 24px)',
|
|
1368
|
-
overflowY: 'auto',
|
|
1369
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1370
|
-
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
1371
|
-
borderRadius: 8,
|
|
1372
|
-
padding: '10px 12px',
|
|
1373
|
-
}}
|
|
1374
|
-
>
|
|
2302
|
+
<div style={selectionPanelStyle}>
|
|
1375
2303
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
1376
2304
|
<span style={{ fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
1377
2305
|
{selected.label}
|
|
@@ -1476,7 +2404,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1476
2404
|
whiteSpace: 'nowrap',
|
|
1477
2405
|
}}
|
|
1478
2406
|
>
|
|
1479
|
-
{
|
|
2407
|
+
{c.technology || c.kind}
|
|
1480
2408
|
{(c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''}
|
|
1481
2409
|
</span>
|
|
1482
2410
|
</div>
|
|
@@ -1508,8 +2436,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1508
2436
|
>
|
|
1509
2437
|
Components
|
|
1510
2438
|
</span>
|
|
1511
|
-
{
|
|
1512
|
-
|
|
2439
|
+
{selectedContainerMembers.length > 0 ? (
|
|
2440
|
+
selectedContainerMembers.map((alias) => (
|
|
1513
2441
|
<span
|
|
1514
2442
|
key={alias}
|
|
1515
2443
|
title={alias}
|
|
@@ -1540,6 +2468,290 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
|
|
|
1540
2468
|
)}
|
|
1541
2469
|
</div>
|
|
1542
2470
|
)}
|
|
2471
|
+
|
|
2472
|
+
{selectedEdge && ready && (
|
|
2473
|
+
<div style={selectionPanelStyle}>
|
|
2474
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
2475
|
+
<span
|
|
2476
|
+
aria-hidden
|
|
2477
|
+
style={{
|
|
2478
|
+
width: 8,
|
|
2479
|
+
height: 8,
|
|
2480
|
+
borderRadius: '50%',
|
|
2481
|
+
background: protocolColor(selectedEdge.protocol),
|
|
2482
|
+
flexShrink: 0,
|
|
2483
|
+
}}
|
|
2484
|
+
/>
|
|
2485
|
+
<span
|
|
2486
|
+
style={{
|
|
2487
|
+
fontFamily: theme.fonts.monospace,
|
|
2488
|
+
fontWeight: 600,
|
|
2489
|
+
color: theme.colors.text,
|
|
2490
|
+
flex: 1,
|
|
2491
|
+
minWidth: 0,
|
|
2492
|
+
overflow: 'hidden',
|
|
2493
|
+
textOverflow: 'ellipsis',
|
|
2494
|
+
whiteSpace: 'nowrap',
|
|
2495
|
+
}}
|
|
2496
|
+
>
|
|
2497
|
+
{selectedEdge.label}
|
|
2498
|
+
</span>
|
|
2499
|
+
<button
|
|
2500
|
+
type="button"
|
|
2501
|
+
onClick={() => {
|
|
2502
|
+
setSelectedEdgeId(null);
|
|
2503
|
+
onSelectEdge?.(null);
|
|
2504
|
+
}}
|
|
2505
|
+
aria-label="Clear selection"
|
|
2506
|
+
style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
|
|
2507
|
+
>
|
|
2508
|
+
×
|
|
2509
|
+
</button>
|
|
2510
|
+
</div>
|
|
2511
|
+
|
|
2512
|
+
{/* The rollup row: what this line folded up. The protocol color the
|
|
2513
|
+
card already carries is enough — this is the count + verbs. */}
|
|
2514
|
+
<div
|
|
2515
|
+
style={{
|
|
2516
|
+
display: 'flex',
|
|
2517
|
+
alignItems: 'center',
|
|
2518
|
+
gap: 6,
|
|
2519
|
+
flexWrap: 'wrap',
|
|
2520
|
+
marginTop: 6,
|
|
2521
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
2522
|
+
paddingTop: 6,
|
|
2523
|
+
}}
|
|
2524
|
+
>
|
|
2525
|
+
{selectedEdge.mechanisms.map((m) => (
|
|
2526
|
+
<span
|
|
2527
|
+
key={m}
|
|
2528
|
+
style={{
|
|
2529
|
+
fontFamily: theme.fonts.monospace,
|
|
2530
|
+
fontSize: theme.fontSizes[0],
|
|
2531
|
+
color: theme.colors.text,
|
|
2532
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
2533
|
+
borderRadius: 4,
|
|
2534
|
+
padding: '1px 6px',
|
|
2535
|
+
}}
|
|
2536
|
+
>
|
|
2537
|
+
{m}
|
|
2538
|
+
</span>
|
|
2539
|
+
))}
|
|
2540
|
+
<span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }}>
|
|
2541
|
+
{selectedEdge.count} {selectedEdge.count === 1 ? 'step' : 'steps'}
|
|
2542
|
+
</span>
|
|
2543
|
+
</div>
|
|
2544
|
+
|
|
2545
|
+
{/* The drill-down list: the trails this line rolls up, each handed to
|
|
2546
|
+
the host for a deep-link into the component graph. */}
|
|
2547
|
+
<div
|
|
2548
|
+
style={{
|
|
2549
|
+
display: 'flex',
|
|
2550
|
+
flexDirection: 'column',
|
|
2551
|
+
gap: 4,
|
|
2552
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
2553
|
+
paddingTop: 6,
|
|
2554
|
+
marginTop: 6,
|
|
2555
|
+
}}
|
|
2556
|
+
>
|
|
2557
|
+
<span
|
|
2558
|
+
style={{
|
|
2559
|
+
fontFamily: theme.fonts.monospace,
|
|
2560
|
+
fontSize: theme.fontSizes[0],
|
|
2561
|
+
color: muted,
|
|
2562
|
+
textTransform: 'uppercase',
|
|
2563
|
+
letterSpacing: 0.5,
|
|
2564
|
+
}}
|
|
2565
|
+
>
|
|
2566
|
+
Backed by trails
|
|
2567
|
+
</span>
|
|
2568
|
+
{(selectedEdge.trailRefs ?? []).length > 0 ? (
|
|
2569
|
+
(selectedEdge.trailRefs ?? []).map((ref) => (
|
|
2570
|
+
<button
|
|
2571
|
+
key={ref.trailId}
|
|
2572
|
+
type="button"
|
|
2573
|
+
onClick={() => onTrailClick?.(ref)}
|
|
2574
|
+
disabled={!onTrailClick}
|
|
2575
|
+
title={
|
|
2576
|
+
onTrailClick
|
|
2577
|
+
? `Focus this trail in the component graph`
|
|
2578
|
+
: 'Trail drill-down needs onTrailClick'
|
|
2579
|
+
}
|
|
2580
|
+
style={{
|
|
2581
|
+
display: 'flex',
|
|
2582
|
+
alignItems: 'center',
|
|
2583
|
+
gap: 6,
|
|
2584
|
+
textAlign: 'left',
|
|
2585
|
+
border: 'none',
|
|
2586
|
+
background: 'transparent',
|
|
2587
|
+
padding: 0,
|
|
2588
|
+
cursor: onTrailClick ? 'pointer' : 'default',
|
|
2589
|
+
fontFamily: theme.fonts.monospace,
|
|
2590
|
+
fontSize: theme.fontSizes[0],
|
|
2591
|
+
}}
|
|
2592
|
+
>
|
|
2593
|
+
<span style={{ color: theme.colors.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
2594
|
+
{ref.trailId}
|
|
2595
|
+
</span>
|
|
2596
|
+
<span style={{ color: muted, flexShrink: 0 }}>
|
|
2597
|
+
{ref.stepIndices.length} {ref.stepIndices.length === 1 ? 'step' : 'steps'}
|
|
2598
|
+
{ref.stepIndices.length > 0 ? ` · #${ref.stepIndices.map((i) => i + 1).join(', #')}` : ''}
|
|
2599
|
+
</span>
|
|
2600
|
+
</button>
|
|
2601
|
+
))
|
|
2602
|
+
) : (
|
|
2603
|
+
<span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }}>
|
|
2604
|
+
Authored line — no trail provenance yet
|
|
2605
|
+
</span>
|
|
2606
|
+
)}
|
|
2607
|
+
</div>
|
|
2608
|
+
</div>
|
|
2609
|
+
)}
|
|
2610
|
+
|
|
2611
|
+
{/* Construct functionality — a construct row's declaration + purpose,
|
|
2612
|
+
opened from a card. Its own bottom-left panel so it never fights the
|
|
2613
|
+
top-right node/edge inspector for the corner. */}
|
|
2614
|
+
{selectedConstruct && (
|
|
2615
|
+
<div
|
|
2616
|
+
style={{
|
|
2617
|
+
...selectionPanelStyle,
|
|
2618
|
+
top: 'auto',
|
|
2619
|
+
bottom: 12,
|
|
2620
|
+
left: 12,
|
|
2621
|
+
right: 'auto',
|
|
2622
|
+
width: 320,
|
|
2623
|
+
maxHeight: 'calc(50% - 18px)',
|
|
2624
|
+
}}
|
|
2625
|
+
>
|
|
2626
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
2627
|
+
<span
|
|
2628
|
+
aria-hidden
|
|
2629
|
+
style={{
|
|
2630
|
+
width: 8,
|
|
2631
|
+
height: 8,
|
|
2632
|
+
borderRadius: '50%',
|
|
2633
|
+
background: selectedConstruct.color,
|
|
2634
|
+
flexShrink: 0,
|
|
2635
|
+
}}
|
|
2636
|
+
/>
|
|
2637
|
+
<span
|
|
2638
|
+
style={{
|
|
2639
|
+
fontFamily: theme.fonts.monospace,
|
|
2640
|
+
fontWeight: 600,
|
|
2641
|
+
color: theme.colors.text,
|
|
2642
|
+
flex: 1,
|
|
2643
|
+
minWidth: 0,
|
|
2644
|
+
overflow: 'hidden',
|
|
2645
|
+
textOverflow: 'ellipsis',
|
|
2646
|
+
whiteSpace: 'nowrap',
|
|
2647
|
+
}}
|
|
2648
|
+
>
|
|
2649
|
+
{selectedConstruct.name}
|
|
2650
|
+
</span>
|
|
2651
|
+
<span
|
|
2652
|
+
style={{
|
|
2653
|
+
flexShrink: 0,
|
|
2654
|
+
fontFamily: theme.fonts.monospace,
|
|
2655
|
+
fontSize: theme.fontSizes[0],
|
|
2656
|
+
color: muted,
|
|
2657
|
+
textTransform: 'uppercase',
|
|
2658
|
+
letterSpacing: 0.4,
|
|
2659
|
+
}}
|
|
2660
|
+
>
|
|
2661
|
+
{selectedConstruct.badge}
|
|
2662
|
+
</span>
|
|
2663
|
+
<button
|
|
2664
|
+
type="button"
|
|
2665
|
+
onClick={() => setSelectedConstructAlias(null)}
|
|
2666
|
+
aria-label="Clear construct"
|
|
2667
|
+
style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
|
|
2668
|
+
>
|
|
2669
|
+
×
|
|
2670
|
+
</button>
|
|
2671
|
+
</div>
|
|
2672
|
+
{selectedConstruct.component.purpose && (
|
|
2673
|
+
<div
|
|
2674
|
+
style={{
|
|
2675
|
+
marginTop: 6,
|
|
2676
|
+
fontFamily: theme.fonts.body,
|
|
2677
|
+
fontSize: theme.fontSizes[0],
|
|
2678
|
+
color: theme.colors.text,
|
|
2679
|
+
lineHeight: 1.4,
|
|
2680
|
+
}}
|
|
2681
|
+
>
|
|
2682
|
+
{selectedConstruct.component.purpose}
|
|
2683
|
+
</div>
|
|
2684
|
+
)}
|
|
2685
|
+
{selectedConstruct.declaration && (
|
|
2686
|
+
<pre
|
|
2687
|
+
style={{
|
|
2688
|
+
marginTop: 6,
|
|
2689
|
+
marginBottom: 0,
|
|
2690
|
+
padding: '6px 8px',
|
|
2691
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
2692
|
+
background: theme.colors.background ?? 'transparent',
|
|
2693
|
+
color: theme.colors.text,
|
|
2694
|
+
fontFamily: theme.fonts.monospace,
|
|
2695
|
+
fontSize: theme.fontSizes[0],
|
|
2696
|
+
lineHeight: 1.45,
|
|
2697
|
+
whiteSpace: 'pre-wrap',
|
|
2698
|
+
overflowWrap: 'anywhere',
|
|
2699
|
+
}}
|
|
2700
|
+
>
|
|
2701
|
+
{selectedConstruct.declaration}
|
|
2702
|
+
</pre>
|
|
2703
|
+
)}
|
|
2704
|
+
<div
|
|
2705
|
+
style={{
|
|
2706
|
+
marginTop: 6,
|
|
2707
|
+
fontFamily: theme.fonts.monospace,
|
|
2708
|
+
fontSize: theme.fontSizes[0],
|
|
2709
|
+
color: muted,
|
|
2710
|
+
}}
|
|
2711
|
+
>
|
|
2712
|
+
{selectedConstruct.component.alias}
|
|
2713
|
+
{selectedConstruct.role ? ` · ${selectedConstruct.role}` : ''}
|
|
2714
|
+
{selectedConstruct.component.proposed ? ' · proposed' : ''}
|
|
2715
|
+
</div>
|
|
2716
|
+
</div>
|
|
2717
|
+
)}
|
|
2718
|
+
</>
|
|
2719
|
+
);
|
|
2720
|
+
return (
|
|
2721
|
+
<div
|
|
2722
|
+
style={{
|
|
2723
|
+
position: 'relative',
|
|
2724
|
+
width: '100%',
|
|
2725
|
+
height: '100%',
|
|
2726
|
+
display: 'flex',
|
|
2727
|
+
flexDirection: 'column',
|
|
2728
|
+
background: theme.colors.background,
|
|
2729
|
+
}}
|
|
2730
|
+
>
|
|
2731
|
+
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
|
2732
|
+
{canvasRoot}
|
|
2733
|
+
{selectionPanels}
|
|
2734
|
+
</div>
|
|
2735
|
+
<FileDrawer
|
|
2736
|
+
title={drawerOpen ? drawerTitle : null}
|
|
2737
|
+
onClose={() => onCloseStep?.()}
|
|
2738
|
+
hidden={hideDrawer}
|
|
2739
|
+
progress={drawerProgress}
|
|
2740
|
+
>
|
|
2741
|
+
{drawerOpen && focusedStepInfo
|
|
2742
|
+
? renderTrailViewer
|
|
2743
|
+
? renderTrailViewer({
|
|
2744
|
+
trail: focusedStepInfo.trail,
|
|
2745
|
+
stepIndex: focusedStepInfo.stepIndex,
|
|
2746
|
+
})
|
|
2747
|
+
: renderFileViewer
|
|
2748
|
+
? renderFileViewer(focusedStepInfo.step.file, {
|
|
2749
|
+
startLine: focusedStepInfo.step.span?.startLine,
|
|
2750
|
+
endLine: focusedStepInfo.step.span?.endLine,
|
|
2751
|
+
})
|
|
2752
|
+
: <StepDrawerFallback step={focusedStepInfo.step} />
|
|
2753
|
+
: null}
|
|
2754
|
+
</FileDrawer>
|
|
1543
2755
|
</div>
|
|
1544
2756
|
);
|
|
1545
2757
|
}
|