@graph-artifact/core 0.1.6 → 0.1.9
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/components/DiagramCanvas.d.ts +3 -1
- package/dist/components/DiagramCanvas.js +2 -2
- package/dist/components/GraphCanvas.d.ts +10 -0
- package/dist/components/GraphCanvas.js +202 -22
- package/dist/components/nodes/FlowNode.js +9 -6
- package/dist/config.js +2 -1
- package/dist/layout/dagre/index.d.ts +4 -1
- package/dist/layout/dagre/index.js +3 -2
- package/dist/layout/dagre/nodeSizing.js +27 -2
- package/dist/layout/edges/buildEdges.js +9 -7
- package/dist/layout/index.d.ts +4 -1
- package/dist/layout/index.js +11 -2
- package/dist/layout/sequenceLayout.js +39 -6
- package/dist/layout/subgraph.js +29 -20
- package/dist/parsers/flowchart.js +17 -8
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
|
@@ -6,6 +6,8 @@ interface DiagramCanvasProps {
|
|
|
6
6
|
onNodeClick: (nodeId: string, nodeLabel: string) => void;
|
|
7
7
|
canvasOverrides?: Partial<GraphConfig['canvas']>;
|
|
8
8
|
hideAttribution?: boolean;
|
|
9
|
+
relayoutOnMeasure?: boolean;
|
|
10
|
+
onLayoutStable?: () => void;
|
|
9
11
|
}
|
|
10
|
-
export declare function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution }: DiagramCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export declare function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable }: DiagramCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
11
13
|
export {};
|
|
@@ -11,9 +11,9 @@ function UnsupportedCustomDiagram({ parsed }) {
|
|
|
11
11
|
fontSize: 14,
|
|
12
12
|
}, children: `Diagram type '${parsed.diagramType}' is detected but custom rendering is not implemented yet.` }));
|
|
13
13
|
}
|
|
14
|
-
export function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution }) {
|
|
14
|
+
export function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable }) {
|
|
15
15
|
if (parsed.kind === 'graph') {
|
|
16
|
-
return (_jsx(GraphCanvas, { parsed: parsed, metadata: metadata, onNodeClick: onNodeClick, canvasOverrides: canvasOverrides, hideAttribution: hideAttribution }));
|
|
16
|
+
return (_jsx(GraphCanvas, { parsed: parsed, metadata: metadata, onNodeClick: onNodeClick, canvasOverrides: canvasOverrides, hideAttribution: hideAttribution, relayoutOnMeasure: relayoutOnMeasure, onLayoutStable: onLayoutStable }));
|
|
17
17
|
}
|
|
18
18
|
return _jsx(UnsupportedCustomDiagram, { parsed: parsed });
|
|
19
19
|
}
|
|
@@ -7,6 +7,16 @@ interface GraphCanvasProps {
|
|
|
7
7
|
onNodeClick: (nodeId: string, nodeLabel: string) => void;
|
|
8
8
|
canvasOverrides?: Partial<GraphConfig['canvas']>;
|
|
9
9
|
hideAttribution?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* If true, reruns dagre after React Flow measures node DOM sizes so
|
|
12
|
+
* layout reflects actual rendered content (auto-sized text/markdown).
|
|
13
|
+
*/
|
|
14
|
+
relayoutOnMeasure?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Called once when layout is stable and fitView has been applied (if enabled).
|
|
17
|
+
* Useful for host apps that need to know when the diagram is "ready".
|
|
18
|
+
*/
|
|
19
|
+
onLayoutStable?: () => void;
|
|
10
20
|
}
|
|
11
21
|
export declare function GraphCanvas(props: GraphCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
12
22
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
|
|
3
|
-
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useNodesInitialized, useStore, useStoreApi, } from '@xyflow/react';
|
|
3
|
+
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useReactFlow, useNodesInitialized, useStore, useStoreApi, } from '@xyflow/react';
|
|
4
4
|
import '@xyflow/react/dist/style.css';
|
|
5
5
|
import { layoutNodes, buildLayoutEdges } from '../layout/index.js';
|
|
6
6
|
import { getConfig, getNodeTypes } from '../config.js';
|
|
@@ -8,12 +8,13 @@ import { useTheme } from '../ThemeContext.js';
|
|
|
8
8
|
import { RoutedEdge } from './edges/RoutedEdge.js';
|
|
9
9
|
// Stable reference — must be outside the component to avoid infinite re-renders
|
|
10
10
|
const edgeTypes = { routed: RoutedEdge };
|
|
11
|
-
function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution }) {
|
|
11
|
+
function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable, }) {
|
|
12
12
|
const theme = useTheme();
|
|
13
13
|
const { canvas: baseCanvas } = getConfig();
|
|
14
14
|
const canvas = useMemo(() => ({ ...baseCanvas, ...(canvasOverrides ?? {}) }), [baseCanvas, canvasOverrides]);
|
|
15
15
|
const resolvedNodeTypes = getNodeTypes();
|
|
16
16
|
const storeApi = useStoreApi();
|
|
17
|
+
const reactFlow = useReactFlow();
|
|
17
18
|
const nodesInitialized = useNodesInitialized();
|
|
18
19
|
const measuredSignature = useStore((s) => Array.from(s.nodeLookup.values()).reduce((hash, n) => {
|
|
19
20
|
const w = Math.round(n.measured?.width ?? n.width ?? 0);
|
|
@@ -26,8 +27,15 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
26
27
|
hash = ((hash << 5) - hash + h) | 0;
|
|
27
28
|
return hash;
|
|
28
29
|
}, 0));
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const defaultRelayout = parsed.diagramType === 'flowchart' ||
|
|
31
|
+
parsed.diagramType === 'er' ||
|
|
32
|
+
parsed.diagramType === 'class' ||
|
|
33
|
+
parsed.diagramType === 'gitGraph';
|
|
34
|
+
const shouldRelayoutOnMeasure = Boolean(relayoutOnMeasure ?? defaultRelayout) &&
|
|
35
|
+
parsed.diagramType !== 'sequence' &&
|
|
36
|
+
parsed.diagramType !== 'mindmap';
|
|
37
|
+
// Initial layout (dagre estimates).
|
|
38
|
+
const initialLayoutState = useMemo(() => {
|
|
31
39
|
try {
|
|
32
40
|
return {
|
|
33
41
|
result: layoutNodes(parsed, { theme, metadata }),
|
|
@@ -39,21 +47,134 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
39
47
|
return { result: null, error: message };
|
|
40
48
|
}
|
|
41
49
|
}, [parsed, theme, metadata]);
|
|
42
|
-
const
|
|
43
|
-
const nodes =
|
|
44
|
-
const context =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const [layoutError, setLayoutError] = useState(initialLayoutState.error);
|
|
51
|
+
const [nodes, setNodes] = useState(initialLayoutState.result?.nodes ?? []);
|
|
52
|
+
const [context, setContext] = useState(initialLayoutState.result?.context ?? undefined);
|
|
53
|
+
const [edges, setEdges] = useState(() => {
|
|
54
|
+
const r = initialLayoutState.result;
|
|
55
|
+
const c = r?.context ?? undefined;
|
|
56
|
+
return c
|
|
57
|
+
? buildLayoutEdges(c, theme)
|
|
58
|
+
: (r?.edges ?? []);
|
|
59
|
+
});
|
|
51
60
|
const contextRef = useRef(context);
|
|
52
61
|
contextRef.current = context;
|
|
53
|
-
//
|
|
62
|
+
// Apply fitView once per diagram after node measurement stabilizes.
|
|
63
|
+
const didInitialFit = useRef(false);
|
|
64
|
+
const didStableCallback = useRef(false);
|
|
54
65
|
useEffect(() => {
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
didInitialFit.current = false;
|
|
67
|
+
didStableCallback.current = false;
|
|
68
|
+
}, [parsed]);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!nodesInitialized)
|
|
71
|
+
return;
|
|
72
|
+
if (layoutError)
|
|
73
|
+
return;
|
|
74
|
+
if (nodes.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
// Wait until after relayout pass completes (if enabled).
|
|
77
|
+
if (shouldRelayoutOnMeasure && relayoutPass.current > 0 && relayoutPass.current < 2) {
|
|
78
|
+
// allow another pass to settle
|
|
79
|
+
}
|
|
80
|
+
// Always try to fitView once if enabled; React Flow's declarative fitView
|
|
81
|
+
// runs early and can miss when sizes change post-measurement.
|
|
82
|
+
if (canvas.fitView && !didInitialFit.current) {
|
|
83
|
+
didInitialFit.current = true;
|
|
84
|
+
// Double rAF to ensure DOM + measured sizes are committed.
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
requestAnimationFrame(() => {
|
|
87
|
+
try {
|
|
88
|
+
reactFlow.fitView({ padding: 0.2, maxZoom: 1, duration: 250 });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ignore fitView errors (unmounted / not ready)
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (onLayoutStable && !didStableCallback.current) {
|
|
97
|
+
didStableCallback.current = true;
|
|
98
|
+
onLayoutStable();
|
|
99
|
+
}
|
|
100
|
+
}, [
|
|
101
|
+
nodesInitialized,
|
|
102
|
+
measuredSignature,
|
|
103
|
+
layoutError,
|
|
104
|
+
nodes.length,
|
|
105
|
+
canvas.fitView,
|
|
106
|
+
reactFlow,
|
|
107
|
+
onLayoutStable,
|
|
108
|
+
shouldRelayoutOnMeasure,
|
|
109
|
+
]);
|
|
110
|
+
// Reset to initial layout when input diagram changes.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
setLayoutError(initialLayoutState.error);
|
|
113
|
+
if (!initialLayoutState.result) {
|
|
114
|
+
setNodes([]);
|
|
115
|
+
setContext(undefined);
|
|
116
|
+
setEdges([]);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const r = initialLayoutState.result;
|
|
120
|
+
const c = r.context ?? undefined;
|
|
121
|
+
setNodes(r.nodes);
|
|
122
|
+
setContext(c);
|
|
123
|
+
setEdges(c ? buildLayoutEdges(c, theme) : (r.edges ?? []));
|
|
124
|
+
}, [initialLayoutState, theme]);
|
|
125
|
+
// If we enable measured relayout, allow a couple of passes until stable.
|
|
126
|
+
const relayoutPass = useRef(0);
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
relayoutPass.current = 0;
|
|
129
|
+
}, [parsed, theme, metadata, shouldRelayoutOnMeasure]);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!shouldRelayoutOnMeasure)
|
|
132
|
+
return;
|
|
133
|
+
if (!nodesInitialized || !contextRef.current)
|
|
134
|
+
return;
|
|
135
|
+
if (relayoutPass.current >= 2)
|
|
136
|
+
return;
|
|
137
|
+
const nodeLookup = storeApi.getState().nodeLookup;
|
|
138
|
+
const overrides = new Map();
|
|
139
|
+
let anyDiff = false;
|
|
140
|
+
for (const internalNode of nodeLookup.values()) {
|
|
141
|
+
const estimated = contextRef.current.estimatedPositions.get(internalNode.id);
|
|
142
|
+
if (!estimated)
|
|
143
|
+
continue; // ignore subgraph containers and unknown nodes
|
|
144
|
+
const measuredW = internalNode.measured?.width ?? internalNode.width ?? estimated.width;
|
|
145
|
+
const measuredH = internalNode.measured?.height ?? internalNode.height ?? estimated.height;
|
|
146
|
+
if (!Number.isFinite(measuredW) || !Number.isFinite(measuredH) || measuredW <= 0 || measuredH <= 0)
|
|
147
|
+
continue;
|
|
148
|
+
overrides.set(internalNode.id, { width: measuredW, height: measuredH });
|
|
149
|
+
if (Math.abs(measuredW - estimated.width) > 2 || Math.abs(measuredH - estimated.height) > 2) {
|
|
150
|
+
anyDiff = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!anyDiff)
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
const next = layoutNodes(parsed, { theme, metadata }, overrides);
|
|
157
|
+
if (!next.context)
|
|
158
|
+
return; // sequence/custom returns edges directly
|
|
159
|
+
relayoutPass.current += 1;
|
|
160
|
+
setNodes(next.nodes);
|
|
161
|
+
setContext(next.context);
|
|
162
|
+
setEdges(buildLayoutEdges(next.context, theme));
|
|
163
|
+
setLayoutError(null);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
setLayoutError(message);
|
|
168
|
+
}
|
|
169
|
+
}, [
|
|
170
|
+
shouldRelayoutOnMeasure,
|
|
171
|
+
nodesInitialized,
|
|
172
|
+
measuredSignature,
|
|
173
|
+
parsed,
|
|
174
|
+
theme,
|
|
175
|
+
metadata,
|
|
176
|
+
storeApi,
|
|
177
|
+
]);
|
|
57
178
|
// Phase 2: Recompute when React Flow measurements change so intersections
|
|
58
179
|
// always use actual rendered node dimensions.
|
|
59
180
|
useEffect(() => {
|
|
@@ -61,12 +182,13 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
61
182
|
return;
|
|
62
183
|
const nodeLookup = storeApi.getState().nodeLookup;
|
|
63
184
|
const measuredPositions = new Map();
|
|
185
|
+
const subgraphIds = new Set((parsed.subgraphs ?? []).map((s) => s.id));
|
|
64
186
|
for (const internalNode of nodeLookup.values()) {
|
|
65
187
|
const estimated = contextRef.current.estimatedPositions.get(internalNode.id);
|
|
66
|
-
if (!estimated)
|
|
188
|
+
if (!estimated && !subgraphIds.has(internalNode.id))
|
|
67
189
|
continue;
|
|
68
|
-
const measuredW = internalNode.measured?.width ?? internalNode.width ?? estimated
|
|
69
|
-
const measuredH = internalNode.measured?.height ?? internalNode.height ?? estimated
|
|
190
|
+
const measuredW = internalNode.measured?.width ?? internalNode.width ?? estimated?.width ?? 0;
|
|
191
|
+
const measuredH = internalNode.measured?.height ?? internalNode.height ?? estimated?.height ?? 0;
|
|
70
192
|
const abs = internalNode.internals.positionAbsolute;
|
|
71
193
|
measuredPositions.set(internalNode.id, {
|
|
72
194
|
x: abs.x,
|
|
@@ -77,6 +199,64 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
77
199
|
}
|
|
78
200
|
setEdges(buildLayoutEdges(contextRef.current, theme, measuredPositions));
|
|
79
201
|
}, [nodesInitialized, measuredSignature, theme, storeApi]);
|
|
202
|
+
// Subgraph boxes are visual annotations; compute their bounds from measured
|
|
203
|
+
// child node DOM dimensions so they never drift due to sizing changes.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!nodesInitialized)
|
|
206
|
+
return;
|
|
207
|
+
if (!parsed.subgraphs || parsed.subgraphs.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
const { layout } = getConfig();
|
|
210
|
+
const nodeLookup = storeApi.getState().nodeLookup;
|
|
211
|
+
// Include extra pad so node shadows/rounded corners don't peek outside.
|
|
212
|
+
// We intentionally over-pad here; subgraphs are visual groupings, not tight boxes.
|
|
213
|
+
const pad = layout.subgraphPadding + 16;
|
|
214
|
+
const labelOffset = theme.nodeStyles.subgraph.labelOffset;
|
|
215
|
+
const boundsById = new Map();
|
|
216
|
+
for (const sg of parsed.subgraphs) {
|
|
217
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
218
|
+
for (const nid of sg.nodeIds) {
|
|
219
|
+
const internal = nodeLookup.get(nid);
|
|
220
|
+
if (!internal)
|
|
221
|
+
continue;
|
|
222
|
+
const abs = internal.internals.positionAbsolute;
|
|
223
|
+
const w = internal.measured?.width ?? internal.width ?? 0;
|
|
224
|
+
const h = internal.measured?.height ?? internal.height ?? 0;
|
|
225
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0)
|
|
226
|
+
continue;
|
|
227
|
+
minX = Math.min(minX, abs.x);
|
|
228
|
+
minY = Math.min(minY, abs.y);
|
|
229
|
+
maxX = Math.max(maxX, abs.x + w);
|
|
230
|
+
maxY = Math.max(maxY, abs.y + h);
|
|
231
|
+
}
|
|
232
|
+
if (!isFinite(minX))
|
|
233
|
+
continue;
|
|
234
|
+
const next = {
|
|
235
|
+
x: minX - pad,
|
|
236
|
+
y: minY - pad - labelOffset,
|
|
237
|
+
width: maxX - minX + pad * 2,
|
|
238
|
+
height: maxY - minY + pad * 2 + labelOffset,
|
|
239
|
+
};
|
|
240
|
+
boundsById.set(sg.id, next);
|
|
241
|
+
}
|
|
242
|
+
if (boundsById.size === 0)
|
|
243
|
+
return;
|
|
244
|
+
setNodes((prev) => prev.map((n) => {
|
|
245
|
+
const b = boundsById.get(n.id);
|
|
246
|
+
if (b) {
|
|
247
|
+
return {
|
|
248
|
+
...n,
|
|
249
|
+
position: { x: b.x, y: b.y },
|
|
250
|
+
style: {
|
|
251
|
+
...(n.style ?? {}),
|
|
252
|
+
width: b.width,
|
|
253
|
+
height: b.height,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return n;
|
|
258
|
+
}));
|
|
259
|
+
}, [nodesInitialized, measuredSignature, parsed.subgraphs, storeApi, theme.nodeStyles.subgraph.labelOffset]);
|
|
80
260
|
const handleNodeClick = useCallback((_event, node) => {
|
|
81
261
|
const label = String(node.data?.label ?? node.id);
|
|
82
262
|
onNodeClick(node.id, label);
|
|
@@ -84,7 +264,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
84
264
|
const defaultEdgeOptions = useMemo(() => ({ animated: false, style: theme.edgeDefaults.style }), [theme.edgeDefaults.style]);
|
|
85
265
|
const miniMapNodeColor = useCallback(() => theme.color.orange1, [theme.color.orange1]);
|
|
86
266
|
const miniMapMaskColor = useMemo(() => `${theme.color.gray1}cc`, [theme.color.gray1]);
|
|
87
|
-
if (
|
|
267
|
+
if (layoutError) {
|
|
88
268
|
return (_jsx("div", { style: {
|
|
89
269
|
width: '100%',
|
|
90
270
|
height: '100%',
|
|
@@ -96,9 +276,9 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
96
276
|
fontFamily: theme.font.family,
|
|
97
277
|
fontSize: 13,
|
|
98
278
|
textAlign: 'center',
|
|
99
|
-
}, children: `Diagram syntax/layout error: ${
|
|
279
|
+
}, children: `Diagram syntax/layout error: ${layoutError}` }));
|
|
100
280
|
}
|
|
101
|
-
return (_jsx("div", { style: { width: '100%', height: '100%' }, children: _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: resolvedNodeTypes, edgeTypes: edgeTypes, onNodeClick: handleNodeClick, fitView: canvas.fitView, minZoom: canvas.minZoom, maxZoom: canvas.maxZoom, defaultEdgeOptions: defaultEdgeOptions, proOptions: hideAttribution ? { hideAttribution: true } : undefined, children: [canvas.showBackground && (_jsx(Background, { color: theme.color.gray2, gap: canvas.backgroundGap, size: canvas.backgroundSize })), canvas.showControls && _jsx(Controls, {}), canvas.showMiniMap && (_jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }))] }) }));
|
|
281
|
+
return (_jsx("div", { style: { width: '100%', height: '100%' }, children: _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: resolvedNodeTypes, edgeTypes: edgeTypes, onNodeClick: handleNodeClick, fitView: canvas.fitView, minZoom: canvas.minZoom, maxZoom: canvas.maxZoom, defaultEdgeOptions: defaultEdgeOptions, zIndexMode: "manual", proOptions: hideAttribution ? { hideAttribution: true } : undefined, children: [canvas.showBackground && (_jsx(Background, { color: theme.color.gray2, gap: canvas.backgroundGap, size: canvas.backgroundSize })), canvas.showControls && _jsx(Controls, {}), canvas.showMiniMap && (_jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }))] }) }));
|
|
102
282
|
}
|
|
103
283
|
export function GraphCanvas(props) {
|
|
104
284
|
return (_jsx(ReactFlowProvider, { children: _jsx(GraphCanvasInner, { ...props }) }));
|
|
@@ -37,12 +37,9 @@ function htmlSubsetToMarkdown(src) {
|
|
|
37
37
|
}
|
|
38
38
|
function normalizeLabelMarkdown(label) {
|
|
39
39
|
// Mermaid treats "\n" inside labels as a hard line break.
|
|
40
|
-
// react-markdown doesn't always preserve raw newlines, so force
|
|
41
|
-
// CommonMark hard-breaks by adding two trailing spaces.
|
|
42
40
|
return htmlSubsetToMarkdown(label)
|
|
43
41
|
.replace(/\\n/g, '\n')
|
|
44
|
-
.trim()
|
|
45
|
-
.replace(/\n/g, ' \n');
|
|
42
|
+
.trim();
|
|
46
43
|
}
|
|
47
44
|
/**
|
|
48
45
|
* Generates evenly-spaced handle positions across a face.
|
|
@@ -161,13 +158,19 @@ export const FlowNode = memo(function FlowNode({ data }) {
|
|
|
161
158
|
return (_jsxs("div", { style: {
|
|
162
159
|
backgroundColor: nodeBgColor, borderRadius: br,
|
|
163
160
|
padding: `${theme.space[3]} ${theme.space[5]}`,
|
|
164
|
-
width: layoutWidth,
|
|
161
|
+
width: layoutWidth,
|
|
162
|
+
// Allow content-driven height. Dagre supplies an initial estimate;
|
|
163
|
+
// GraphCanvas can relayout using measured dimensions.
|
|
164
|
+
minHeight: layoutHeight,
|
|
165
165
|
boxSizing: 'border-box',
|
|
166
166
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
167
167
|
fontFamily: theme.font.family, cursor: 'pointer',
|
|
168
168
|
}, children: [_jsx("div", { style: {
|
|
169
169
|
color: nodeTextColor, fontSize: theme.font.size.lg, fontWeight: theme.font.weight.bold,
|
|
170
170
|
textAlign: 'center', lineHeight: theme.lineHeight.normal,
|
|
171
|
-
whiteSpace: 'normal',
|
|
171
|
+
whiteSpace: 'normal',
|
|
172
|
+
overflowWrap: 'anywhere',
|
|
173
|
+
wordBreak: 'break-word',
|
|
174
|
+
width: '100%',
|
|
172
175
|
}, children: _jsx(ReactMarkdown, { components: markdownComponents, children: labelMarkdown }) }), ChildComponent && (_jsx(ChildComponent, { nodeId: String(data.id ?? ''), label: label, componentProps: meta?.componentProps })), _jsx(NodeHandles, { fwIn: fwIn, fwOut: fwOut })] }));
|
|
173
176
|
});
|
package/dist/config.js
CHANGED
|
@@ -18,7 +18,10 @@ export type { NodePosition, HandlePositions, EdgeWaypoints } from '../types.js';
|
|
|
18
18
|
* Back-edges route to side handles to avoid passing through intermediate nodes.
|
|
19
19
|
*/
|
|
20
20
|
export declare function directionToHandles(direction: ParsedGraph['direction']): HandlePositions;
|
|
21
|
-
export declare function createDagreGraph(parsed: ParsedGraph, layoutConfig: GraphConfig['layout']
|
|
21
|
+
export declare function createDagreGraph(parsed: ParsedGraph, layoutConfig: GraphConfig['layout'], nodeSizeOverrides?: Map<string, {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}>): dagre.graphlib.Graph;
|
|
22
25
|
/**
|
|
23
26
|
* Extracts top-left positions from dagre's center-based coordinates.
|
|
24
27
|
* Returns a Map of nodeId → { x, y, width, height }.
|
|
@@ -50,7 +50,7 @@ export function directionToHandles(direction) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
// ─── Dagre Graph Construction ───────────────────────────────────────────────
|
|
53
|
-
export function createDagreGraph(parsed, layoutConfig) {
|
|
53
|
+
export function createDagreGraph(parsed, layoutConfig, nodeSizeOverrides) {
|
|
54
54
|
validateParsedGraph(parsed);
|
|
55
55
|
const g = new dagre.graphlib.Graph({ multigraph: true, compound: true });
|
|
56
56
|
g.setDefaultEdgeLabel(() => ({}));
|
|
@@ -81,7 +81,8 @@ export function createDagreGraph(parsed, layoutConfig) {
|
|
|
81
81
|
ranker: 'network-simplex',
|
|
82
82
|
});
|
|
83
83
|
for (const node of parsed.nodes) {
|
|
84
|
-
const
|
|
84
|
+
const override = nodeSizeOverrides?.get(node.id);
|
|
85
|
+
const size = override ?? nodeSize(node, parsed.diagramType, layoutConfig.nodeSizing);
|
|
85
86
|
g.setNode(node.id, { width: size.width, height: size.height });
|
|
86
87
|
}
|
|
87
88
|
// Defensive: if a parser emits an edge endpoint that is missing from
|
|
@@ -35,14 +35,39 @@ function stripMarkdown(input) {
|
|
|
35
35
|
.replace(/[_~#>|-]/g, '')
|
|
36
36
|
.trim();
|
|
37
37
|
}
|
|
38
|
+
function decodeHtmlEntities(src) {
|
|
39
|
+
return src
|
|
40
|
+
.replace(/ /gi, ' ')
|
|
41
|
+
.replace(/&/gi, '&')
|
|
42
|
+
.replace(/</gi, '<')
|
|
43
|
+
.replace(/>/gi, '>')
|
|
44
|
+
.replace(/"/gi, '"')
|
|
45
|
+
.replace(/'/g, "'");
|
|
46
|
+
}
|
|
47
|
+
function normalizeLabelText(label) {
|
|
48
|
+
// Sizing must mirror render-time transformations. In practice Mermaid labels
|
|
49
|
+
// often contain a small HTML subset (<br/>, <b>, <i>) which we render as
|
|
50
|
+
// markdown in the node components. Convert the key cases here so line/width
|
|
51
|
+
// estimation is correct.
|
|
52
|
+
let s = label;
|
|
53
|
+
s = s.replace(/<\s*br\s*\/?\s*>/gi, '\n');
|
|
54
|
+
// Strip common formatting tags (they don't affect width materially)
|
|
55
|
+
s = s.replace(/<\s*\/?\s*(strong|b|em|i|code)\s*>/gi, '');
|
|
56
|
+
// Strip any remaining tags defensively
|
|
57
|
+
s = s.replace(/<[^>]*>/g, '');
|
|
58
|
+
s = decodeHtmlEntities(s);
|
|
59
|
+
// Mermaid uses escaped "\n" inside labels
|
|
60
|
+
s = s.replace(/\\n/g, '\n');
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
38
63
|
function labelMetrics(label) {
|
|
39
|
-
const normalized = label
|
|
64
|
+
const normalized = normalizeLabelText(label);
|
|
40
65
|
const lines = normalized.split('\n');
|
|
41
66
|
const maxChars = Math.max(1, ...lines.map((line) => stripMarkdown(line).length || 1));
|
|
42
67
|
return { maxChars, lines: Math.max(1, lines.length) };
|
|
43
68
|
}
|
|
44
69
|
function estimatedWrappedLineCount(label, contentCharsPerLine) {
|
|
45
|
-
const normalized = label
|
|
70
|
+
const normalized = normalizeLabelText(label);
|
|
46
71
|
const lines = normalized.split('\n');
|
|
47
72
|
let total = 0;
|
|
48
73
|
const charsPerLine = Math.max(8, contentCharsPerLine);
|
|
@@ -118,8 +118,8 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
118
118
|
const forwardBySource = new Map();
|
|
119
119
|
const forwardByTarget = new Map();
|
|
120
120
|
parsedEdges.forEach((edge, i) => {
|
|
121
|
-
const sp = positions.get(edge.source);
|
|
122
|
-
const tp = positions.get(edge.target);
|
|
121
|
+
const sp = positions.get(edge.sourceSubgraphId ?? edge.source);
|
|
122
|
+
const tp = positions.get(edge.targetSubgraphId ?? edge.target);
|
|
123
123
|
if (!sp || !tp)
|
|
124
124
|
return;
|
|
125
125
|
const edgeClass = classifyEdge(sp, tp, direction);
|
|
@@ -149,14 +149,14 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
149
149
|
const backSourceCounters = new Map();
|
|
150
150
|
const backTargetCounters = new Map();
|
|
151
151
|
return parsedEdges.map((edge, i) => {
|
|
152
|
-
const sourcePos = positions.get(edge.source);
|
|
153
|
-
const targetPos = positions.get(edge.target);
|
|
152
|
+
const sourcePos = positions.get(edge.sourceSubgraphId ?? edge.source);
|
|
153
|
+
const targetPos = positions.get(edge.targetSubgraphId ?? edge.target);
|
|
154
154
|
const edgeClass = sourcePos && targetPos
|
|
155
155
|
? classifyEdge(sourcePos, targetPos, direction)
|
|
156
156
|
: 'forward';
|
|
157
157
|
const waypoints = edgeWaypoints?.[i];
|
|
158
|
-
const sourceShape = nodeShapes?.get(edge.source) ?? 'rect';
|
|
159
|
-
const targetShape = nodeShapes?.get(edge.target) ?? 'rect';
|
|
158
|
+
const sourceShape = edge.sourceSubgraphId ? 'rect' : (nodeShapes?.get(edge.source) ?? 'rect');
|
|
159
|
+
const targetShape = edge.targetSubgraphId ? 'rect' : (nodeShapes?.get(edge.target) ?? 'rect');
|
|
160
160
|
// ── Compute path from dagre waypoints ──
|
|
161
161
|
let svgPath = '';
|
|
162
162
|
let labelPos = { x: 0, y: 0 };
|
|
@@ -371,7 +371,9 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
371
371
|
labelBgBorderRadius: theme.edgeDefaults.labelBgBorderRadius,
|
|
372
372
|
sourceHandle,
|
|
373
373
|
targetHandle,
|
|
374
|
-
|
|
374
|
+
// Keep edges above subgraph backgrounds but below regular nodes.
|
|
375
|
+
// (Regular nodes set zIndex=10; subgraphs set zIndex=0.)
|
|
376
|
+
zIndex: 1,
|
|
375
377
|
data: {
|
|
376
378
|
svgPath,
|
|
377
379
|
labelX: labelPos.x,
|
package/dist/layout/index.d.ts
CHANGED
|
@@ -43,7 +43,10 @@ export interface CustomLayoutResult {
|
|
|
43
43
|
data: ParsedCustomDiagram['data'];
|
|
44
44
|
}
|
|
45
45
|
export type LayoutResult = GraphLayoutResult | CustomLayoutResult;
|
|
46
|
-
export declare function layoutNodes(parsed: ParsedGraph, options: LayoutOptions
|
|
46
|
+
export declare function layoutNodes(parsed: ParsedGraph, options: LayoutOptions, nodeSizeOverrides?: Map<string, {
|
|
47
|
+
width: number;
|
|
48
|
+
height: number;
|
|
49
|
+
}>): {
|
|
47
50
|
nodes: Node[];
|
|
48
51
|
context: LayoutContext;
|
|
49
52
|
} | {
|
package/dist/layout/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { computeSubgraphBounds, buildSubgraphNode } from './subgraph.js';
|
|
|
21
21
|
import { layoutSequenceDiagram } from './sequenceLayout.js';
|
|
22
22
|
import { layoutMindmapGraph } from './mindmapLayout.js';
|
|
23
23
|
// ─── Phase 1: Layout Nodes ──────────────────────────────────────────────────
|
|
24
|
-
export function layoutNodes(parsed, options) {
|
|
24
|
+
export function layoutNodes(parsed, options, nodeSizeOverrides) {
|
|
25
25
|
const { theme, metadata } = options;
|
|
26
26
|
// Mindmap uses dedicated radial layout (not dagre).
|
|
27
27
|
if (parsed.diagramType === 'mindmap') {
|
|
@@ -46,7 +46,7 @@ export function layoutNodes(parsed, options) {
|
|
|
46
46
|
}
|
|
47
47
|
const { layout } = getConfig();
|
|
48
48
|
// 1. Build dagre graph and run layout
|
|
49
|
-
const graph = createDagreGraph(parsed, layout);
|
|
49
|
+
const graph = createDagreGraph(parsed, layout, nodeSizeOverrides);
|
|
50
50
|
// 2. Extract positioned coordinates and edge waypoints
|
|
51
51
|
const positions = extractPositions(graph, parsed.nodes);
|
|
52
52
|
const edgeWaypoints = extractEdgeWaypoints(graph, parsed);
|
|
@@ -62,6 +62,7 @@ export function layoutNodes(parsed, options) {
|
|
|
62
62
|
return {
|
|
63
63
|
id: node.id,
|
|
64
64
|
position: { x: pos.x, y: pos.y },
|
|
65
|
+
zIndex: 10,
|
|
65
66
|
data: {
|
|
66
67
|
label: node.label,
|
|
67
68
|
shape: node.shape,
|
|
@@ -85,6 +86,14 @@ export function layoutNodes(parsed, options) {
|
|
|
85
86
|
const bounds = computeSubgraphBounds(subgraph, positions, layout.subgraphPadding, theme.nodeStyles.subgraph.labelOffset, graph);
|
|
86
87
|
if (!bounds)
|
|
87
88
|
continue;
|
|
89
|
+
// Make subgraph bounds available to the edge builder so edges that
|
|
90
|
+
// reference a subgraph id can attach to the subgraph boundary.
|
|
91
|
+
positions.set(subgraph.id, {
|
|
92
|
+
x: bounds.x,
|
|
93
|
+
y: bounds.y,
|
|
94
|
+
width: bounds.width,
|
|
95
|
+
height: bounds.height,
|
|
96
|
+
});
|
|
88
97
|
nodes.unshift(buildSubgraphNode(subgraph, bounds, theme));
|
|
89
98
|
}
|
|
90
99
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { MarkerType } from '@xyflow/react';
|
|
19
19
|
import { getConfig } from '../config.js';
|
|
20
|
+
import { generateRoundedPath } from './edges/paths.js';
|
|
20
21
|
// ─── Layout Constants ──────────────────────────────────────────────────────
|
|
21
22
|
const PARTICIPANT_WIDTH = 150;
|
|
22
23
|
const PARTICIPANT_HEIGHT = 40;
|
|
@@ -291,12 +292,11 @@ export function layoutSequenceDiagram(sequence, theme, metadata) {
|
|
|
291
292
|
selectable: false,
|
|
292
293
|
});
|
|
293
294
|
if (isSelf) {
|
|
294
|
-
const
|
|
295
|
+
const loopWidth = 44;
|
|
296
|
+
const loopRightX = participantCenterX(fromIdx, seq.leftPadding, seq.participantWidth, seq.participantGap) + loopWidth;
|
|
295
297
|
const loopBottomY = y + seq.selfMessageExtra + 10;
|
|
296
|
-
const cornerAnchorId = `__anchor_${msg.from}_${mi}_corner`;
|
|
297
298
|
const returnAnchorId = `__anchor_${msg.from}_${mi}_return`;
|
|
298
|
-
|
|
299
|
-
nodes.push({ id: cornerAnchorId, position: { x: loopRightX - 1, y: y - 1 }, data: { anchor: true }, type: 'sequenceAnchor', draggable: false, selectable: false }, {
|
|
299
|
+
nodes.push({
|
|
300
300
|
id: returnAnchorId,
|
|
301
301
|
position: {
|
|
302
302
|
x: participantCenterX(fromIdx, seq.leftPadding, seq.participantWidth, seq.participantGap) - 1,
|
|
@@ -306,8 +306,16 @@ export function layoutSequenceDiagram(sequence, theme, metadata) {
|
|
|
306
306
|
type: 'sequenceAnchor',
|
|
307
307
|
draggable: false,
|
|
308
308
|
selectable: false,
|
|
309
|
-
}
|
|
310
|
-
|
|
309
|
+
});
|
|
310
|
+
// Self-message: draw a single rounded loop-back path (closer to Mermaid).
|
|
311
|
+
const cx = participantCenterX(fromIdx, seq.leftPadding, seq.participantWidth, seq.participantGap);
|
|
312
|
+
const routePoints = [
|
|
313
|
+
{ x: cx, y },
|
|
314
|
+
{ x: loopRightX, y },
|
|
315
|
+
{ x: loopRightX, y: loopBottomY },
|
|
316
|
+
{ x: cx, y: loopBottomY },
|
|
317
|
+
];
|
|
318
|
+
edges.push(buildSequenceRoutedEdge(`e-self-${mi}`, sourceAnchorId, returnAnchorId, msg.arrowType, theme, true, routePoints));
|
|
311
319
|
if (label) {
|
|
312
320
|
nodes.push({
|
|
313
321
|
id: `__msglabel_${mi}`,
|
|
@@ -412,3 +420,28 @@ function buildSequenceEdge(id, source, target, arrowType, theme, showArrow) {
|
|
|
412
420
|
} : {}),
|
|
413
421
|
};
|
|
414
422
|
}
|
|
423
|
+
function buildSequenceRoutedEdge(id, source, target, arrowType, theme, showArrow, points) {
|
|
424
|
+
const isDashed = arrowType === 'dashed' || arrowType === 'dashed_open';
|
|
425
|
+
const isOpen = arrowType === 'solid_open' || arrowType === 'dashed_open';
|
|
426
|
+
const svgPath = generateRoundedPath(points, 14);
|
|
427
|
+
return {
|
|
428
|
+
id,
|
|
429
|
+
source,
|
|
430
|
+
target,
|
|
431
|
+
type: 'routed',
|
|
432
|
+
style: {
|
|
433
|
+
stroke: theme.color.gray3,
|
|
434
|
+
strokeWidth: 1.5,
|
|
435
|
+
...(isDashed ? { strokeDasharray: '6,4' } : {}),
|
|
436
|
+
},
|
|
437
|
+
data: { svgPath },
|
|
438
|
+
...(showArrow && !isOpen ? {
|
|
439
|
+
markerEnd: {
|
|
440
|
+
type: MarkerType.ArrowClosed,
|
|
441
|
+
color: theme.color.gray3,
|
|
442
|
+
width: 10,
|
|
443
|
+
height: 10,
|
|
444
|
+
},
|
|
445
|
+
} : {}),
|
|
446
|
+
};
|
|
447
|
+
}
|
package/dist/layout/subgraph.js
CHANGED
|
@@ -11,7 +11,31 @@
|
|
|
11
11
|
* Falls back to manual bounding box if dagre cluster data isn't available.
|
|
12
12
|
*/
|
|
13
13
|
export function computeSubgraphBounds(subgraph, nodePositions, padding, labelOffset, dagreGraph) {
|
|
14
|
-
//
|
|
14
|
+
// Prefer manual bounds derived from child node positions. In practice this
|
|
15
|
+
// is more reliable than dagre's compound cluster dimensions when node
|
|
16
|
+
// sizes are overridden post-measurement.
|
|
17
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
18
|
+
for (const nodeId of subgraph.nodeIds) {
|
|
19
|
+
const pos = nodePositions.get(nodeId);
|
|
20
|
+
if (!pos)
|
|
21
|
+
continue;
|
|
22
|
+
minX = Math.min(minX, pos.x);
|
|
23
|
+
minY = Math.min(minY, pos.y);
|
|
24
|
+
maxX = Math.max(maxX, pos.x + pos.width);
|
|
25
|
+
maxY = Math.max(maxY, pos.y + pos.height);
|
|
26
|
+
}
|
|
27
|
+
if (isFinite(minX)) {
|
|
28
|
+
// Account for node shadows/rounding so children don't "peek" outside.
|
|
29
|
+
const shadowPad = 4;
|
|
30
|
+
const pad = padding + shadowPad;
|
|
31
|
+
return {
|
|
32
|
+
x: minX - pad,
|
|
33
|
+
y: minY - pad - labelOffset,
|
|
34
|
+
width: maxX - minX + pad * 2,
|
|
35
|
+
height: maxY - minY + pad * 2 + labelOffset,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Fallback: use dagre's computed cluster bounds if available.
|
|
15
39
|
if (dagreGraph) {
|
|
16
40
|
const clusterNode = dagreGraph.node(subgraph.id);
|
|
17
41
|
if (clusterNode && clusterNode.width && clusterNode.height) {
|
|
@@ -23,25 +47,7 @@ export function computeSubgraphBounds(subgraph, nodePositions, padding, labelOff
|
|
|
23
47
|
};
|
|
24
48
|
}
|
|
25
49
|
}
|
|
26
|
-
|
|
27
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
28
|
-
for (const nodeId of subgraph.nodeIds) {
|
|
29
|
-
const pos = nodePositions.get(nodeId);
|
|
30
|
-
if (!pos)
|
|
31
|
-
continue;
|
|
32
|
-
minX = Math.min(minX, pos.x);
|
|
33
|
-
minY = Math.min(minY, pos.y);
|
|
34
|
-
maxX = Math.max(maxX, pos.x + pos.width);
|
|
35
|
-
maxY = Math.max(maxY, pos.y + pos.height);
|
|
36
|
-
}
|
|
37
|
-
if (!isFinite(minX))
|
|
38
|
-
return null;
|
|
39
|
-
return {
|
|
40
|
-
x: minX - padding,
|
|
41
|
-
y: minY - padding - labelOffset,
|
|
42
|
-
width: maxX - minX + padding * 2,
|
|
43
|
-
height: maxY - minY + padding * 2 + labelOffset,
|
|
44
|
-
};
|
|
50
|
+
return null;
|
|
45
51
|
}
|
|
46
52
|
// ─── Subgraph Node Builder ──────────────────────────────────────────────────
|
|
47
53
|
export function buildSubgraphNode(subgraph, bounds, theme) {
|
|
@@ -51,13 +57,16 @@ export function buildSubgraphNode(subgraph, bounds, theme) {
|
|
|
51
57
|
position: { x: bounds.x, y: bounds.y },
|
|
52
58
|
data: { label: subgraph.label },
|
|
53
59
|
type: 'subgraph',
|
|
60
|
+
zIndex: 0,
|
|
54
61
|
style: {
|
|
62
|
+
boxSizing: 'border-box',
|
|
55
63
|
width: bounds.width,
|
|
56
64
|
height: bounds.height,
|
|
57
65
|
backgroundColor: `${theme.color.darkBg3}${subgraphStyles.bgOpacity}`,
|
|
58
66
|
border: `${subgraphStyles.borderStyle} ${theme.color.gray3}`,
|
|
59
67
|
borderRadius: theme.radius.lg,
|
|
60
68
|
padding: theme.space[2],
|
|
69
|
+
overflow: 'visible',
|
|
61
70
|
},
|
|
62
71
|
};
|
|
63
72
|
}
|
|
@@ -78,11 +78,17 @@ export function parseFlowchart(syntax) {
|
|
|
78
78
|
}
|
|
79
79
|
const resolvedEdges = [];
|
|
80
80
|
for (const edge of edges) {
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
81
|
+
const sourceResolved = resolveFlowEndpoint(edge.source, 'source', nodeMap, subgraphMap);
|
|
82
|
+
const targetResolved = resolveFlowEndpoint(edge.target, 'target', nodeMap, subgraphMap);
|
|
83
|
+
if (!sourceResolved || !targetResolved)
|
|
84
84
|
continue;
|
|
85
|
-
resolvedEdges.push({
|
|
85
|
+
resolvedEdges.push({
|
|
86
|
+
...edge,
|
|
87
|
+
source: sourceResolved.nodeId,
|
|
88
|
+
target: targetResolved.nodeId,
|
|
89
|
+
sourceSubgraphId: sourceResolved.subgraphId,
|
|
90
|
+
targetSubgraphId: targetResolved.subgraphId,
|
|
91
|
+
});
|
|
86
92
|
}
|
|
87
93
|
return {
|
|
88
94
|
kind: 'graph',
|
|
@@ -99,12 +105,15 @@ function resolveFlowEndpoint(endpoint, role, nodeMap, subgraphMap) {
|
|
|
99
105
|
// Heuristic anchor selection:
|
|
100
106
|
// - source endpoint (subgraph -> node): use the last member encountered
|
|
101
107
|
// - target endpoint (node -> subgraph): use the first member encountered
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
return {
|
|
109
|
+
nodeId: role === 'source'
|
|
110
|
+
? sg.nodeIds[sg.nodeIds.length - 1]
|
|
111
|
+
: sg.nodeIds[0],
|
|
112
|
+
subgraphId: endpoint,
|
|
113
|
+
};
|
|
105
114
|
}
|
|
106
115
|
if (nodeMap.has(endpoint))
|
|
107
|
-
return endpoint;
|
|
116
|
+
return { nodeId: endpoint };
|
|
108
117
|
return null;
|
|
109
118
|
}
|
|
110
119
|
// ─── Arrow Classification ─────────────────────────────────────────────────────
|
package/dist/types.d.ts
CHANGED
|
@@ -141,6 +141,18 @@ export type Cardinality = 'ONLY_ONE' | 'ZERO_OR_ONE' | 'ZERO_OR_MORE' | 'ONE_OR_
|
|
|
141
141
|
export interface ParsedEdge {
|
|
142
142
|
source: string;
|
|
143
143
|
target: string;
|
|
144
|
+
/**
|
|
145
|
+
* If the original Mermaid syntax referenced a subgraph id as an endpoint,
|
|
146
|
+
* we resolve it to a representative member node for layout, but keep the
|
|
147
|
+
* subgraph id here so edge rendering can attach to the subgraph boundary.
|
|
148
|
+
*/
|
|
149
|
+
sourceSubgraphId?: string;
|
|
150
|
+
/**
|
|
151
|
+
* If the original Mermaid syntax referenced a subgraph id as an endpoint,
|
|
152
|
+
* we resolve it to a representative member node for layout, but keep the
|
|
153
|
+
* subgraph id here so edge rendering can attach to the subgraph boundary.
|
|
154
|
+
*/
|
|
155
|
+
targetSubgraphId?: string;
|
|
144
156
|
label?: string;
|
|
145
157
|
edgeType?: EdgeType;
|
|
146
158
|
/** Render edge line without arrowhead marker (used by mindmap-like links). */
|