@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.
@@ -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
- // Phase 1: Layout nodes with dagre + initial edges with estimated dimensions
30
- const layoutState = useMemo(() => {
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 layoutResult = layoutState.result;
43
- const nodes = layoutResult?.nodes ?? [];
44
- const context = layoutResult?.context ?? undefined;
45
- // Edges with estimated dimensions always available immediately
46
- const estimatedEdges = useMemo(() => context
47
- ? buildLayoutEdges(context, theme)
48
- : (layoutResult?.edges ?? []), [context, theme, layoutResult]);
49
- // Start with estimated edges so something is always visible
50
- const [edges, setEdges] = useState(estimatedEdges);
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
- // When diagram changes, immediately show estimated edges
62
+ // Apply fitView once per diagram after node measurement stabilizes.
63
+ const didInitialFit = useRef(false);
64
+ const didStableCallback = useRef(false);
54
65
  useEffect(() => {
55
- setEdges(estimatedEdges);
56
- }, [estimatedEdges]);
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.width;
69
- const measuredH = internalNode.measured?.height ?? internalNode.height ?? estimated.height;
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 (layoutState.error || !layoutState.result) {
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: ${layoutState.error ?? 'Unknown layout failure'}` }));
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, height: layoutHeight,
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', overflowWrap: 'break-word', width: '100%',
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
@@ -63,7 +63,8 @@ function createDefaultConfig() {
63
63
  },
64
64
  },
65
65
  canvas: {
66
- minZoom: 0.1,
66
+ // Allow fitView to zoom out far enough for large diagrams.
67
+ minZoom: 0.02,
67
68
  maxZoom: 2,
68
69
  fitView: true,
69
70
  showBackground: true,
@@ -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']): dagre.graphlib.Graph;
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 size = nodeSize(node, parsed.diagramType, layoutConfig.nodeSizing);
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(/&nbsp;/gi, ' ')
41
+ .replace(/&amp;/gi, '&')
42
+ .replace(/&lt;/gi, '<')
43
+ .replace(/&gt;/gi, '>')
44
+ .replace(/&quot;/gi, '"')
45
+ .replace(/&#39;/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.replace(/\\n/g, '\n');
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.replace(/\\n/g, '\n');
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
- zIndex: isErEdge ? 2 : (edgeClass === 'back' ? 0 : 1),
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,
@@ -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
  } | {
@@ -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 loopRightX = participantCenterX(fromIdx, seq.leftPadding, seq.participantWidth, seq.participantGap) + 40;
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
- const cornerBottomId = `__anchor_${msg.from}_${mi}_cornerbot`;
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
- }, { id: cornerBottomId, position: { x: loopRightX - 1, y: loopBottomY - 1 }, data: { anchor: true }, type: 'sequenceAnchor', draggable: false, selectable: false });
310
- edges.push(buildSequenceEdge(`e-self-${mi}-a`, sourceAnchorId, cornerAnchorId, msg.arrowType, theme, false), buildSequenceEdge(`e-self-${mi}-b`, cornerAnchorId, cornerBottomId, msg.arrowType, theme, false), buildSequenceEdge(`e-self-${mi}-c`, cornerBottomId, returnAnchorId, msg.arrowType, theme, true));
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
+ }
@@ -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
- // Try dagre's computed cluster bounds first
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
- // Fallback: manual bounding box from child node positions
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 source = resolveFlowEndpoint(edge.source, 'source', nodeMap, subgraphMap);
82
- const target = resolveFlowEndpoint(edge.target, 'target', nodeMap, subgraphMap);
83
- if (!source || !target)
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({ ...edge, source, target });
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 role === 'source'
103
- ? sg.nodeIds[sg.nodeIds.length - 1]
104
- : sg.nodeIds[0];
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). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graph-artifact/core",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "Composable Mermaid-like parser, layout engine, and React renderer for interactive diagram artifacts.",
5
5
  "license": "MIT",
6
6
  "repository": {