opentui-markdown-render 0.0.0-stage → 0.2.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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +223 -2
  4. package/bin/otmd.tsx +99 -0
  5. package/native/Cargo.lock +3708 -0
  6. package/native/Cargo.toml +28 -0
  7. package/native/build.rs +3 -0
  8. package/native/src/lib.rs +880 -0
  9. package/package.json +67 -4
  10. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  11. package/src/app.tsx +180 -0
  12. package/src/cli.ts +63 -0
  13. package/src/config/index.ts +46 -0
  14. package/src/config/load.ts +337 -0
  15. package/src/config/resolve.ts +36 -0
  16. package/src/config/theme.ts +110 -0
  17. package/src/config/types.ts +105 -0
  18. package/src/core/document.ts +44 -0
  19. package/src/core/frame.ts +144 -0
  20. package/src/core/image.ts +9 -0
  21. package/src/core/index.ts +6 -0
  22. package/src/core/layout.ts +27 -0
  23. package/src/core/math.ts +170 -0
  24. package/src/core/parse.ts +208 -0
  25. package/src/core/tables.ts +58 -0
  26. package/src/graphics.ts +85 -0
  27. package/src/headings.ts +77 -0
  28. package/src/index.ts +107 -0
  29. package/src/math/index.ts +86 -0
  30. package/src/math/native.ts +39 -0
  31. package/src/math/renderer.ts +84 -0
  32. package/src/math/validate.ts +44 -0
  33. package/src/media.tsx +267 -0
  34. package/src/merman/class/dagre-layout.ts +171 -0
  35. package/src/merman/class/diagram.ts +8 -0
  36. package/src/merman/class/drawing.ts +343 -0
  37. package/src/merman/class/labels.ts +38 -0
  38. package/src/merman/class/layout.ts +531 -0
  39. package/src/merman/class/parser.ts +308 -0
  40. package/src/merman/class/render-grid.ts +18 -0
  41. package/src/merman/class/style.ts +27 -0
  42. package/src/merman/class/types.ts +57 -0
  43. package/src/merman/core/canvas.ts +228 -0
  44. package/src/merman/core/color/style.ts +56 -0
  45. package/src/merman/core/drawing.ts +307 -0
  46. package/src/merman/core/geometry.ts +347 -0
  47. package/src/merman/core/mermaid.ts +38 -0
  48. package/src/merman/core/render-grid.ts +51 -0
  49. package/src/merman/core/spatial.ts +243 -0
  50. package/src/merman/core/text-lines.ts +32 -0
  51. package/src/merman/core/text.ts +44 -0
  52. package/src/merman/detect.ts +19 -0
  53. package/src/merman/diagnostics.ts +16 -0
  54. package/src/merman/diagram.ts +268 -0
  55. package/src/merman/flowchart/drawing.ts +433 -0
  56. package/src/merman/flowchart/labels.ts +144 -0
  57. package/src/merman/flowchart/layout.ts +1057 -0
  58. package/src/merman/flowchart/options.ts +12 -0
  59. package/src/merman/flowchart/parser.ts +477 -0
  60. package/src/merman/flowchart/render.ts +10 -0
  61. package/src/merman/flowchart/routing.ts +1429 -0
  62. package/src/merman/flowchart/style.ts +79 -0
  63. package/src/merman/flowchart/types.ts +63 -0
  64. package/src/merman/gantt/diagram.ts +8 -0
  65. package/src/merman/gantt/drawing.ts +193 -0
  66. package/src/merman/gantt/parser.ts +198 -0
  67. package/src/merman/gantt/render-grid.ts +17 -0
  68. package/src/merman/gantt/style.ts +36 -0
  69. package/src/merman/gantt/types.ts +45 -0
  70. package/src/merman/gitgraph/diagram.ts +8 -0
  71. package/src/merman/gitgraph/drawing.ts +234 -0
  72. package/src/merman/gitgraph/parser.ts +234 -0
  73. package/src/merman/gitgraph/render-grid.ts +17 -0
  74. package/src/merman/gitgraph/style.ts +37 -0
  75. package/src/merman/gitgraph/types.ts +36 -0
  76. package/src/merman/palette.ts +60 -0
  77. package/src/merman/sequence/diagram.ts +8 -0
  78. package/src/merman/sequence/drawing.ts +288 -0
  79. package/src/merman/sequence/options.ts +8 -0
  80. package/src/merman/sequence/parser.ts +303 -0
  81. package/src/merman/sequence/placement.ts +692 -0
  82. package/src/merman/sequence/render-grid.ts +22 -0
  83. package/src/merman/sequence/style.ts +81 -0
  84. package/src/merman/sequence/types.ts +74 -0
  85. package/src/merman/state/diagram.ts +8 -0
  86. package/src/merman/state/drawing.ts +363 -0
  87. package/src/merman/state/endpoint.ts +15 -0
  88. package/src/merman/state/layout.ts +1134 -0
  89. package/src/merman/state/note.ts +59 -0
  90. package/src/merman/state/options.ts +11 -0
  91. package/src/merman/state/parser.ts +245 -0
  92. package/src/merman/state/render-grid.ts +22 -0
  93. package/src/merman/state/routing.ts +1522 -0
  94. package/src/merman/state/search.ts +103 -0
  95. package/src/merman/state/style.ts +52 -0
  96. package/src/merman/state/types.ts +63 -0
  97. package/src/merman/state/visible-model.ts +77 -0
  98. package/src/merman/timeline/diagram.ts +8 -0
  99. package/src/merman/timeline/drawing.ts +109 -0
  100. package/src/merman/timeline/parser.ts +119 -0
  101. package/src/merman/timeline/render-grid.ts +17 -0
  102. package/src/merman/timeline/style.ts +36 -0
  103. package/src/merman/timeline/types.ts +31 -0
  104. package/src/merman.ts +3 -0
  105. package/src/statusline.tsx +183 -0
  106. package/src/theme/colors.ts +11 -0
  107. package/src/theme/defaults.ts +190 -0
  108. package/src/theme/index.ts +7 -0
  109. package/src/theme/load.ts +39 -0
  110. package/src/theme/resolve.ts +167 -0
  111. package/src/theme/syntax.ts +96 -0
  112. package/src/theme/types.ts +151 -0
  113. package/src/view/code.tsx +27 -0
  114. package/src/view/document-slots.ts +111 -0
  115. package/src/view/document-table.tsx +98 -0
  116. package/src/view/document.tsx +195 -0
  117. package/src/view/frame.tsx +83 -0
  118. package/src/view/inline-flow.tsx +113 -0
  119. package/src/view/inline-layout.ts +149 -0
  120. package/src/view/inline-math.ts +42 -0
  121. package/src/view/inline-style.ts +103 -0
  122. package/src/view/math.tsx +85 -0
  123. package/src/view/mermaid.tsx +128 -0
  124. package/src/view/table.ts +100 -0
  125. package/themes/dark.json +32 -0
  126. package/themes/example.jsonc +98 -0
  127. package/themes/light.json +32 -0
  128. package/themes/solarized-dark.json +32 -0
  129. package/themes/solarized-light.json +32 -0
@@ -0,0 +1,1429 @@
1
+ import {
2
+ advanceCoordinate,
3
+ afterFarthestCoordinate,
4
+ beforeNearestCoordinate,
5
+ boundsCenter,
6
+ boundsSidePoint,
7
+ centerCoordinate,
8
+ coordinate,
9
+ keepAfter,
10
+ keepBefore,
11
+ lane,
12
+ oppositeSide,
13
+ orthogonalPath,
14
+ orthogonalPathPoints,
15
+ pathThrough,
16
+ pathViaLane,
17
+ segmentBetween,
18
+ segmentSpan,
19
+ sideForDirection,
20
+ snapCoordinate,
21
+ shiftPoint,
22
+ withCoordinate,
23
+ type DiagramAxis,
24
+ type DiagramDirection,
25
+ type DiagramLane,
26
+ type DiagramSide,
27
+ } from "../core/geometry.js"
28
+ import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
29
+ import { flowchartEdgeLabelLayout, flowchartRouteLabelLayout, type FlowchartEdgeLabelLayout } from "./labels.js"
30
+ import type {
31
+ FlowchartDiagram,
32
+ FlowchartDirection,
33
+ FlowchartEdge,
34
+ FlowchartEdgeRoute,
35
+ FlowchartNodeBounds,
36
+ FlowchartPoint,
37
+ FlowchartSubgraph,
38
+ FlowchartSubgraphBounds,
39
+ } from "./types.js"
40
+
41
+ export { directionBetween as flowchartDirectionBetween } from "../core/geometry.js"
42
+
43
+ const BUS_CLEARANCE = 3
44
+ const NODE_CLEARANCE = 2
45
+ const ROUTING_CANDIDATE_BUDGET = 1024
46
+ type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
47
+ type VerticalTravel = Extract<DiagramDirection, "up" | "down">
48
+ type PortRole = "source" | "target"
49
+
50
+ interface EdgeRecord {
51
+ edge: FlowchartEdge
52
+ sourcePort: FlowchartPoint
53
+ targetPort: FlowchartPoint
54
+ }
55
+
56
+ function isVerticalDirection(direction: FlowchartDirection): boolean {
57
+ return direction === "TB" || direction === "TD" || direction === "BT"
58
+ }
59
+
60
+ function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel {
61
+ return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up"
62
+ }
63
+
64
+ function isVerticalBackEdge(
65
+ from: FlowchartNodeBounds,
66
+ to: FlowchartNodeBounds,
67
+ direction: FlowchartDirection,
68
+ ): boolean {
69
+ return direction === "BT"
70
+ ? centerCoordinate(to, "y") > centerCoordinate(from, "y")
71
+ : centerCoordinate(to, "y") < centerCoordinate(from, "y")
72
+ }
73
+
74
+ function isHorizontalBackEdge(
75
+ from: FlowchartNodeBounds,
76
+ to: FlowchartNodeBounds,
77
+ direction: FlowchartDirection,
78
+ ): boolean {
79
+ return direction === "RL"
80
+ ? centerCoordinate(to, "x") > centerCoordinate(from, "x")
81
+ : centerCoordinate(to, "x") < centerCoordinate(from, "x")
82
+ }
83
+
84
+ function horizontalTravel(
85
+ from: FlowchartNodeBounds,
86
+ to: FlowchartNodeBounds,
87
+ direction: FlowchartDirection,
88
+ ): HorizontalTravel {
89
+ const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x")
90
+ const targetIsSameOrRight = centerCoordinate(to, "x") >= centerCoordinate(from, "x")
91
+ return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left"
92
+ }
93
+
94
+ function verticalBackEdgePath(
95
+ from: FlowchartNodeBounds,
96
+ to: FlowchartNodeBounds,
97
+ leftBoundary?: number,
98
+ ): FlowchartPoint[] {
99
+ const start = boundsSidePoint(from, "left")
100
+ const end = boundsSidePoint(to, "left")
101
+ const busX = Math.min(
102
+ afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE),
103
+ leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2,
104
+ )
105
+ return pathViaLane(start, lane("x", busX), end)
106
+ }
107
+
108
+ function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
109
+ const travel = verticalTravel(from, to)
110
+ const startSide = sideForDirection(travel)
111
+ const endSide = oppositeSide(startSide)
112
+ const sourceCenter = boundsCenter(from)
113
+ const targetCenter = boundsCenter(to)
114
+ const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1))
115
+ const end = boundsSidePoint(to, endSide)
116
+ return orthogonalPath(start, end, { preferredAxis: "y" })
117
+ }
118
+
119
+ function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
120
+ const start = boundsSidePoint(from, "top")
121
+ const end = boundsSidePoint(to, "top")
122
+ const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE)
123
+ return pathViaLane(start, lane("y", busY), end)
124
+ }
125
+
126
+ function horizontalEdgePath(
127
+ from: FlowchartNodeBounds,
128
+ to: FlowchartNodeBounds,
129
+ direction: FlowchartDirection,
130
+ ): FlowchartPoint[] {
131
+ const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width
132
+ if (overlapsHorizontally) return verticalForwardEdgePath(from, to)
133
+
134
+ if (isHorizontalBackEdge(from, to, direction)) return horizontalBackEdgePath(from, to)
135
+
136
+ const travel = horizontalTravel(from, to, direction)
137
+ const startSide = sideForDirection(travel)
138
+ return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)), {
139
+ preferredAxis: "x",
140
+ })
141
+ }
142
+
143
+ function selfEdgePath(bounds: FlowchartNodeBounds, laneOffset = 0): FlowchartPoint[] {
144
+ const start = boundsSidePoint(bounds, "right")
145
+ const end = boundsSidePoint(bounds, "bottom")
146
+ const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE + laneOffset
147
+ const bottomLaneY = bounds.top + bounds.height + 1 + laneOffset
148
+ return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
149
+ }
150
+
151
+ function parallelEdgePath(
152
+ from: FlowchartNodeBounds,
153
+ to: FlowchartNodeBounds,
154
+ axis: DiagramAxis,
155
+ laneCoordinate: number,
156
+ ): FlowchartPoint[] {
157
+ if (axis === "y") {
158
+ const start = boundsSidePoint(from, "bottom")
159
+ const end = boundsSidePoint(to, "bottom")
160
+ return pathViaLane(start, lane("y", laneCoordinate), end)
161
+ }
162
+
163
+ const start = boundsSidePoint(from, "right")
164
+ const end = boundsSidePoint(to, "right")
165
+ return pathViaLane(start, lane("x", laneCoordinate), end)
166
+ }
167
+
168
+ function labelHeight(edge: FlowchartEdge): number {
169
+ return edge.label ? splitDiagramLines(edge.label).length : 0
170
+ }
171
+
172
+ function rightRenderExtent(route: FlowchartEdgeRoute): number {
173
+ let right = Math.max(...route.points.map((point) => point.x))
174
+ if (route.edge.label) {
175
+ const label = flowchartRouteLabelLayout(route, diagramTextWidth)
176
+ right = Math.max(right, label.point.x + label.width - 1)
177
+ }
178
+ return right
179
+ }
180
+
181
+ function parallelLaneAxis(from: FlowchartNodeBounds, to: FlowchartNodeBounds): DiagramAxis {
182
+ return Math.abs(to.centerX - from.centerX) >= Math.abs(to.centerY - from.centerY) ? "y" : "x"
183
+ }
184
+
185
+ function edgePath(
186
+ from: FlowchartNodeBounds,
187
+ to: FlowchartNodeBounds,
188
+ direction: FlowchartDirection,
189
+ leftBoundary?: number,
190
+ ): FlowchartPoint[] {
191
+ if (from.id === to.id) return selfEdgePath(from)
192
+ if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
193
+ const overlapsVertically = from.top < to.top + to.height && to.top < from.top + from.height
194
+ if (overlapsVertically) {
195
+ const travel: HorizontalTravel = centerCoordinate(to, "x") >= centerCoordinate(from, "x") ? "right" : "left"
196
+ return orthogonalPath(
197
+ boundsSidePoint(from, sideForDirection(travel)),
198
+ boundsSidePoint(to, oppositeSide(sideForDirection(travel))),
199
+ )
200
+ }
201
+ return isVerticalBackEdge(from, to, direction)
202
+ ? verticalBackEdgePath(from, to, leftBoundary)
203
+ : verticalForwardEdgePath(from, to)
204
+ }
205
+
206
+ function sourceFanOutLane(
207
+ sourcePort: FlowchartPoint,
208
+ targetPorts: readonly FlowchartPoint[],
209
+ axis: DiagramAxis,
210
+ travel: DiagramDirection,
211
+ ): number {
212
+ const sourceCoordinate = coordinate(sourcePort, axis)
213
+ const unclamped = keepBefore(
214
+ advanceCoordinate(coordinate(sourcePort, axis), travel, BUS_CLEARANCE),
215
+ beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE),
216
+ travel,
217
+ )
218
+ return keepAfter(unclamped, sourceCoordinate, travel)
219
+ }
220
+
221
+ function reserveFanOutLane(
222
+ sourcePort: FlowchartPoint,
223
+ targetPorts: readonly FlowchartPoint[],
224
+ axis: DiagramAxis,
225
+ travel: DiagramDirection,
226
+ reserved: Set<number>,
227
+ ): number {
228
+ const preferred = sourceFanOutLane(sourcePort, targetPorts, axis, travel)
229
+ const boundary = beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE)
230
+ const source = coordinate(sourcePort, axis)
231
+ const available = (() => {
232
+ let checked = 0
233
+ for (let offset = 0; offset <= Math.abs(boundary - preferred) && checked < ROUTING_CANDIDATE_BUDGET; offset++) {
234
+ checked++
235
+ const candidate = advanceCoordinate(preferred, travel, offset)
236
+ if (
237
+ keepBefore(candidate, boundary, travel) === candidate &&
238
+ keepAfter(candidate, source, travel) === candidate &&
239
+ !reserved.has(candidate)
240
+ ) {
241
+ return candidate
242
+ }
243
+ }
244
+ for (let offset = 1; offset <= Math.abs(preferred - source) && checked < ROUTING_CANDIDATE_BUDGET; offset++) {
245
+ checked++
246
+ const candidate = advanceCoordinate(preferred, travel, -offset)
247
+ if (
248
+ keepBefore(candidate, boundary, travel) === candidate &&
249
+ keepAfter(candidate, source, travel) === candidate &&
250
+ !reserved.has(candidate)
251
+ ) {
252
+ return candidate
253
+ }
254
+ }
255
+ })()
256
+ const routeLane = available ?? preferred
257
+ reserved.add(routeLane)
258
+ return routeLane
259
+ }
260
+
261
+ function targetFanInLane(
262
+ sourcePorts: readonly FlowchartPoint[],
263
+ targetPort: FlowchartPoint,
264
+ axis: DiagramAxis,
265
+ travel: DiagramDirection,
266
+ ): number {
267
+ const targetCoordinate = coordinate(targetPort, axis)
268
+ const unclamped = keepAfter(
269
+ advanceCoordinate(coordinate(targetPort, axis), travel, -BUS_CLEARANCE),
270
+ afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
271
+ travel,
272
+ )
273
+ return keepBefore(unclamped, advanceCoordinate(targetCoordinate, travel, -1), travel)
274
+ }
275
+
276
+ function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
277
+ const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel))
278
+ return boundsSidePoint(bounds, side)
279
+ }
280
+
281
+ function horizontalForwardRecords(
282
+ edges: FlowchartEdge[],
283
+ bounds: Map<string, FlowchartNodeBounds>,
284
+ direction: FlowchartDirection,
285
+ ): EdgeRecord[] {
286
+ const travel = direction === "RL" ? "left" : "right"
287
+ const records: EdgeRecord[] = []
288
+ for (const edge of edges) {
289
+ const source = bounds.get(edge.from)
290
+ const target = bounds.get(edge.to)
291
+ if (!source || !target) continue
292
+ const forward =
293
+ direction === "RL"
294
+ ? centerCoordinate(target, "x") < centerCoordinate(source, "x")
295
+ : centerCoordinate(target, "x") > centerCoordinate(source, "x")
296
+ if (!forward) continue
297
+ records.push({
298
+ edge,
299
+ sourcePort: portForTravel(source, travel, "source"),
300
+ targetPort: portForTravel(target, travel, "target"),
301
+ })
302
+ }
303
+ return records
304
+ }
305
+
306
+ function verticalForwardRecords(
307
+ edges: FlowchartEdge[],
308
+ bounds: Map<string, FlowchartNodeBounds>,
309
+ direction: FlowchartDirection,
310
+ ): EdgeRecord[] {
311
+ const travel = direction === "BT" ? "up" : "down"
312
+ const records: EdgeRecord[] = []
313
+ for (const edge of edges) {
314
+ const source = bounds.get(edge.from)
315
+ const target = bounds.get(edge.to)
316
+ if (!source || !target) continue
317
+ const forward =
318
+ direction === "BT"
319
+ ? centerCoordinate(target, "y") < centerCoordinate(source, "y")
320
+ : centerCoordinate(target, "y") > centerCoordinate(source, "y")
321
+ if (!forward) continue
322
+ records.push({
323
+ edge,
324
+ sourcePort: portForTravel(source, travel, "source"),
325
+ targetPort: portForTravel(target, travel, "target"),
326
+ })
327
+ }
328
+ return records
329
+ }
330
+
331
+ function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
332
+ for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
333
+ if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
334
+ if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph
335
+ }
336
+ return undefined
337
+ }
338
+
339
+ function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
340
+ for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
341
+ if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
342
+ if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph
343
+ }
344
+ return undefined
345
+ }
346
+
347
+ function horizontalSubgraphEntryTravel(subgraph: FlowchartSubgraph): HorizontalTravel {
348
+ return subgraph.direction === "RL" ? "left" : "right"
349
+ }
350
+
351
+ function horizontalSubgraphEntryLane(subgraph: FlowchartSubgraph, subgraphBound: FlowchartSubgraphBounds): number {
352
+ return subgraph.direction === "RL"
353
+ ? subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
354
+ : subgraphBound.left - BUS_CLEARANCE
355
+ }
356
+
357
+ function horizontalSubgraphJoinY(from: FlowchartSubgraphBounds, targetSubgraphBound: FlowchartSubgraphBounds): number {
358
+ if (from.centerY <= targetSubgraphBound.centerY) {
359
+ const start = from.top + from.height
360
+ const end = targetSubgraphBound.top - 1
361
+ return start <= end ? Math.floor((start + end) / 2) : start
362
+ }
363
+
364
+ const start = targetSubgraphBound.top + targetSubgraphBound.height
365
+ const end = from.top - 1
366
+ return start <= end ? Math.floor((start + end) / 2) : end
367
+ }
368
+
369
+ function horizontalSubgraphExitJoinY(
370
+ from: FlowchartSubgraphBounds,
371
+ targetPort: FlowchartPoint,
372
+ targetBelow: boolean,
373
+ ): number {
374
+ if (targetBelow) {
375
+ const outside = from.top + from.height
376
+ const beforeTarget = targetPort.y - 1
377
+ const preferred = targetPort.y - BUS_CLEARANCE
378
+ return outside <= beforeTarget ? Math.min(Math.max(outside, preferred), beforeTarget) : beforeTarget
379
+ }
380
+
381
+ const outside = from.top - 1
382
+ const afterTarget = targetPort.y + 1
383
+ const preferred = targetPort.y + BUS_CLEARANCE
384
+ return afterTarget <= outside ? Math.max(Math.min(outside, preferred), afterTarget) : afterTarget
385
+ }
386
+
387
+ function groupRecords<Record>(records: readonly Record[], key: (record: Record) => string): Map<string, Record[]> {
388
+ const groups = new Map<string, Record[]>()
389
+ for (const record of records) {
390
+ const groupKey = key(record)
391
+ const group = groups.get(groupKey) ?? []
392
+ group.push(record)
393
+ groups.set(groupKey, group)
394
+ }
395
+ return groups
396
+ }
397
+
398
+ function fanRoute(
399
+ edge: FlowchartEdge,
400
+ sourcePort: FlowchartPoint,
401
+ targetPort: FlowchartPoint,
402
+ routeLane: DiagramLane,
403
+ ): FlowchartEdgeRoute {
404
+ return { edge, points: pathViaLane(sourcePort, routeLane, targetPort) }
405
+ }
406
+
407
+ function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeRecord[] {
408
+ const xs = records.map((record) => record.sourcePort.x)
409
+ const minX = Math.min(...xs)
410
+ const maxX = Math.max(...xs)
411
+ if (maxX - minX > 1) return [...records]
412
+
413
+ const x = Math.round(xs.reduce((total, value) => total + value, 0) / xs.length)
414
+ return records.map((record) => ({ ...record, sourcePort: { ...record.sourcePort, x } }))
415
+ }
416
+
417
+ function routeHorizontalFanOut(
418
+ records: readonly EdgeRecord[],
419
+ bounds: ReadonlyMap<string, FlowchartNodeBounds>,
420
+ direction: FlowchartDirection,
421
+ handled: Set<FlowchartEdge>,
422
+ routes: FlowchartEdgeRoute[],
423
+ ): void {
424
+ const reservedBusLanes = new Set<number>()
425
+ const targetOwners = new Map<string, string>()
426
+ for (const [sourceId, sourceRecords] of groupRecords(records, (record) => record.edge.from)) {
427
+ if (sourceRecords.length < 2) continue
428
+ const travel = direction === "RL" ? "left" : "right"
429
+ const sourcePort = sourceRecords[0]!.sourcePort
430
+ const targetPorts = sourceRecords.map((record) => record.targetPort)
431
+
432
+ const busX = reserveFanOutLane(sourcePort, targetPorts, "x", travel, reservedBusLanes)
433
+ for (const record of sourceRecords) {
434
+ const targetOwner = targetOwners.get(record.edge.to)
435
+ targetOwners.set(record.edge.to, targetOwner ?? sourceId)
436
+ const target = bounds.get(record.edge.to)
437
+ if (!targetOwner || targetOwner === sourceId || !target) {
438
+ routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
439
+ handled.add(record.edge)
440
+ continue
441
+ }
442
+
443
+ const targetSide = sourcePort.y < record.targetPort.y ? "top" : "bottom"
444
+ const targetPoint = boundsSidePoint(target, targetSide)
445
+ const approach = shiftPoint(targetPoint, targetSide === "top" ? "up" : "down")
446
+ routes.push({
447
+ edge: record.edge,
448
+ points: pathThrough([
449
+ sourcePort,
450
+ { x: busX, y: sourcePort.y },
451
+ { x: busX, y: approach.y },
452
+ approach,
453
+ targetPoint,
454
+ ]),
455
+ })
456
+ handled.add(record.edge)
457
+ }
458
+ }
459
+ }
460
+
461
+ function routeHorizontalFanIn(
462
+ records: readonly EdgeRecord[],
463
+ direction: FlowchartDirection,
464
+ handled: Set<FlowchartEdge>,
465
+ routes: FlowchartEdgeRoute[],
466
+ ): void {
467
+ const unhandledRecords = records.filter((record) => !handled.has(record.edge))
468
+ for (const targetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
469
+ if (targetRecords.length < 2) continue
470
+ const travel = direction === "RL" ? "left" : "right"
471
+ const targetPort = targetRecords[0]!.targetPort
472
+ const sourcePorts = targetRecords.map((record) => record.sourcePort)
473
+
474
+ const busX = targetFanInLane(sourcePorts, targetPort, "x", travel)
475
+ for (const record of targetRecords) {
476
+ routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("x", busX)))
477
+ handled.add(record.edge)
478
+ }
479
+ }
480
+ }
481
+
482
+ function routeVerticalFanOut(
483
+ records: readonly EdgeRecord[],
484
+ direction: FlowchartDirection,
485
+ handled: Set<FlowchartEdge>,
486
+ routes: FlowchartEdgeRoute[],
487
+ ): void {
488
+ for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
489
+ if (sourceRecords.length < 2) continue
490
+ const travel = direction === "BT" ? "up" : "down"
491
+ const sourcePort = sourceRecords[0]!.sourcePort
492
+ const targetPorts = sourceRecords.map((record) => record.targetPort)
493
+
494
+ const busY = sourceFanOutLane(sourcePort, targetPorts, "y", travel)
495
+ for (const record of sourceRecords) {
496
+ routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("y", busY)))
497
+ handled.add(record.edge)
498
+ }
499
+ }
500
+ }
501
+
502
+ function routeVerticalFanIn(
503
+ records: readonly EdgeRecord[],
504
+ direction: FlowchartDirection,
505
+ handled: Set<FlowchartEdge>,
506
+ routes: FlowchartEdgeRoute[],
507
+ ): void {
508
+ const unhandledRecords = records.filter((record) => !handled.has(record.edge))
509
+ for (const unalignedTargetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
510
+ const targetRecords = alignClusteredVerticalSources(unalignedTargetRecords)
511
+ if (targetRecords.length < 2) continue
512
+ const travel = direction === "BT" ? "up" : "down"
513
+ const targetPort = targetRecords[0]!.targetPort
514
+ const sourcePorts = targetRecords.map((record) => record.sourcePort)
515
+
516
+ const busY = targetFanInLane(sourcePorts, targetPort, "y", travel)
517
+ for (const record of targetRecords) {
518
+ routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("y", busY)))
519
+ handled.add(record.edge)
520
+ }
521
+ }
522
+ }
523
+
524
+ function routeParallelEdges(
525
+ diagram: FlowchartDiagram,
526
+ bounds: Map<string, FlowchartNodeBounds>,
527
+ directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
528
+ directionAligned: boolean,
529
+ handled: Set<FlowchartEdge>,
530
+ routes: FlowchartEdgeRoute[],
531
+ ): void {
532
+ const groups = groupRecords(diagram.edges, (edge) => `${edge.from}:${edge.to}`)
533
+ for (const edges of groups.values()) {
534
+ if (edges.length < 2) continue
535
+ const from = bounds.get(edges[0]!.from)
536
+ const to = bounds.get(edges[0]!.to)
537
+ if (!from || !to) continue
538
+ if (from.id === to.id) {
539
+ let laneOffset = 0
540
+ for (const edge of edges) {
541
+ routes.push({ edge, points: selfEdgePath(from, laneOffset) })
542
+ handled.add(edge)
543
+ laneOffset++
544
+ }
545
+ continue
546
+ }
547
+ const parallelAxis =
548
+ directionAligned && isVerticalDirection(directionForEdge(edges[0]!)) ? "x" : parallelLaneAxis(from, to)
549
+ let previousRoute: FlowchartEdgeRoute | undefined
550
+ for (const edge of edges) {
551
+ const height = labelHeight(edge)
552
+ const laneCoordinate =
553
+ parallelAxis === "x"
554
+ ? previousRoute
555
+ ? rightRenderExtent(previousRoute) + NODE_CLEARANCE
556
+ : Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + (directionAligned ? 1 : 0)
557
+ : previousRoute
558
+ ? Math.max(...previousRoute.points.map((point) => point.y)) + (height > 1 ? height + 1 : 1)
559
+ : Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + (height > 1 ? height : 0)
560
+ const route: FlowchartEdgeRoute = {
561
+ edge,
562
+ points: parallelEdgePath(from, to, parallelAxis, laneCoordinate),
563
+ labelAxis: parallelAxis === "x" ? "y" : "x",
564
+ }
565
+ routes.push(route)
566
+ handled.add(edge)
567
+ previousRoute = route
568
+ }
569
+ }
570
+ }
571
+
572
+ function routeHorizontalSubgraphExitFanIn(
573
+ diagram: FlowchartDiagram,
574
+ bounds: Map<string, FlowchartNodeBounds>,
575
+ subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
576
+ handled: Set<FlowchartEdge>,
577
+ routes: FlowchartEdgeRoute[],
578
+ ): void {
579
+ if (!subgraphBounds) return
580
+
581
+ const groups = new Map<string, { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartNodeBounds }[]>()
582
+ for (const edge of diagram.edges) {
583
+ if (handled.has(edge)) continue
584
+ const subgraph = horizontalExitSubgraph(diagram, edge)
585
+ const source = bounds.get(edge.from)
586
+ const target = bounds.get(edge.to)
587
+ if (!subgraph || !source || !target) continue
588
+
589
+ const key = `${subgraph.id}:${edge.to}`
590
+ const group = groups.get(key) ?? []
591
+ group.push({ edge, subgraph, source })
592
+ groups.set(key, group)
593
+ }
594
+
595
+ for (const group of groups.values()) {
596
+ const subgraph = group[0]!.subgraph
597
+ const subgraphBound = subgraphBounds.get(subgraph.id)
598
+ const target = bounds.get(group[0]!.edge.to)
599
+ if (!subgraphBound || !target) continue
600
+
601
+ const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right"
602
+ const busX =
603
+ subgraph.direction === "RL"
604
+ ? subgraphBound.left - BUS_CLEARANCE
605
+ : subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
606
+ const targetSubgraph = horizontalEntrySubgraph(diagram, group[0]!.edge)
607
+ const targetSubgraphBound = targetSubgraph ? subgraphBounds.get(targetSubgraph.id) : undefined
608
+ const targetBelow = target.centerY >= subgraphBound.centerY
609
+ const targetPort = targetSubgraph
610
+ ? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target")
611
+ : boundsSidePoint(target, targetBelow ? "top" : "bottom")
612
+ const joinY = targetSubgraphBound
613
+ ? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound)
614
+ : horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow)
615
+ const entryX =
616
+ targetSubgraph && targetSubgraphBound
617
+ ? horizontalSubgraphEntryLane(targetSubgraph, targetSubgraphBound)
618
+ : targetPort.x
619
+
620
+ for (const record of group) {
621
+ const sourcePort = portForTravel(record.source, travel, "source")
622
+ routes.push({
623
+ edge: record.edge,
624
+ points: pathThrough([
625
+ sourcePort,
626
+ { x: busX, y: sourcePort.y },
627
+ { x: busX, y: joinY },
628
+ { x: entryX, y: joinY },
629
+ { x: entryX, y: targetPort.y },
630
+ targetPort,
631
+ ]),
632
+ })
633
+ handled.add(record.edge)
634
+ }
635
+ }
636
+ }
637
+
638
+ function routeHorizontalSubgraphEntries(
639
+ diagram: FlowchartDiagram,
640
+ bounds: Map<string, FlowchartNodeBounds>,
641
+ subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
642
+ handled: Set<FlowchartEdge>,
643
+ routes: FlowchartEdgeRoute[],
644
+ ): void {
645
+ if (!subgraphBounds) return
646
+
647
+ for (const edge of diagram.edges) {
648
+ if (handled.has(edge)) continue
649
+ const subgraph = horizontalEntrySubgraph(diagram, edge)
650
+ const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined
651
+ const from = bounds.get(edge.from)
652
+ const to = bounds.get(edge.to)
653
+ if (!subgraph || !subgraphBound || !from || !to) continue
654
+
655
+ const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target")
656
+ const entryX = horizontalSubgraphEntryLane(subgraph, subgraphBound)
657
+ const travel = verticalTravel(from, to)
658
+ const sourcePort = portForTravel(from, travel, "source")
659
+ routes.push({
660
+ edge,
661
+ points: pathThrough([sourcePort, { x: entryX, y: sourcePort.y }, { x: entryX, y: targetPort.y }, targetPort]),
662
+ })
663
+ handled.add(edge)
664
+ }
665
+ }
666
+
667
+ function pathIntersectsBounds(
668
+ points: readonly FlowchartPoint[],
669
+ bounds: { left: number; top: number; width: number; height: number },
670
+ allowedContact: "source" | "target" | "both" | undefined = undefined,
671
+ ): boolean {
672
+ const right = bounds.left + bounds.width - 1
673
+ const bottom = bounds.top + bounds.height - 1
674
+ for (let index = 1; index < points.length; index++) {
675
+ const from = points[index - 1]!
676
+ const to = points[index]!
677
+ if (from.x === to.x) {
678
+ if (from.x < bounds.left || from.x > right) continue
679
+ const overlapTop = Math.max(Math.min(from.y, to.y), bounds.top)
680
+ const overlapBottom = Math.min(Math.max(from.y, to.y), bottom)
681
+ if (overlapTop > overlapBottom) continue
682
+ const sourceContact =
683
+ (allowedContact === "source" || allowedContact === "both") &&
684
+ index === 1 &&
685
+ overlapTop === overlapBottom &&
686
+ from.x === points[0]!.x &&
687
+ overlapTop === points[0]!.y
688
+ const targetContact =
689
+ (allowedContact === "target" || allowedContact === "both") &&
690
+ index === points.length - 1 &&
691
+ overlapTop === overlapBottom &&
692
+ to.x === points.at(-1)!.x &&
693
+ overlapTop === points.at(-1)!.y
694
+ if (!sourceContact && !targetContact) return true
695
+ continue
696
+ }
697
+ if (from.y < bounds.top || from.y > bottom) continue
698
+ const overlapLeft = Math.max(Math.min(from.x, to.x), bounds.left)
699
+ const overlapRight = Math.min(Math.max(from.x, to.x), right)
700
+ if (overlapLeft > overlapRight) continue
701
+ const sourceContact =
702
+ (allowedContact === "source" || allowedContact === "both") &&
703
+ index === 1 &&
704
+ overlapLeft === overlapRight &&
705
+ overlapLeft === points[0]!.x &&
706
+ from.y === points[0]!.y
707
+ const targetContact =
708
+ (allowedContact === "target" || allowedContact === "both") &&
709
+ index === points.length - 1 &&
710
+ overlapLeft === overlapRight &&
711
+ overlapLeft === points.at(-1)!.x &&
712
+ to.y === points.at(-1)!.y
713
+ if (!sourceContact && !targetContact) return true
714
+ }
715
+ return false
716
+ }
717
+
718
+ function labelIntersectsBounds(
719
+ label: FlowchartEdgeLabelLayout | undefined,
720
+ bounds: { left: number; top: number; width: number; height: number },
721
+ ): boolean {
722
+ if (!label) return false
723
+ return (
724
+ label.point.x <= bounds.left + bounds.width - 1 &&
725
+ label.point.x + label.width - 1 >= bounds.left &&
726
+ label.point.y <= bounds.top + bounds.height - 1 &&
727
+ label.point.y + label.height - 1 >= bounds.top
728
+ )
729
+ }
730
+
731
+ function labelIntersectsSubgraphFrame(
732
+ label: FlowchartEdgeLabelLayout | undefined,
733
+ bounds: FlowchartSubgraphBounds,
734
+ ): boolean {
735
+ if (!label) return false
736
+ const labelRight = label.point.x + label.width - 1
737
+ const labelBottom = label.point.y + label.height - 1
738
+ const right = bounds.left + bounds.width - 1
739
+ const bottom = bounds.top + bounds.height - 1
740
+ return (
741
+ (label.point.x <= right &&
742
+ labelRight >= bounds.left &&
743
+ ((label.point.y <= bounds.top && labelBottom >= bounds.top) ||
744
+ (label.point.y <= bottom && labelBottom >= bottom))) ||
745
+ (label.point.y <= bottom &&
746
+ labelBottom >= bounds.top &&
747
+ ((label.point.x <= bounds.left && labelRight >= bounds.left) || (label.point.x <= right && labelRight >= right)))
748
+ )
749
+ }
750
+
751
+ function routeLength(route: FlowchartEdgeRoute): number {
752
+ let length = 0
753
+ for (let index = 1; index < route.points.length; index++) {
754
+ const from = route.points[index - 1]!
755
+ const to = route.points[index]!
756
+ length += Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
757
+ }
758
+ return length
759
+ }
760
+
761
+ function labelIntersectsLabels(
762
+ label: FlowchartEdgeLabelLayout | undefined,
763
+ otherLabels: readonly FlowchartEdgeLabelLayout[],
764
+ ): boolean {
765
+ if (!label) return false
766
+ return otherLabels.some((otherLabel) =>
767
+ labelIntersectsBounds(label, {
768
+ left: otherLabel.point.x,
769
+ top: otherLabel.point.y,
770
+ width: otherLabel.width,
771
+ height: otherLabel.height,
772
+ }),
773
+ )
774
+ }
775
+
776
+ function labelIntersectsRoutePaths(
777
+ label: FlowchartEdgeLabelLayout | undefined,
778
+ routes: readonly FlowchartEdgeRoute[],
779
+ ): boolean {
780
+ if (!label) return false
781
+ return label.lines.some((line, lineIndex) => {
782
+ const width = diagramTextWidth(line) - 2
783
+ if (width <= 0) return false
784
+ return routes.some((route) =>
785
+ pathIntersectsBounds(route.points, {
786
+ left: label.point.x + 1,
787
+ top: label.point.y + lineIndex,
788
+ width,
789
+ height: 1,
790
+ }),
791
+ )
792
+ })
793
+ }
794
+
795
+ function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly FlowchartEdgeLabelLayout[]): boolean {
796
+ return labels.some((label) =>
797
+ label.lines.some((line, lineIndex) => {
798
+ const width = diagramTextWidth(line) - 2
799
+ return (
800
+ width > 0 &&
801
+ pathIntersectsBounds(route.points, {
802
+ left: label.point.x + 1,
803
+ top: label.point.y + lineIndex,
804
+ width,
805
+ height: 1,
806
+ })
807
+ )
808
+ }),
809
+ )
810
+ }
811
+
812
+ function pathsIntersect(left: readonly FlowchartPoint[], right: readonly FlowchartPoint[]): boolean {
813
+ const occupied = new Set(orthogonalPathPoints(left).map((point) => `${point.x}:${point.y}`))
814
+ return orthogonalPathPoints(right).some((point) => occupied.has(`${point.x}:${point.y}`))
815
+ }
816
+
817
+ function endpointDisjoint(left: FlowchartEdge, right: FlowchartEdge): boolean {
818
+ return left.from !== right.from && left.from !== right.to && left.to !== right.from && left.to !== right.to
819
+ }
820
+
821
+ function endpointConflictsWithRoutes(route: FlowchartEdgeRoute, otherRoutes: readonly FlowchartEdgeRoute[]): boolean {
822
+ const source = route.points[0]
823
+ const target = route.points.at(-1)
824
+ if (!source || !target) return false
825
+ return otherRoutes.some((other) => {
826
+ const otherSource = other.points[0]
827
+ const otherTarget = other.points.at(-1)
828
+ return (
829
+ (otherSource && target.x === otherSource.x && target.y === otherSource.y) ||
830
+ (otherTarget && source.x === otherTarget.x && source.y === otherTarget.y)
831
+ )
832
+ })
833
+ }
834
+
835
+ function pathRunsAlongFrame(points: readonly FlowchartPoint[], bounds: FlowchartSubgraphBounds): boolean {
836
+ const right = bounds.left + bounds.width - 1
837
+ const bottom = bounds.top + bounds.height - 1
838
+ for (let index = 1; index < points.length; index++) {
839
+ const segment = segmentBetween(points[index - 1]!, points[index]!)
840
+ if (!segment) continue
841
+ const span = segmentSpan(segment)
842
+ if (
843
+ segment.axis === "x" &&
844
+ (segment.from.y === bounds.top || segment.from.y === bottom) &&
845
+ Math.min(span.end, right) > Math.max(span.start, bounds.left)
846
+ ) {
847
+ return true
848
+ }
849
+ if (
850
+ segment.axis === "y" &&
851
+ (segment.from.x === bounds.left || segment.from.x === right) &&
852
+ Math.min(span.end, bottom) > Math.max(span.start, bounds.top)
853
+ ) {
854
+ return true
855
+ }
856
+ }
857
+ return false
858
+ }
859
+
860
+ function subgraphTitleBounds(bounds: FlowchartSubgraphBounds): {
861
+ left: number
862
+ top: number
863
+ width: number
864
+ height: number
865
+ } {
866
+ const lines = splitDiagramLines(bounds.label)
867
+ return {
868
+ left: bounds.left + 2,
869
+ top: bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length,
870
+ width: Math.max(...lines.map((line) => diagramTextWidth(` ${line} `))),
871
+ height: lines.length,
872
+ }
873
+ }
874
+
875
+ function avoidNodeObstacles(
876
+ route: FlowchartEdgeRoute,
877
+ routes: readonly FlowchartEdgeRoute[],
878
+ bounds: Map<string, FlowchartNodeBounds>,
879
+ subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
880
+ routeIndex: number,
881
+ diagram: FlowchartDiagram,
882
+ ): FlowchartEdgeRoute {
883
+ const allNodeBounds = [...bounds.values()]
884
+ const allSubgraphBounds = [...(subgraphBounds?.values() ?? [])]
885
+ const subgraphs = diagram.subgraphs ?? []
886
+ const contains = (subgraph: FlowchartSubgraph, nodeId: string): boolean =>
887
+ subgraph.nodeIds.includes(nodeId) ||
888
+ subgraphs.some((child) => child.parentId === subgraph.id && contains(child, nodeId))
889
+ const owner = [...subgraphs].reverse().find((subgraph) => {
890
+ if (!contains(subgraph, route.edge.from) || !contains(subgraph, route.edge.to)) return false
891
+ const children = subgraphs.filter((child) => child.parentId === subgraph.id)
892
+ return (
893
+ children.some((child) => contains(child, route.edge.from)) &&
894
+ children.some((child) => contains(child, route.edge.to)) &&
895
+ !children.some((child) => contains(child, route.edge.from) && contains(child, route.edge.to))
896
+ )
897
+ })
898
+ const ownerBounds = owner ? subgraphBounds?.get(owner.id) : undefined
899
+ const leavesOwner = (candidate: FlowchartEdgeRoute): boolean =>
900
+ Boolean(
901
+ ownerBounds &&
902
+ candidate.points.some(
903
+ (point) =>
904
+ point.x <= ownerBounds.left ||
905
+ point.x >= ownerBounds.left + ownerBounds.width - 1 ||
906
+ point.y <= ownerBounds.top ||
907
+ point.y >= ownerBounds.top + ownerBounds.height - 1,
908
+ ),
909
+ )
910
+ const otherRoutes = routes.filter((_, index) => index !== routeIndex)
911
+ const otherLabels = otherRoutes.flatMap((otherRoute) =>
912
+ otherRoute.edge.label ? [flowchartRouteLabelLayout(otherRoute, diagramTextWidth)] : [],
913
+ )
914
+ const intersectsNode = (candidate: FlowchartEdgeRoute): boolean =>
915
+ allNodeBounds.some((bound) => {
916
+ const isSource = bound.id === route.edge.from
917
+ const isTarget = bound.id === route.edge.to
918
+ const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
919
+ return pathIntersectsBounds(candidate.points, bound, allowedContact)
920
+ })
921
+ const intersectsStructuralObstacle = (candidate: FlowchartEdgeRoute): boolean =>
922
+ intersectsNode(candidate) ||
923
+ allSubgraphBounds.some(
924
+ (bound) =>
925
+ pathRunsAlongFrame(candidate.points, bound) ||
926
+ (bound.label.length > 0 && pathIntersectsBounds(candidate.points, subgraphTitleBounds(bound))),
927
+ )
928
+ const intersectsRoutingObstacle = (candidate: FlowchartEdgeRoute): boolean =>
929
+ intersectsStructuralObstacle(candidate) ||
930
+ endpointConflictsWithRoutes(candidate, otherRoutes) ||
931
+ otherRoutes.some(
932
+ (other) => endpointDisjoint(candidate.edge, other.edge) && pathsIntersect(candidate.points, other.points),
933
+ )
934
+ const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => {
935
+ const label = candidate.edge.label ? flowchartRouteLabelLayout(candidate, diagramTextWidth) : undefined
936
+ return (
937
+ leavesOwner(candidate) ||
938
+ intersectsRoutingObstacle(candidate) ||
939
+ allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
940
+ allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
941
+ labelIntersectsLabels(label, otherLabels) ||
942
+ labelIntersectsRoutePaths(label, otherRoutes) ||
943
+ routeIntersectsLabels(candidate, otherLabels)
944
+ )
945
+ }
946
+ if (!intersectsObstacle(route)) return route
947
+
948
+ const from = bounds.get(route.edge.from)
949
+ const to = bounds.get(route.edge.to)
950
+ if (!from || !to) return route
951
+ const routingBounds = [...allNodeBounds, ...allSubgraphBounds]
952
+ const rightBusX = Math.max(...routingBounds.map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
953
+ const leftBusX = Math.min(...routingBounds.map((bound) => bound.left)) - BUS_CLEARANCE
954
+ const topBusY = Math.min(...routingBounds.map((bound) => bound.top)) - BUS_CLEARANCE
955
+ const bottomBusY = Math.max(...routingBounds.map((bound) => bound.top + bound.height - 1)) + BUS_CLEARANCE
956
+ const rightBusXs = [
957
+ ...new Set([
958
+ rightBusX,
959
+ ...(ownerBounds ? [ownerBounds.left + ownerBounds.width - 2] : []),
960
+ ...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + NODE_CLEARANCE)),
961
+ ]),
962
+ ].sort((left, right) => left - right)
963
+ const leftBusXs = [
964
+ ...new Set([
965
+ leftBusX,
966
+ ...(ownerBounds ? [ownerBounds.left + 1] : []),
967
+ ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - NODE_CLEARANCE)),
968
+ ]),
969
+ ].sort((left, right) => right - left)
970
+ const topBusYs = [
971
+ ...new Set([
972
+ topBusY,
973
+ ...(ownerBounds ? [ownerBounds.top + 1] : []),
974
+ ...otherLabels.map((label) => Math.min(topBusY, label.point.y - NODE_CLEARANCE)),
975
+ ]),
976
+ ].sort((left, right) => right - left)
977
+ const bottomBusYs = [
978
+ ...new Set([
979
+ bottomBusY,
980
+ ...(ownerBounds ? [ownerBounds.top + ownerBounds.height - 2] : []),
981
+ ...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + NODE_CLEARANCE)),
982
+ ]),
983
+ ].sort((left, right) => left - right)
984
+ const busLimit = Math.max(1, Math.floor(Math.sqrt(ROUTING_CANDIDATE_BUDGET / 4)))
985
+ const candidateLeftBusXs = leftBusXs.length > busLimit ? leftBusXs.slice(0, busLimit) : leftBusXs
986
+ const candidateRightBusXs = rightBusXs.length > busLimit ? rightBusXs.slice(0, busLimit) : rightBusXs
987
+ const candidateTopBusYs = topBusYs.length > busLimit ? topBusYs.slice(0, busLimit) : topBusYs
988
+ const candidateBottomBusYs = bottomBusYs.length > busLimit ? bottomBusYs.slice(0, busLimit) : bottomBusYs
989
+ const buses = [
990
+ ...candidateLeftBusXs.map((coordinate) => lane("x", coordinate)),
991
+ ...candidateRightBusXs.map((coordinate) => lane("x", coordinate)),
992
+ ...candidateTopBusYs.map((coordinate) => lane("y", coordinate)),
993
+ ...candidateBottomBusYs.map((coordinate) => lane("y", coordinate)),
994
+ ]
995
+ const routeViaBus = (start: FlowchartPoint, targetSide: DiagramSide, bus: DiagramLane): FlowchartEdgeRoute => {
996
+ const end = boundsSidePoint(to, targetSide)
997
+ const approach = shiftPoint(
998
+ end,
999
+ targetSide === "left" ? "left" : targetSide === "right" ? "right" : targetSide === "top" ? "up" : "down",
1000
+ )
1001
+ return {
1002
+ ...route,
1003
+ labelAxis: route.labelAxis === undefined ? undefined : bus.axis === "x" ? "y" : "x",
1004
+ points:
1005
+ bus.axis === "x"
1006
+ ? pathThrough([start, { x: bus.coordinate, y: start.y }, { x: bus.coordinate, y: approach.y }, approach, end])
1007
+ : pathThrough([
1008
+ start,
1009
+ { x: start.x, y: bus.coordinate },
1010
+ { x: approach.x, y: bus.coordinate },
1011
+ approach,
1012
+ end,
1013
+ ]),
1014
+ }
1015
+ }
1016
+ const selfLoops =
1017
+ from.id !== to.id
1018
+ ? []
1019
+ : [
1020
+ ...candidateRightBusXs.flatMap((busX) =>
1021
+ candidateBottomBusYs.map(
1022
+ (busY): FlowchartEdgeRoute => ({
1023
+ ...route,
1024
+ points: pathThrough([
1025
+ boundsSidePoint(from, "right"),
1026
+ { x: busX, y: from.centerY },
1027
+ { x: busX, y: busY },
1028
+ { x: from.centerX, y: busY },
1029
+ boundsSidePoint(from, "bottom"),
1030
+ ]),
1031
+ }),
1032
+ ),
1033
+ ),
1034
+ ...candidateBottomBusYs.flatMap((busY) =>
1035
+ candidateLeftBusXs.map(
1036
+ (busX): FlowchartEdgeRoute => ({
1037
+ ...route,
1038
+ points: pathThrough([
1039
+ boundsSidePoint(from, "bottom"),
1040
+ { x: from.centerX, y: busY },
1041
+ { x: busX, y: busY },
1042
+ { x: busX, y: from.centerY },
1043
+ boundsSidePoint(from, "left"),
1044
+ ]),
1045
+ }),
1046
+ ),
1047
+ ),
1048
+ ...candidateLeftBusXs.flatMap((busX) =>
1049
+ candidateTopBusYs.map(
1050
+ (busY): FlowchartEdgeRoute => ({
1051
+ ...route,
1052
+ points: pathThrough([
1053
+ boundsSidePoint(from, "left"),
1054
+ { x: busX, y: from.centerY },
1055
+ { x: busX, y: busY },
1056
+ { x: from.centerX, y: busY },
1057
+ boundsSidePoint(from, "top"),
1058
+ ]),
1059
+ }),
1060
+ ),
1061
+ ),
1062
+ ...candidateTopBusYs.flatMap((busY) =>
1063
+ candidateRightBusXs.map(
1064
+ (busX): FlowchartEdgeRoute => ({
1065
+ ...route,
1066
+ points: pathThrough([
1067
+ boundsSidePoint(from, "top"),
1068
+ { x: from.centerX, y: busY },
1069
+ { x: busX, y: busY },
1070
+ { x: busX, y: from.centerY },
1071
+ boundsSidePoint(from, "right"),
1072
+ ]),
1073
+ }),
1074
+ ),
1075
+ ),
1076
+ ]
1077
+ const targetSides = ["left", "right", "top", "bottom"] satisfies DiagramSide[]
1078
+ const shortest = (candidates: FlowchartEdgeRoute[], accept: (candidate: FlowchartEdgeRoute) => boolean) =>
1079
+ candidates.filter(accept).sort((left, right) => routeLength(left) - routeLength(right))[0]
1080
+ if (from.id === to.id)
1081
+ return (
1082
+ shortest(selfLoops, (candidate) => !intersectsObstacle(candidate)) ??
1083
+ shortest(selfLoops, (candidate) => !intersectsStructuralObstacle(candidate)) ??
1084
+ route
1085
+ )
1086
+ const currentTargetSide = sideForOutsidePoint(to, route.points.at(-1)!)
1087
+ const preservedTargets = buses.map((bus) => routeViaBus(route.points[0]!, currentTargetSide, bus))
1088
+ const sameSides: FlowchartEdgeRoute[] = [
1089
+ ...candidateRightBusXs.map(
1090
+ (busX): FlowchartEdgeRoute => ({
1091
+ ...route,
1092
+ labelAxis: route.labelAxis === undefined ? undefined : "y",
1093
+ points: pathViaLane(boundsSidePoint(from, "right"), lane("x", busX), boundsSidePoint(to, "right")),
1094
+ }),
1095
+ ),
1096
+ ...candidateLeftBusXs.map(
1097
+ (busX): FlowchartEdgeRoute => ({
1098
+ ...route,
1099
+ labelAxis: route.labelAxis === undefined ? undefined : "y",
1100
+ points: pathViaLane(boundsSidePoint(from, "left"), lane("x", busX), boundsSidePoint(to, "left")),
1101
+ }),
1102
+ ),
1103
+ ...candidateTopBusYs.map(
1104
+ (busY): FlowchartEdgeRoute => ({
1105
+ ...route,
1106
+ labelAxis: route.labelAxis === undefined ? undefined : "x",
1107
+ points: pathViaLane(boundsSidePoint(from, "top"), lane("y", busY), boundsSidePoint(to, "top")),
1108
+ }),
1109
+ ),
1110
+ ...candidateBottomBusYs.map(
1111
+ (busY): FlowchartEdgeRoute => ({
1112
+ ...route,
1113
+ labelAxis: route.labelAxis === undefined ? undefined : "x",
1114
+ points: pathViaLane(boundsSidePoint(from, "bottom"), lane("y", busY), boundsSidePoint(to, "bottom")),
1115
+ }),
1116
+ ),
1117
+ ]
1118
+ const preservedSources = targetSides.flatMap((targetSide) =>
1119
+ buses.map((bus) => routeViaBus(route.points[0]!, targetSide, bus)),
1120
+ )
1121
+ const attachments = targetSides.flatMap((sourceSide) =>
1122
+ targetSides.flatMap((targetSide) =>
1123
+ buses.map((bus) => routeViaBus(boundsSidePoint(from, sourceSide), targetSide, bus)),
1124
+ ),
1125
+ )
1126
+ if (subgraphBounds) {
1127
+ return (
1128
+ shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ??
1129
+ shortest(sameSides, (candidate) => !intersectsObstacle(candidate)) ??
1130
+ shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
1131
+ shortest(attachments, (candidate) => !intersectsObstacle(candidate)) ??
1132
+ shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
1133
+ shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
1134
+ route
1135
+ )
1136
+ }
1137
+ return (
1138
+ sameSides.find((candidate) => !intersectsObstacle(candidate)) ??
1139
+ shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ??
1140
+ attachments.find((candidate) => !intersectsObstacle(candidate)) ??
1141
+ shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
1142
+ shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
1143
+ shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
1144
+ route
1145
+ )
1146
+ }
1147
+
1148
+ function avoidLabelOverlap(
1149
+ route: FlowchartEdgeRoute,
1150
+ otherRoutes: readonly FlowchartEdgeRoute[],
1151
+ bounds: ReadonlyMap<string, FlowchartNodeBounds>,
1152
+ subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
1153
+ targetWidth?: number,
1154
+ includeLabelWidth = true,
1155
+ ): FlowchartEdgeRoute {
1156
+ if (!route.edge.label) return route
1157
+ const nodeBounds = [...bounds.values()]
1158
+ const frameBounds = [...(subgraphBounds?.values() ?? [])]
1159
+ const otherLabels = otherRoutes.flatMap((other) =>
1160
+ other.edge.label ? [flowchartRouteLabelLayout(other, diagramTextWidth)] : [],
1161
+ )
1162
+ const otherConnectorBounds = otherRoutes.flatMap((other) => {
1163
+ const source = bounds.get(other.edge.from)
1164
+ const sourcePoint = other.points[0]
1165
+ if (!source || !sourcePoint) return []
1166
+ const connector = flowchartSourceConnector(source, sourcePoint)
1167
+ return [
1168
+ { left: connector.x, top: connector.y, width: 1, height: 1 },
1169
+ { left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 },
1170
+ ]
1171
+ })
1172
+ const hasParallelRoute = otherRoutes.some(
1173
+ (other) => other.edge.from === route.edge.from && other.edge.to === route.edge.to,
1174
+ )
1175
+ const intersectsObstacle = (label: FlowchartEdgeLabelLayout): boolean =>
1176
+ (targetWidth !== undefined &&
1177
+ (hasParallelRoute || !includeLabelWidth
1178
+ ? label.point.x > targetWidth
1179
+ : label.point.x + label.width > targetWidth)) ||
1180
+ nodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
1181
+ frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
1182
+ labelIntersectsLabels(label, otherLabels) ||
1183
+ labelIntersectsRoutePaths(label, otherRoutes) ||
1184
+ otherConnectorBounds.some((bound) => labelIntersectsBounds(label, bound))
1185
+ const current = flowchartRouteLabelLayout(route, diagramTextWidth)
1186
+ if (!intersectsObstacle(current)) return route
1187
+
1188
+ const seen = new Set<string>()
1189
+ let remaining = ROUTING_CANDIDATE_BUDGET
1190
+ const available = (candidate: FlowchartPoint) => {
1191
+ remaining--
1192
+ const key = `${candidate.x}:${candidate.y}`
1193
+ if (seen.has(key)) return false
1194
+ seen.add(key)
1195
+ return !intersectsObstacle({ ...current, point: candidate })
1196
+ }
1197
+ for (let index = 1; index < route.points.length && remaining > 0; index++) {
1198
+ const segment = segmentBetween(route.points[index - 1]!, route.points[index]!)
1199
+ if (!segment) continue
1200
+ const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth, route.labelAxis, index - 1)
1201
+ const fixed =
1202
+ segment.axis === "y"
1203
+ ? [label.point, { x: segment.from.x - label.width, y: label.point.y }]
1204
+ : [
1205
+ label.point,
1206
+ { x: label.point.x, y: segment.from.y - label.height },
1207
+ { x: label.point.x, y: segment.from.y + 1 },
1208
+ ]
1209
+ for (const candidate of fixed) {
1210
+ if (remaining <= 0) return route
1211
+ if (available(candidate)) return { ...route, labelPoint: candidate }
1212
+ }
1213
+ if (segment.axis === "y") {
1214
+ const bottom = Math.max(segment.from.y, segment.to.y) - label.height + 1
1215
+ for (let y = Math.min(segment.from.y, segment.to.y); y <= bottom && remaining > 0; y++) {
1216
+ for (const x of [segment.from.x + 1, segment.from.x - label.width]) {
1217
+ const candidate = { x, y }
1218
+ if (available(candidate)) return { ...route, labelPoint: candidate }
1219
+ if (remaining <= 0) return route
1220
+ }
1221
+ }
1222
+ continue
1223
+ }
1224
+ const right = Math.max(segment.from.x, segment.to.x) - label.width + 1
1225
+ for (let x = Math.min(segment.from.x, segment.to.x); x <= right && remaining > 0; x++) {
1226
+ for (const y of [segment.from.y, segment.from.y - label.height, segment.from.y + 1]) {
1227
+ const candidate = { x, y }
1228
+ if (available(candidate)) return { ...route, labelPoint: candidate }
1229
+ if (remaining <= 0) return route
1230
+ }
1231
+ }
1232
+ }
1233
+ if (targetWidth !== undefined && includeLabelWidth && current.point.x + current.width > targetWidth) {
1234
+ const x = Math.max(0, targetWidth - current.width)
1235
+ for (let distance = 0; distance < 100; distance++) {
1236
+ for (const y of distance === 0 ? [current.point.y] : [current.point.y - distance, current.point.y + distance]) {
1237
+ if (y < 0 || intersectsObstacle({ ...current, point: { x, y } })) continue
1238
+ return { ...route, labelPoint: { x, y } }
1239
+ }
1240
+ }
1241
+ }
1242
+ return route
1243
+ }
1244
+
1245
+ export function routeFlowchartEdges(
1246
+ diagram: FlowchartDiagram,
1247
+ bounds: Map<string, FlowchartNodeBounds>,
1248
+ directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
1249
+ subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
1250
+ targetWidth?: number,
1251
+ directionAligned = false,
1252
+ ): FlowchartEdgeRoute[] {
1253
+ const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
1254
+ const handled = new Set<FlowchartEdge>()
1255
+ const routes: FlowchartEdgeRoute[] = []
1256
+ const leftBoundary = subgraphBounds
1257
+ ? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
1258
+ : undefined
1259
+
1260
+ routeParallelEdges(routedDiagram, bounds, directionForEdge, directionAligned, handled, routes)
1261
+
1262
+ for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
1263
+ const horizontalEdges = routedDiagram.edges.filter(
1264
+ (edge) => !handled.has(edge) && directionForEdge(edge) === direction,
1265
+ )
1266
+ if (horizontalEdges.length === 0) continue
1267
+ const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
1268
+ routeHorizontalFanOut(records, bounds, direction, handled, routes)
1269
+ routeHorizontalFanIn(records, direction, handled, routes)
1270
+ }
1271
+
1272
+ routeHorizontalSubgraphExitFanIn(routedDiagram, bounds, subgraphBounds, handled, routes)
1273
+ routeHorizontalSubgraphEntries(routedDiagram, bounds, subgraphBounds, handled, routes)
1274
+
1275
+ for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) {
1276
+ const verticalEdges = routedDiagram.edges.filter(
1277
+ (edge) => !handled.has(edge) && directionForEdge(edge) === direction,
1278
+ )
1279
+ if (verticalEdges.length === 0) continue
1280
+ const records = verticalForwardRecords(verticalEdges, bounds, direction)
1281
+ routeVerticalFanOut(records, direction, handled, routes)
1282
+ routeVerticalFanIn(records, direction, handled, routes)
1283
+ }
1284
+
1285
+ for (const edge of routedDiagram.edges) {
1286
+ if (handled.has(edge)) continue
1287
+ const from = bounds.get(edge.from)
1288
+ const to = bounds.get(edge.to)
1289
+ if (!from || !to) continue
1290
+ routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
1291
+ }
1292
+ for (let index = routes.length - 1; index >= 0; index--) {
1293
+ routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index, routedDiagram)
1294
+ }
1295
+ const subgraphs = diagram.subgraphs ?? []
1296
+ const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
1297
+ const containers = (id: string) => {
1298
+ const ids = new Set<string>()
1299
+ let current = subgraphs.find((subgraph) => subgraph.nodeIds.includes(id))
1300
+ while (current) {
1301
+ ids.add(current.id)
1302
+ current = current.parentId ? subgraphById.get(current.parentId) : undefined
1303
+ }
1304
+ return ids
1305
+ }
1306
+ const groupedLabelEdge = (edge: FlowchartEdge) => {
1307
+ const fromContainers = containers(edge.from)
1308
+ if (![...containers(edge.to)].some((id) => fromContainers.has(id))) return false
1309
+ const targets = new Set(
1310
+ routedDiagram.edges
1311
+ .filter((candidate) => candidate.label && candidate.from === edge.from)
1312
+ .map((candidate) => candidate.to),
1313
+ )
1314
+ const sources = new Set(
1315
+ routedDiagram.edges
1316
+ .filter((candidate) => candidate.label && candidate.to === edge.to)
1317
+ .map((candidate) => candidate.from),
1318
+ )
1319
+ return targets.size > 1 || sources.size > 1
1320
+ }
1321
+ return routes.reduce<FlowchartEdgeRoute[]>((resolved, route, index) => {
1322
+ const grouped = groupedLabelEdge(route.edge)
1323
+ return [
1324
+ ...resolved,
1325
+ avoidLabelOverlap(
1326
+ route,
1327
+ [...resolved, ...routes.slice(index + 1)],
1328
+ bounds,
1329
+ subgraphBounds,
1330
+ targetWidth !== undefined && subgraphs.length > 0 && grouped ? Math.max(1, targetWidth - 5) : targetWidth,
1331
+ subgraphs.length === 0 || grouped,
1332
+ ),
1333
+ ]
1334
+ }, [])
1335
+ }
1336
+
1337
+ export function avoidFlowchartFrameBorders(
1338
+ routes: readonly FlowchartEdgeRoute[],
1339
+ bounds: ReadonlyMap<string, FlowchartNodeBounds>,
1340
+ subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
1341
+ ): void {
1342
+ const inside = (node: FlowchartNodeBounds, frame: FlowchartSubgraphBounds) =>
1343
+ node.left >= frame.left &&
1344
+ node.top >= frame.top &&
1345
+ node.left + node.width <= frame.left + frame.width &&
1346
+ node.top + node.height <= frame.top + frame.height
1347
+
1348
+ for (const route of routes) {
1349
+ const source = bounds.get(route.edge.from)
1350
+ const target = bounds.get(route.edge.to)
1351
+ for (const frame of subgraphBounds.values()) {
1352
+ const inward = Boolean(source && target && inside(source, frame) && inside(target, frame))
1353
+ const right = frame.left + frame.width - 1
1354
+ const bottom = frame.top + frame.height - 1
1355
+ const points: FlowchartPoint[] = [route.points[0]!]
1356
+ for (let index = 1; index < route.points.length; index++) {
1357
+ const from = route.points[index - 1]!
1358
+ const to = route.points[index]!
1359
+ const segment = segmentBetween(from, to)
1360
+ if (!segment) continue
1361
+ const span = segmentSpan(segment)
1362
+ const horizontalSide =
1363
+ segment.axis === "x" && Math.min(span.end, right) > Math.max(span.start, frame.left)
1364
+ ? segment.from.y === frame.top
1365
+ ? "top"
1366
+ : segment.from.y === bottom
1367
+ ? "bottom"
1368
+ : undefined
1369
+ : undefined
1370
+ const verticalSide =
1371
+ segment.axis === "y" && Math.min(span.end, bottom) > Math.max(span.start, frame.top)
1372
+ ? segment.from.x === frame.left
1373
+ ? "left"
1374
+ : segment.from.x === right
1375
+ ? "right"
1376
+ : undefined
1377
+ : undefined
1378
+ if (!horizontalSide && !verticalSide) {
1379
+ points.push(to)
1380
+ continue
1381
+ }
1382
+
1383
+ const offset = horizontalSide
1384
+ ? horizontalSide === "top"
1385
+ ? frame.top + (inward ? 1 : -1)
1386
+ : bottom + (inward ? -1 : 1)
1387
+ : verticalSide === "left"
1388
+ ? frame.left + (inward ? 1 : -1)
1389
+ : right + (inward ? -1 : 1)
1390
+ if (horizontalSide) points.push({ x: from.x, y: offset }, { x: to.x, y: offset }, to)
1391
+ else points.push({ x: offset, y: from.y }, { x: offset, y: to.y }, to)
1392
+ }
1393
+ route.points = pathThrough(points)
1394
+ }
1395
+ }
1396
+ }
1397
+
1398
+ function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
1399
+ if (sourcePoint.x < bounds.left) return "left"
1400
+ if (sourcePoint.x >= bounds.left + bounds.width) return "right"
1401
+ if (sourcePoint.y < bounds.top) return "top"
1402
+ return "bottom"
1403
+ }
1404
+
1405
+ function connectorChar(side: DiagramSide): string {
1406
+ switch (side) {
1407
+ case "left":
1408
+ return "┤"
1409
+ case "right":
1410
+ return "├"
1411
+ case "top":
1412
+ return "┴"
1413
+ case "bottom":
1414
+ return "┬"
1415
+ }
1416
+ }
1417
+
1418
+ export function flowchartSourceConnector(
1419
+ from: FlowchartNodeBounds,
1420
+ sourcePoint: FlowchartPoint,
1421
+ ): { x: number; y: number; char: string } {
1422
+ const side = sideForOutsidePoint(from, sourcePoint)
1423
+ const connector = boundsSidePoint(from, side, "border")
1424
+ return {
1425
+ x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
1426
+ y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
1427
+ char: connectorChar(side),
1428
+ }
1429
+ }