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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +107 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- 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
|
+
}
|