@graph-artifact/core 0.1.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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/ThemeContext.d.ts +47 -0
  4. package/dist/ThemeContext.js +81 -0
  5. package/dist/components/DiagramCanvas.d.ts +8 -0
  6. package/dist/components/DiagramCanvas.js +19 -0
  7. package/dist/components/GraphCanvas.d.ts +9 -0
  8. package/dist/components/GraphCanvas.js +104 -0
  9. package/dist/components/NodeDetail.d.ts +9 -0
  10. package/dist/components/NodeDetail.js +127 -0
  11. package/dist/components/edges/RoutedEdge.d.ts +11 -0
  12. package/dist/components/edges/RoutedEdge.js +199 -0
  13. package/dist/components/nodes/ClassNode.d.ts +5 -0
  14. package/dist/components/nodes/ClassNode.js +62 -0
  15. package/dist/components/nodes/EntityNode.d.ts +5 -0
  16. package/dist/components/nodes/EntityNode.js +57 -0
  17. package/dist/components/nodes/FlowNode.d.ts +5 -0
  18. package/dist/components/nodes/FlowNode.js +144 -0
  19. package/dist/components/nodes/SequenceNodes.d.ts +33 -0
  20. package/dist/components/nodes/SequenceNodes.js +205 -0
  21. package/dist/components/nodes/StateNode.d.ts +5 -0
  22. package/dist/components/nodes/StateNode.js +71 -0
  23. package/dist/components/nodes/SubgraphNode.d.ts +5 -0
  24. package/dist/components/nodes/SubgraphNode.js +16 -0
  25. package/dist/config.d.ts +138 -0
  26. package/dist/config.js +165 -0
  27. package/dist/core.d.ts +12 -0
  28. package/dist/core.js +7 -0
  29. package/dist/diagrams/detect.d.ts +2 -0
  30. package/dist/diagrams/detect.js +8 -0
  31. package/dist/diagrams/plugins.d.ts +2 -0
  32. package/dist/diagrams/plugins.js +45 -0
  33. package/dist/diagrams/registry.d.ts +3 -0
  34. package/dist/diagrams/registry.js +13 -0
  35. package/dist/diagrams/types.d.ts +7 -0
  36. package/dist/diagrams/types.js +1 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +3 -0
  39. package/dist/layout/dagre/index.d.ts +31 -0
  40. package/dist/layout/dagre/index.js +224 -0
  41. package/dist/layout/dagre/nodeSizing.d.ts +32 -0
  42. package/dist/layout/dagre/nodeSizing.js +202 -0
  43. package/dist/layout/edges/buildEdges.d.ts +18 -0
  44. package/dist/layout/edges/buildEdges.js +405 -0
  45. package/dist/layout/edges/classify.d.ts +13 -0
  46. package/dist/layout/edges/classify.js +36 -0
  47. package/dist/layout/edges/diamondHandles.d.ts +23 -0
  48. package/dist/layout/edges/diamondHandles.js +108 -0
  49. package/dist/layout/edges/index.d.ts +10 -0
  50. package/dist/layout/edges/index.js +8 -0
  51. package/dist/layout/edges/paths.d.ts +57 -0
  52. package/dist/layout/edges/paths.js +279 -0
  53. package/dist/layout/index.d.ts +59 -0
  54. package/dist/layout/index.js +131 -0
  55. package/dist/layout/intersect/circle.d.ts +2 -0
  56. package/dist/layout/intersect/circle.js +14 -0
  57. package/dist/layout/intersect/diamond.d.ts +9 -0
  58. package/dist/layout/intersect/diamond.js +21 -0
  59. package/dist/layout/intersect/index.d.ts +17 -0
  60. package/dist/layout/intersect/index.js +28 -0
  61. package/dist/layout/intersect/rect.d.ts +10 -0
  62. package/dist/layout/intersect/rect.js +31 -0
  63. package/dist/layout/intersect/rectRounded.d.ts +20 -0
  64. package/dist/layout/intersect/rectRounded.js +48 -0
  65. package/dist/layout/mindmapLayout.d.ts +13 -0
  66. package/dist/layout/mindmapLayout.js +299 -0
  67. package/dist/layout/sequenceLayout.d.ts +24 -0
  68. package/dist/layout/sequenceLayout.js +414 -0
  69. package/dist/layout/subgraph.d.ts +26 -0
  70. package/dist/layout/subgraph.js +63 -0
  71. package/dist/layout/types.d.ts +34 -0
  72. package/dist/layout/types.js +8 -0
  73. package/dist/parsers/classDiagram.d.ts +2 -0
  74. package/dist/parsers/classDiagram.js +105 -0
  75. package/dist/parsers/er.d.ts +2 -0
  76. package/dist/parsers/er.js +97 -0
  77. package/dist/parsers/flowchart.d.ts +2 -0
  78. package/dist/parsers/flowchart.js +191 -0
  79. package/dist/parsers/helpers.d.ts +4 -0
  80. package/dist/parsers/helpers.js +8 -0
  81. package/dist/parsers/index.d.ts +7 -0
  82. package/dist/parsers/index.js +19 -0
  83. package/dist/parsers/mindmap.d.ts +2 -0
  84. package/dist/parsers/mindmap.js +124 -0
  85. package/dist/parsers/sequence.d.ts +18 -0
  86. package/dist/parsers/sequence.js +196 -0
  87. package/dist/parsers/state.d.ts +2 -0
  88. package/dist/parsers/state.js +68 -0
  89. package/dist/react.d.ts +7 -0
  90. package/dist/react.js +9 -0
  91. package/dist/reactDefaults.d.ts +5 -0
  92. package/dist/reactDefaults.js +37 -0
  93. package/dist/renderMarkdown.d.ts +9 -0
  94. package/dist/renderMarkdown.js +103 -0
  95. package/dist/swagger.d.ts +113 -0
  96. package/dist/swagger.js +551 -0
  97. package/dist/theme/dark.d.ts +8 -0
  98. package/dist/theme/dark.js +190 -0
  99. package/dist/theme/index.d.ts +18 -0
  100. package/dist/theme/index.js +29 -0
  101. package/dist/theme/light.d.ts +8 -0
  102. package/dist/theme/light.js +190 -0
  103. package/dist/theme/types.d.ts +97 -0
  104. package/dist/theme/types.js +7 -0
  105. package/dist/types.d.ts +235 -0
  106. package/dist/types.js +1 -0
  107. package/package.json +74 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * buildEdges.ts — Main edge builder.
3
+ *
4
+ * Orchestrates: intersection → path generation → handle assignment → styling
5
+ * to produce React Flow Edge objects from parsed edges + dagre waypoints.
6
+ *
7
+ * For each edge:
8
+ * 1. Takes dagre's waypoints (which route around intermediate nodes)
9
+ * 2. Computes node-boundary intersection (rect or diamond) for start/end
10
+ * 3. Shortens the end by the arrow marker size so the tip lands at the boundary
11
+ * 4. Generates a rounded SVG path through all waypoints
12
+ * 5. Passes the pre-computed path + label position as edge data
13
+ */
14
+ import { MarkerType } from '@xyflow/react';
15
+ import { intersectNode } from '../intersect/index.js';
16
+ import { waypointsDeviate, shortenPathEnd, shortenPathStart, generateSmoothPath, generateRoundedPath, computeLabelPosition, } from './paths.js';
17
+ import { classifyEdge, edgeStyleForType } from './classify.js';
18
+ // ─── Constants ──────────────────────────────────────────────────────────────
19
+ const MARKER_SHORTENING = 6; // shorten path end so arrowhead tip sits at the boundary
20
+ const ER_PATH_GAP = 8; // hard visual clearance from ER node boundary
21
+ function clamp255(v) {
22
+ return Math.max(0, Math.min(255, Math.round(v)));
23
+ }
24
+ function shadeHex(hex, factor) {
25
+ const clean = hex.replace('#', '');
26
+ if (!/^[0-9a-fA-F]{6}$/.test(clean))
27
+ return hex;
28
+ const r = parseInt(clean.slice(0, 2), 16);
29
+ const g = parseInt(clean.slice(2, 4), 16);
30
+ const b = parseInt(clean.slice(4, 6), 16);
31
+ const rr = clamp255(r * (1 + factor));
32
+ const gg = clamp255(g * (1 + factor));
33
+ const bb = clamp255(b * (1 + factor));
34
+ return `#${rr.toString(16).padStart(2, '0')}${gg.toString(16).padStart(2, '0')}${bb.toString(16).padStart(2, '0')}`;
35
+ }
36
+ function buildBranchPalette(base, orange2, orange3) {
37
+ return [
38
+ shadeHex(base, -0.22),
39
+ shadeHex(base, -0.12),
40
+ base,
41
+ orange2,
42
+ orange3,
43
+ shadeHex(base, 0.14),
44
+ ];
45
+ }
46
+ function pushOutFromNodeBoundary(point, node, distance) {
47
+ if (distance <= 0)
48
+ return point;
49
+ const dx = point.x - node.x;
50
+ const dy = point.y - node.y;
51
+ const len = Math.hypot(dx, dy);
52
+ if (len < 1e-6)
53
+ return point;
54
+ return {
55
+ x: point.x + (dx / len) * distance,
56
+ y: point.y + (dy / len) * distance,
57
+ };
58
+ }
59
+ function clampErEndpoints(points, sourceCenter, targetCenter, sourceShape, targetShape, borderRadius, gap) {
60
+ if (points.length < 2)
61
+ return points;
62
+ const out = points.slice();
63
+ const startRef = out[1];
64
+ const endRef = out[out.length - 2];
65
+ const startBoundary = intersectNode(sourceCenter, startRef, sourceShape, borderRadius);
66
+ const endBoundary = intersectNode(targetCenter, endRef, targetShape, borderRadius);
67
+ out[0] = pushOutFromNodeBoundary(startBoundary, sourceCenter, gap);
68
+ out[out.length - 1] = pushOutFromNodeBoundary(endBoundary, targetCenter, gap);
69
+ return out;
70
+ }
71
+ function pointInsideNodeBounds(point, node, epsilon = 0.5) {
72
+ const left = node.x - node.width / 2;
73
+ const right = node.x + node.width / 2;
74
+ const top = node.y - node.height / 2;
75
+ const bottom = node.y + node.height / 2;
76
+ return (point.x >= left - epsilon &&
77
+ point.x <= right + epsilon &&
78
+ point.y >= top - epsilon &&
79
+ point.y <= bottom + epsilon);
80
+ }
81
+ function chooseSourceAimPoint(center, points) {
82
+ const first = points[0];
83
+ if (!first || points.length < 2)
84
+ return first;
85
+ const second = points[1];
86
+ const dx = first.x - center.x;
87
+ const dy = first.y - center.y;
88
+ const turnDx = second.x - first.x;
89
+ const turnDy = second.y - first.y;
90
+ // If dagre creates a short vertical exit then quickly turns lateral,
91
+ // prefer the lateral point so the edge exits from the side when appropriate.
92
+ const verticalThenLateral = Math.abs(dx) < 6 &&
93
+ Math.abs(dy) > 6 &&
94
+ Math.abs(turnDx) > Math.abs(turnDy) * 1.25;
95
+ return verticalThenLateral ? second : first;
96
+ }
97
+ function chooseTargetAimPoint(center, points) {
98
+ const last = points[points.length - 1];
99
+ if (!last || points.length < 2)
100
+ return last;
101
+ const prev = points[points.length - 2];
102
+ const dx = last.x - center.x;
103
+ const dy = last.y - center.y;
104
+ const turnDx = last.x - prev.x;
105
+ const turnDy = last.y - prev.y;
106
+ const lateralThenVerticalNearTarget = Math.abs(dx) < 6 &&
107
+ Math.abs(dy) > 6 &&
108
+ Math.abs(turnDx) > Math.abs(turnDy) * 1.25;
109
+ return lateralThenVerticalNearTarget ? prev : last;
110
+ }
111
+ // ─── Edge Builder ───────────────────────────────────────────────────────────
112
+ export function buildEdges(parsedEdges, positions, _handles, direction, theme, diamondEdgeHandles, edgeWaypoints, nodeShapes) {
113
+ const markerSize = theme.edgeDefaults.markerSize;
114
+ const isVertical = direction === 'TB' || direction === 'BT';
115
+ const cardBorderRadius = parseInt(theme.radius.lg, 10) || 8;
116
+ const branchPalette = buildBranchPalette(theme.color.orange1, theme.color.orange2, theme.color.orange3);
117
+ // Build handle slot maps for forward edges
118
+ const forwardBySource = new Map();
119
+ const forwardByTarget = new Map();
120
+ parsedEdges.forEach((edge, i) => {
121
+ const sp = positions.get(edge.source);
122
+ const tp = positions.get(edge.target);
123
+ if (!sp || !tp)
124
+ return;
125
+ const edgeClass = classifyEdge(sp, tp, direction);
126
+ if (edgeClass !== 'forward')
127
+ return;
128
+ const targetCross = isVertical ? tp.x + tp.width / 2 : tp.y + tp.height / 2;
129
+ const sourceCross = isVertical ? sp.x + sp.width / 2 : sp.y + sp.height / 2;
130
+ const srcGroup = forwardBySource.get(edge.source) ?? [];
131
+ srcGroup.push({ edgeIndex: i, otherPos: targetCross });
132
+ forwardBySource.set(edge.source, srcGroup);
133
+ const tgtGroup = forwardByTarget.get(edge.target) ?? [];
134
+ tgtGroup.push({ edgeIndex: i, otherPos: sourceCross });
135
+ forwardByTarget.set(edge.target, tgtGroup);
136
+ });
137
+ const fwSourceSlot = new Map();
138
+ const fwTargetSlot = new Map();
139
+ for (const [, group] of forwardBySource) {
140
+ group.sort((a, b) => a.otherPos - b.otherPos);
141
+ group.forEach((entry, slot) => fwSourceSlot.set(entry.edgeIndex, slot));
142
+ }
143
+ for (const [, group] of forwardByTarget) {
144
+ group.sort((a, b) => a.otherPos - b.otherPos);
145
+ group.forEach((entry, slot) => fwTargetSlot.set(entry.edgeIndex, slot));
146
+ }
147
+ // Back-edge side handle counters
148
+ const SIDE_SLOTS = 5;
149
+ const backSourceCounters = new Map();
150
+ const backTargetCounters = new Map();
151
+ return parsedEdges.map((edge, i) => {
152
+ const sourcePos = positions.get(edge.source);
153
+ const targetPos = positions.get(edge.target);
154
+ const edgeClass = sourcePos && targetPos
155
+ ? classifyEdge(sourcePos, targetPos, direction)
156
+ : 'forward';
157
+ const waypoints = edgeWaypoints?.[i];
158
+ const sourceShape = nodeShapes?.get(edge.source) ?? 'rect';
159
+ const targetShape = nodeShapes?.get(edge.target) ?? 'rect';
160
+ // ── Compute path from dagre waypoints ──
161
+ let svgPath = '';
162
+ let labelPos = { x: 0, y: 0 };
163
+ // ER edges draw cardinality symbols manually (no SVG markers),
164
+ // so no path shortening needed.
165
+ const isErEdge = edge.cardSource || edge.cardTarget;
166
+ const isMindmapEdge = !!edge.noArrow;
167
+ const shortenEnd = isErEdge ? ER_PATH_GAP : (isMindmapEdge ? 0 : MARKER_SHORTENING);
168
+ const shortenStart = isErEdge ? ER_PATH_GAP : 0;
169
+ // Track path endpoints + angles for ER cardinality symbols
170
+ let pathStart;
171
+ let pathEnd;
172
+ let startAngle = 0; // angle toward source node (radians)
173
+ let endAngle = 0; // angle toward target node (radians)
174
+ let routePointsForLive;
175
+ if (waypoints && waypoints.points.length > 0 && sourcePos && targetPos) {
176
+ const sourceCenter = {
177
+ x: sourcePos.x + sourcePos.width / 2,
178
+ y: sourcePos.y + sourcePos.height / 2,
179
+ width: sourcePos.width,
180
+ height: sourcePos.height,
181
+ };
182
+ const targetCenter = {
183
+ x: targetPos.x + targetPos.width / 2,
184
+ y: targetPos.y + targetPos.height / 2,
185
+ width: targetPos.width,
186
+ height: targetPos.height,
187
+ };
188
+ const rawPoints = waypoints.points;
189
+ // Determine if dagre's waypoints deviate from a straight source→target line.
190
+ // Back/lateral edges always use waypoints (they need routing to avoid overlap).
191
+ // Forward edges use waypoints only if they deviate significantly from straight.
192
+ // For ER, always keep dagre waypoint routing so curves stay natural.
193
+ // The straight-forward shortcut makes ER edges look too angular.
194
+ const isSimpleForward = !isErEdge && !edge.noArrow && edgeClass === 'forward' && !waypointsDeviate(rawPoints, 20);
195
+ if (isSimpleForward) {
196
+ // Simple forward edge: straight line from source boundary to target boundary.
197
+ const startIntersect = intersectNode(sourceCenter, targetCenter, sourceShape, isErEdge ? cardBorderRadius : 0);
198
+ const endIntersect = intersectNode(targetCenter, sourceCenter, targetShape, isErEdge ? cardBorderRadius : 0);
199
+ let finalPoints = [startIntersect, endIntersect];
200
+ finalPoints = shortenPathStart(finalPoints, shortenStart);
201
+ finalPoints = shortenPathEnd(finalPoints, shortenEnd);
202
+ if (isErEdge) {
203
+ finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius, ER_PATH_GAP);
204
+ }
205
+ labelPos = computeLabelPosition(finalPoints);
206
+ svgPath = `M${finalPoints[0].x},${finalPoints[0].y}L${finalPoints[1].x},${finalPoints[1].y}`;
207
+ routePointsForLive = finalPoints;
208
+ // Compute endpoint angles for ER cardinality symbols
209
+ pathStart = finalPoints[0];
210
+ pathEnd = finalPoints[finalPoints.length - 1];
211
+ // Start angle: toward source node (from second point back to first)
212
+ startAngle = Math.atan2(finalPoints[0].y - finalPoints[1].y, finalPoints[0].x - finalPoints[1].x);
213
+ // End angle: toward target node (from second-to-last to last)
214
+ endAngle = Math.atan2(finalPoints[finalPoints.length - 1].y - finalPoints[finalPoints.length - 2].y, finalPoints[finalPoints.length - 1].x - finalPoints[finalPoints.length - 2].x);
215
+ }
216
+ else {
217
+ // Complex route: strip first/last (node centers), keep inner routing points.
218
+ const innerPoints = rawPoints.length > 2
219
+ ? rawPoints.slice(1, -1)
220
+ : rawPoints.slice();
221
+ // Dagre waypoints are based on estimated node sizes. After React Flow
222
+ // measurement, ER/Class nodes can be taller; trim any near-end waypoints
223
+ // that now fall inside source/target node bounds to prevent edge overlap.
224
+ while (innerPoints.length > 0 && pointInsideNodeBounds(innerPoints[0], sourceCenter)) {
225
+ innerPoints.shift();
226
+ }
227
+ while (innerPoints.length > 0 && pointInsideNodeBounds(innerPoints[innerPoints.length - 1], targetCenter)) {
228
+ innerPoints.pop();
229
+ }
230
+ if (innerPoints.length === 0) {
231
+ const startIntersect = intersectNode(sourceCenter, targetCenter, sourceShape, isErEdge ? cardBorderRadius : 0);
232
+ const endIntersect = intersectNode(targetCenter, sourceCenter, targetShape, isErEdge ? cardBorderRadius : 0);
233
+ let finalPoints = [startIntersect, endIntersect];
234
+ finalPoints = shortenPathStart(finalPoints, shortenStart);
235
+ finalPoints = shortenPathEnd(finalPoints, shortenEnd);
236
+ if (isErEdge) {
237
+ finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius, ER_PATH_GAP);
238
+ }
239
+ labelPos = computeLabelPosition(finalPoints);
240
+ svgPath = `M${finalPoints[0].x},${finalPoints[0].y}L${finalPoints[1].x},${finalPoints[1].y}`;
241
+ routePointsForLive = finalPoints;
242
+ pathStart = finalPoints[0];
243
+ pathEnd = finalPoints[finalPoints.length - 1];
244
+ startAngle = Math.atan2(finalPoints[0].y - finalPoints[1].y, finalPoints[0].x - finalPoints[1].x);
245
+ endAngle = Math.atan2(finalPoints[finalPoints.length - 1].y - finalPoints[finalPoints.length - 2].y, finalPoints[finalPoints.length - 1].x - finalPoints[finalPoints.length - 2].x);
246
+ }
247
+ else {
248
+ const sourceAim = isErEdge ? chooseSourceAimPoint(sourceCenter, innerPoints) : innerPoints[0];
249
+ const targetAim = isErEdge ? chooseTargetAimPoint(targetCenter, innerPoints) : innerPoints[innerPoints.length - 1];
250
+ const startIntersect = intersectNode(sourceCenter, sourceAim, sourceShape, isErEdge ? cardBorderRadius : 0);
251
+ const endIntersect = intersectNode(targetCenter, targetAim, targetShape, isErEdge ? cardBorderRadius : 0);
252
+ let finalPoints = [startIntersect, ...innerPoints, endIntersect];
253
+ finalPoints = shortenPathStart(finalPoints, shortenStart);
254
+ finalPoints = shortenPathEnd(finalPoints, shortenEnd);
255
+ if (isErEdge) {
256
+ finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius, ER_PATH_GAP);
257
+ }
258
+ labelPos = computeLabelPosition(finalPoints);
259
+ // For ER, keep middle smoothing but force straight start/end segments.
260
+ // This stabilizes endpoint tangent (for cardinality symbols) and
261
+ // prevents spline overshoot from visually re-entering node bounds.
262
+ if (edge.noArrow) {
263
+ if (finalPoints.length === 3) {
264
+ const [p0, c, p2] = finalPoints;
265
+ svgPath = `M${p0.x},${p0.y}Q${c.x},${c.y},${p2.x},${p2.y}`;
266
+ }
267
+ else {
268
+ svgPath = generateRoundedPath(finalPoints, 24);
269
+ }
270
+ }
271
+ else {
272
+ svgPath = generateSmoothPath(finalPoints, !!isErEdge);
273
+ }
274
+ routePointsForLive = finalPoints;
275
+ // Compute endpoint angles for ER cardinality symbols
276
+ pathStart = finalPoints[0];
277
+ pathEnd = finalPoints[finalPoints.length - 1];
278
+ startAngle = Math.atan2(finalPoints[0].y - finalPoints[1].y, finalPoints[0].x - finalPoints[1].x);
279
+ endAngle = Math.atan2(finalPoints[finalPoints.length - 1].y - finalPoints[finalPoints.length - 2].y, finalPoints[finalPoints.length - 1].x - finalPoints[finalPoints.length - 2].x);
280
+ }
281
+ }
282
+ }
283
+ // ── Handle assignment ──
284
+ let sourceHandle;
285
+ let targetHandle;
286
+ if (isErEdge && sourcePos && targetPos) {
287
+ // ER-specific handle strategy:
288
+ // prefer side handles for lateral/diagonal links, top/bottom for vertical links.
289
+ const srcCX = sourcePos.x + sourcePos.width / 2;
290
+ const srcCY = sourcePos.y + sourcePos.height / 2;
291
+ const tgtCX = targetPos.x + targetPos.width / 2;
292
+ const tgtCY = targetPos.y + targetPos.height / 2;
293
+ const dx = tgtCX - srcCX;
294
+ const dy = tgtCY - srcCY;
295
+ const lateralBias = Math.abs(dx) > Math.abs(dy) * 0.55;
296
+ if (lateralBias) {
297
+ const srcSide = dx >= 0 ? 'right' : 'left';
298
+ const tgtSide = dx >= 0 ? 'left' : 'right';
299
+ const midSlot = 2; // center of 5 slots
300
+ sourceHandle = `back-${srcSide}-source-${midSlot}`;
301
+ targetHandle = `back-${tgtSide}-target-${midSlot}`;
302
+ }
303
+ else {
304
+ // Vertical ER links use canonical forward handles.
305
+ sourceHandle = 'fw-source-0';
306
+ targetHandle = 'fw-target-0';
307
+ }
308
+ }
309
+ else if (edgeClass === 'back') {
310
+ const srcCX = sourcePos ? sourcePos.x + sourcePos.width / 2 : 0;
311
+ const tgtCX = targetPos ? targetPos.x + targetPos.width / 2 : 0;
312
+ const side = srcCX <= tgtCX ? 'left' : 'right';
313
+ const srcKey = `${edge.source}:${side}`;
314
+ const tgtKey = `${edge.target}:${side}`;
315
+ const srcSlot = (backSourceCounters.get(srcKey) ?? 0) % SIDE_SLOTS;
316
+ const tgtSlot = (backTargetCounters.get(tgtKey) ?? 0) % SIDE_SLOTS;
317
+ backSourceCounters.set(srcKey, srcSlot + 1);
318
+ backTargetCounters.set(tgtKey, tgtSlot + 1);
319
+ sourceHandle = `back-${side}-source-${srcSlot}`;
320
+ targetHandle = `back-${side}-target-${tgtSlot}`;
321
+ }
322
+ else if (edgeClass === 'lateral') {
323
+ sourceHandle = 'lateral-source';
324
+ targetHandle = 'lateral-target';
325
+ }
326
+ else {
327
+ const diamondHandle = diamondEdgeHandles?.get(i);
328
+ if (diamondHandle) {
329
+ sourceHandle = diamondHandle;
330
+ }
331
+ else {
332
+ const srcGroup = forwardBySource.get(edge.source);
333
+ const srcSlot = fwSourceSlot.get(i);
334
+ if (srcGroup && srcGroup.length > 1 && srcSlot !== undefined) {
335
+ sourceHandle = `fw-source-${srcSlot}`;
336
+ }
337
+ }
338
+ const tgtGroup = forwardByTarget.get(edge.target);
339
+ const tgtSlot = fwTargetSlot.get(i);
340
+ if (tgtGroup && tgtGroup.length > 1 && tgtSlot !== undefined) {
341
+ targetHandle = `fw-target-${tgtSlot}`;
342
+ }
343
+ }
344
+ // ER edges use cardinality markers instead of arrowheads
345
+ const hasCardinality = edge.cardSource || edge.cardTarget;
346
+ const disableArrowMarker = hasCardinality || edge.noArrow;
347
+ const baseStyle = edgeStyleForType(edge.edgeType, theme);
348
+ const mindmapStyle = edge.noArrow
349
+ ? {
350
+ ...baseStyle,
351
+ stroke: branchPalette[Math.abs(edge.branchIndex ?? 0) % branchPalette.length],
352
+ strokeWidth: (edge.treeDepth ?? 2) <= 1
353
+ ? 6
354
+ : (edge.treeDepth ?? 2) === 2
355
+ ? 3.5
356
+ : 2.25,
357
+ opacity: 0.92,
358
+ }
359
+ : baseStyle;
360
+ return {
361
+ id: `e-${edge.source}-${edge.target}-${i}`,
362
+ source: edge.source,
363
+ target: edge.target,
364
+ label: edge.label,
365
+ type: 'routed',
366
+ animated: false,
367
+ style: mindmapStyle,
368
+ labelStyle: theme.edgeDefaults.labelStyle,
369
+ labelBgStyle: theme.edgeDefaults.labelBgStyle,
370
+ labelBgPadding: theme.edgeDefaults.labelBgPadding,
371
+ labelBgBorderRadius: theme.edgeDefaults.labelBgBorderRadius,
372
+ sourceHandle,
373
+ targetHandle,
374
+ zIndex: isErEdge ? 2 : (edgeClass === 'back' ? 0 : 1),
375
+ data: {
376
+ svgPath,
377
+ labelX: labelPos.x,
378
+ labelY: labelPos.y,
379
+ bgColor: theme.color.darkBg2,
380
+ cardSource: edge.cardSource,
381
+ cardTarget: edge.cardTarget,
382
+ cardSourceToken: edge.cardSourceToken,
383
+ cardTargetToken: edge.cardTargetToken,
384
+ ...(isErEdge && routePointsForLive ? { routePoints: routePointsForLive } : {}),
385
+ // Endpoint data for manual cardinality symbol rendering
386
+ ...(isErEdge && pathStart && pathEnd ? {
387
+ pathStartX: pathStart.x,
388
+ pathStartY: pathStart.y,
389
+ pathStartAngle: startAngle,
390
+ pathEndX: pathEnd.x,
391
+ pathEndY: pathEnd.y,
392
+ pathEndAngle: endAngle,
393
+ } : {}),
394
+ },
395
+ ...(disableArrowMarker ? {} : {
396
+ markerEnd: {
397
+ type: MarkerType.ArrowClosed,
398
+ color: theme.color.gray3,
399
+ width: markerSize,
400
+ height: markerSize,
401
+ },
402
+ }),
403
+ };
404
+ });
405
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * classify.ts — Edge classification and styling.
3
+ *
4
+ * Determines whether an edge is forward, back, or lateral based on
5
+ * node positions and graph direction. Also resolves edge visual styles
6
+ * (solid, dashed, thick) from theme tokens.
7
+ */
8
+ import type { ParsedGraph, EdgeType } from '../../types.js';
9
+ import type { Theme } from '../../ThemeContext.js';
10
+ import type { NodePosition } from '../types.js';
11
+ export type EdgeDirection = 'forward' | 'back' | 'lateral';
12
+ export declare function classifyEdge(sourcePos: NodePosition, targetPos: NodePosition, direction: ParsedGraph['direction']): EdgeDirection;
13
+ export declare function edgeStyleForType(edgeType: EdgeType | undefined, theme: Theme): Record<string, unknown>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * classify.ts — Edge classification and styling.
3
+ *
4
+ * Determines whether an edge is forward, back, or lateral based on
5
+ * node positions and graph direction. Also resolves edge visual styles
6
+ * (solid, dashed, thick) from theme tokens.
7
+ */
8
+ export function classifyEdge(sourcePos, targetPos, direction) {
9
+ const sourceCenterY = sourcePos.y + sourcePos.height / 2;
10
+ const targetCenterY = targetPos.y + targetPos.height / 2;
11
+ const sourceCenterX = sourcePos.x + sourcePos.width / 2;
12
+ const targetCenterX = targetPos.x + targetPos.width / 2;
13
+ const isVertical = direction === 'TB' || direction === 'BT';
14
+ const primaryDelta = isVertical
15
+ ? targetCenterY - sourceCenterY
16
+ : targetCenterX - sourceCenterX;
17
+ const threshold = isVertical
18
+ ? (sourcePos.height + targetPos.height) / 4
19
+ : (sourcePos.width + targetPos.width) / 4;
20
+ if (Math.abs(primaryDelta) < threshold)
21
+ return 'lateral';
22
+ const isForwardDirection = direction === 'TB' || direction === 'LR';
23
+ return (primaryDelta > 0) === isForwardDirection ? 'forward' : 'back';
24
+ }
25
+ // ─── Edge Styling ───────────────────────────────────────────────────────────
26
+ export function edgeStyleForType(edgeType, theme) {
27
+ const base = theme.edgeDefaults.style;
28
+ switch (edgeType) {
29
+ case 'dashed':
30
+ return { ...base, strokeDasharray: theme.edgeDefaults.dashedPattern };
31
+ case 'thick':
32
+ return { ...base, strokeWidth: theme.edgeDefaults.thickWidth };
33
+ default:
34
+ return base;
35
+ }
36
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * diamondHandles.ts — Diamond handle configuration and forward edge counting.
3
+ *
4
+ * Computes how many source handles a diamond node needs at each position
5
+ * (bottom, left, right) based on where each outgoing edge's target is
6
+ * located relative to the diamond center. Also counts forward-edge
7
+ * connections per node for handle spreading.
8
+ */
9
+ import type { ParsedEdge, ParsedGraph, ParsedNode } from '../../types.js';
10
+ import type { NodePosition } from '../types.js';
11
+ export interface DiamondHandleConfig {
12
+ bottom: number;
13
+ left: number;
14
+ right: number;
15
+ }
16
+ export declare function computeDiamondAssignments(nodes: ParsedNode[], parsedEdges: ParsedEdge[], positions: Map<string, NodePosition>, direction: ParsedGraph['direction']): {
17
+ handleConfigs: Map<string, DiamondHandleConfig>;
18
+ edgeHandles: Map<number, string>;
19
+ };
20
+ export declare function computeForwardEdgeCounts(parsedEdges: ParsedEdge[], positions: Map<string, NodePosition>, direction: ParsedGraph['direction']): {
21
+ incoming: Map<string, number>;
22
+ outgoing: Map<string, number>;
23
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * diamondHandles.ts — Diamond handle configuration and forward edge counting.
3
+ *
4
+ * Computes how many source handles a diamond node needs at each position
5
+ * (bottom, left, right) based on where each outgoing edge's target is
6
+ * located relative to the diamond center. Also counts forward-edge
7
+ * connections per node for handle spreading.
8
+ */
9
+ import { classifyEdge } from './classify.js';
10
+ export function computeDiamondAssignments(nodes, parsedEdges, positions, direction) {
11
+ const handleConfigs = new Map();
12
+ const edgeHandles = new Map();
13
+ const isVertical = direction === 'TB' || direction === 'BT';
14
+ const diamondIds = new Set();
15
+ for (const node of nodes) {
16
+ if (node.shape === 'diamond')
17
+ diamondIds.add(node.id);
18
+ }
19
+ if (diamondIds.size === 0)
20
+ return { handleConfigs, edgeHandles };
21
+ const outgoingByDiamond = new Map();
22
+ parsedEdges.forEach((edge, i) => {
23
+ if (!diamondIds.has(edge.source))
24
+ return;
25
+ const sp = positions.get(edge.source);
26
+ const tp = positions.get(edge.target);
27
+ if (!sp || !tp)
28
+ return;
29
+ const edgeClass = classifyEdge(sp, tp, direction);
30
+ if (edgeClass !== 'forward')
31
+ return;
32
+ const group = outgoingByDiamond.get(edge.source) ?? [];
33
+ group.push({ edgeIndex: i, targetId: edge.target });
34
+ outgoingByDiamond.set(edge.source, group);
35
+ });
36
+ for (const [diamondId, outgoing] of outgoingByDiamond) {
37
+ const diamondPos = positions.get(diamondId);
38
+ const diamondCX = diamondPos.x + diamondPos.width / 2;
39
+ const diamondCY = diamondPos.y + diamondPos.height / 2;
40
+ const threshold = diamondPos.width * 0.3;
41
+ const classified = [];
42
+ for (const { edgeIndex, targetId } of outgoing) {
43
+ const targetPos = positions.get(targetId);
44
+ const targetCX = targetPos.x + targetPos.width / 2;
45
+ const targetCY = targetPos.y + targetPos.height / 2;
46
+ let position;
47
+ const relativeX = targetCX - diamondCX;
48
+ if (isVertical) {
49
+ const isBelow = targetCY > diamondCY;
50
+ if (isBelow && Math.abs(relativeX) <= threshold) {
51
+ position = 'bottom';
52
+ }
53
+ else if (relativeX < 0) {
54
+ position = 'left';
55
+ }
56
+ else {
57
+ position = 'right';
58
+ }
59
+ }
60
+ else {
61
+ const relativeY = targetCY - diamondCY;
62
+ const isForward = direction === 'LR' ? targetCX > diamondCX : targetCX < diamondCX;
63
+ if (isForward && Math.abs(relativeY) <= threshold) {
64
+ position = 'bottom';
65
+ }
66
+ else if (relativeY < 0) {
67
+ position = 'left';
68
+ }
69
+ else {
70
+ position = 'right';
71
+ }
72
+ }
73
+ classified.push({ edgeIndex, relativeX, position });
74
+ }
75
+ const bottomEdges = classified.filter(c => c.position === 'bottom');
76
+ if (bottomEdges.length > 1) {
77
+ bottomEdges.sort((a, b) => Math.abs(a.relativeX) - Math.abs(b.relativeX));
78
+ for (let i = 1; i < bottomEdges.length; i++) {
79
+ bottomEdges[i].position = bottomEdges[i].relativeX < 0 ? 'left' : 'right';
80
+ }
81
+ }
82
+ const config = { bottom: 0, left: 0, right: 0 };
83
+ for (const c of classified) {
84
+ const slot = config[c.position];
85
+ config[c.position]++;
86
+ edgeHandles.set(c.edgeIndex, `fw-source-${c.position}-${slot}`);
87
+ }
88
+ handleConfigs.set(diamondId, config);
89
+ }
90
+ return { handleConfigs, edgeHandles };
91
+ }
92
+ // ─── Forward Edge Counts ────────────────────────────────────────────────────
93
+ export function computeForwardEdgeCounts(parsedEdges, positions, direction) {
94
+ const incoming = new Map();
95
+ const outgoing = new Map();
96
+ for (const edge of parsedEdges) {
97
+ const sourcePos = positions.get(edge.source);
98
+ const targetPos = positions.get(edge.target);
99
+ if (!sourcePos || !targetPos)
100
+ continue;
101
+ const edgeClass = classifyEdge(sourcePos, targetPos, direction);
102
+ if (edgeClass !== 'forward')
103
+ continue;
104
+ outgoing.set(edge.source, (outgoing.get(edge.source) ?? 0) + 1);
105
+ incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1);
106
+ }
107
+ return { incoming, outgoing };
108
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * edges/ — Edge construction pipeline.
3
+ *
4
+ * Barrel export for all edge-related functionality.
5
+ */
6
+ export { buildEdges } from './buildEdges.js';
7
+ export { computeForwardEdgeCounts, computeDiamondAssignments } from './diamondHandles.js';
8
+ export type { DiamondHandleConfig } from './diamondHandles.js';
9
+ export { classifyEdge, edgeStyleForType } from './classify.js';
10
+ export type { EdgeDirection } from './classify.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * edges/ — Edge construction pipeline.
3
+ *
4
+ * Barrel export for all edge-related functionality.
5
+ */
6
+ export { buildEdges } from './buildEdges.js';
7
+ export { computeForwardEdgeCounts, computeDiamondAssignments } from './diamondHandles.js';
8
+ export { classifyEdge, edgeStyleForType } from './classify.js';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * paths.ts — Pure path math utilities.
3
+ *
4
+ * Functions for path generation, shortening, deviation detection,
5
+ * and label placement. No side effects, no DOM, no React — just geometry.
6
+ */
7
+ import type { Point } from '../types.js';
8
+ /**
9
+ * Checks whether any waypoint deviates from the straight line between the
10
+ * first and last point by more than `threshold` pixels.
11
+ *
12
+ * Used to distinguish simple straight edges (where dagre's waypoints are
13
+ * essentially collinear) from routed edges (where dagre bent the path
14
+ * around obstacles). Back-edges and lateral edges skip this check entirely.
15
+ */
16
+ export declare function waypointsDeviate(points: Point[], threshold: number): boolean;
17
+ /**
18
+ * Shortens a path by moving the last point `distance` pixels back along
19
+ * the direction from the second-to-last point to the last point.
20
+ * This prevents the arrowhead marker from overshooting the node boundary.
21
+ */
22
+ export declare function shortenPathEnd(points: Point[], distance: number): Point[];
23
+ /**
24
+ * Shortens a path by moving the first point `distance` pixels forward along
25
+ * the direction from the first point to the second point.
26
+ * Used for ER start markers so they don't overflow into the source node.
27
+ */
28
+ export declare function shortenPathStart(points: Point[], distance: number): Point[];
29
+ /**
30
+ * Generates an SVG path using Catmull-Rom interpolation through all points.
31
+ *
32
+ * Unlike B-splines (which approximate), Catmull-Rom passes THROUGH every
33
+ * control point while producing smooth curves between them. This gives us
34
+ * the flowing look of Mermaid's curveBasis without the overshoot problem.
35
+ *
36
+ * For edges with only 2 points (straight lines), produces a simple L command.
37
+ * For edges with bends, produces smooth cubic bezier curves that naturally
38
+ * flow through dagre's computed waypoints.
39
+ *
40
+ * When `straightEnds` is true, the first and last segments use straight lines
41
+ * instead of curves. This ensures markers at path endpoints align perfectly
42
+ * with the node boundary (used for ER cardinality markers).
43
+ *
44
+ * Alpha = 0.5 (centripetal) prevents cusps and self-intersections.
45
+ */
46
+ export declare function generateSmoothPath(points: Point[], straightEnds?: boolean): string;
47
+ /**
48
+ * Basis spline path (d3 curveBasis style): smoother than Catmull-Rom and
49
+ * naturally reduces sharp elbows in orthogonal-ish routes.
50
+ */
51
+ export declare function generateBasisPath(points: Point[]): string;
52
+ /**
53
+ * Generates a polyline path with rounded corners using quadratic curves.
54
+ * End segments stay straight; only interior corners are rounded.
55
+ */
56
+ export declare function generateRoundedPath(points: Point[], radius?: number): string;
57
+ export declare function computeLabelPosition(points: Point[]): Point;