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,1522 @@
1
+ import { BorderChars } from "@opentui/core"
2
+ import { diagramLineGlyph } from "../core/drawing.js"
3
+ import { orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
4
+ import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
5
+ import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
6
+ import type { StateDiagramBoxBounds as BoxBounds, StateDiagramNoteBounds } from "./layout.js"
7
+ import { stateDiagramNoteConnector } from "./note.js"
8
+ import {
9
+ createStateSearchBudget,
10
+ createStateSearchSpace,
11
+ findStateManhattanPath,
12
+ type StateSearchBudget,
13
+ type StateSearchSpace,
14
+ } from "./search.js"
15
+ import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
16
+ import { isHiddenCompositeMarker, type StateVisibleDiagram, type StateVisibleTransition } from "./visible-model.js"
17
+
18
+ interface StateTransitionRoutePlanBase {
19
+ transition: StateVisibleTransition
20
+ from: BoxBounds
21
+ to: BoxBounds
22
+ targetIsChoice: boolean
23
+ targetIsHiddenMarker: boolean
24
+ }
25
+
26
+ export type StateTransitionRoutePlan =
27
+ | (StateTransitionRoutePlanBase & { kind: "self"; lane: number })
28
+ | (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
29
+ | (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number; approachX: number })
30
+ | (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
31
+ | (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number; approachX: number })
32
+ | (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
33
+ | (StateTransitionRoutePlanBase & {
34
+ kind: "side-parallel"
35
+ railX: number
36
+ targetApproach?: "top" | "bottom"
37
+ })
38
+ | (StateTransitionRoutePlanBase & { kind: "vertical" })
39
+
40
+ export type StateTransitionPathPoint = readonly [number, number]
41
+
42
+ interface StateTransitionRenderCellBase {
43
+ x: number
44
+ y: number
45
+ }
46
+
47
+ export type StateTransitionRenderCell = StateTransitionRenderCellBase &
48
+ ({ char: string; arrowDirection?: never } | { char?: never; arrowDirection: DiagramDirection })
49
+
50
+ export interface StateTransitionRenderLabel {
51
+ x: number
52
+ y: number
53
+ lines: readonly string[]
54
+ }
55
+
56
+ export interface StateTransitionRenderPlan {
57
+ route: StateTransitionRoutePlan
58
+ cells: readonly StateTransitionRenderCell[]
59
+ path: readonly StateTransitionPathPoint[]
60
+ label?: StateTransitionRenderLabel
61
+ pathRepaired?: boolean
62
+ }
63
+
64
+ export interface StateTransitionRenderOptions {
65
+ feedbackTopY?: number
66
+ noteBounds?: readonly StateDiagramNoteBounds[]
67
+ repairRoutes?: boolean
68
+ searchBudget?: StateSearchBudget
69
+ }
70
+
71
+ export interface StateTransitionJunctionPlan {
72
+ state: StateDiagramState
73
+ bounds: BoxBounds
74
+ connections: ReadonlySet<DiagramDirection>
75
+ transitions: readonly StateVisibleTransition[]
76
+ kind: "choice" | "fork" | "join" | "hidden-composite-marker"
77
+ }
78
+
79
+ interface StateTransitionRenderBuilder {
80
+ route: StateTransitionRoutePlan
81
+ cells: StateTransitionRenderCell[]
82
+ path: StateTransitionPathPoint[]
83
+ label?: StateTransitionRenderLabel
84
+ }
85
+
86
+ export function measureStateTransitionLabel(label: string): { lines: string[]; width: number; height: number } {
87
+ if (!label) return { lines: [], width: 0, height: 0 }
88
+ const lines = splitDiagramLines(label)
89
+ return { lines, width: Math.max(...lines.map(diagramTextWidth)), height: lines.length }
90
+ }
91
+
92
+ export function hasReverseTransition(diagram: StateDiagram, transition: StateDiagramTransition): boolean {
93
+ return diagram.transitions.some((other) => other.from === transition.to && other.to === transition.from)
94
+ }
95
+
96
+ export function isStateHorizontalFeedback(
97
+ diagram: Pick<StateDiagram, "direction">,
98
+ from: BoxBounds,
99
+ to: BoxBounds,
100
+ ): boolean {
101
+ if (diagram.direction === "RL") return to.centerX > from.centerX
102
+ return to.centerX < from.centerX
103
+ }
104
+
105
+ interface FeedbackAllocation {
106
+ side: "bottom" | "top"
107
+ railY: number
108
+ }
109
+
110
+ interface AllocatedFeedbackInterval extends FeedbackAllocation {
111
+ left: number
112
+ right: number
113
+ lane: number
114
+ }
115
+
116
+ interface FeedbackInterval {
117
+ transition: StateVisibleTransition
118
+ left: number
119
+ right: number
120
+ side?: "bottom" | "top"
121
+ }
122
+
123
+ function feedbackIntervalsOverlap(
124
+ left: { left: number; right: number },
125
+ right: { left: number; right: number },
126
+ ): boolean {
127
+ return left.left <= right.right && right.left <= left.right
128
+ }
129
+
130
+ function feedbackIntervalsCross(
131
+ left: { left: number; right: number },
132
+ right: { left: number; right: number },
133
+ ): boolean {
134
+ return (
135
+ (left.left < right.left && right.left < left.right && left.right < right.right) ||
136
+ (right.left < left.left && left.left < right.right && right.right < left.right)
137
+ )
138
+ }
139
+
140
+ function createFeedbackAllocations(
141
+ diagram: StateVisibleDiagram,
142
+ bounds: ReadonlyMap<string, BoxBounds>,
143
+ feedbackLaneY: number,
144
+ laneGap: number,
145
+ feedbackTopY?: number,
146
+ ): ReadonlyMap<StateVisibleTransition, FeedbackAllocation> {
147
+ if (diagram.direction !== "LR" && diagram.direction !== "RL") return new Map()
148
+ const allocations = new Map<StateVisibleTransition, FeedbackAllocation>()
149
+ const sidedIntervals: Record<"bottom" | "top", FeedbackInterval[]> = { bottom: [], top: [] }
150
+ const canonicalSides = new Map<string, "bottom" | "top">()
151
+ const topLaneY = feedbackTopY ?? Math.min(...[...bounds.values()].map((bound) => bound.top)) - 3
152
+ const intervals: FeedbackInterval[] = []
153
+
154
+ for (const transition of diagram.transitions) {
155
+ const from = bounds.get(transition.from)
156
+ const to = bounds.get(transition.to)
157
+ if (!from || !to || transition.from === transition.to || !isStateHorizontalFeedback(diagram, from, to)) continue
158
+ if (from.centerY !== to.centerY && !(from.centerY > to.centerY)) continue
159
+
160
+ intervals.push({ transition, left: Math.min(from.centerX, to.centerX), right: Math.max(from.centerX, to.centerX) })
161
+ }
162
+
163
+ for (const interval of intervals) {
164
+ const endpointKey = `${interval.transition.from}\u0000${interval.transition.to}`
165
+ const side =
166
+ canonicalSides.get(endpointKey) ??
167
+ (["bottom", "top"] as const).find(
168
+ (candidate) => !sidedIntervals[candidate].some((existing) => feedbackIntervalsCross(existing, interval)),
169
+ )
170
+ if (!side) continue
171
+ canonicalSides.set(endpointKey, side)
172
+ interval.side = side
173
+ sidedIntervals[side].push(interval)
174
+ }
175
+
176
+ for (const side of ["bottom", "top"] as const) {
177
+ const occupied: AllocatedFeedbackInterval[] = []
178
+ const intervalsByWidth = [...sidedIntervals[side]].sort(
179
+ (left, right) => left.right - left.left - (right.right - right.left),
180
+ )
181
+ for (const interval of intervalsByWidth) {
182
+ let lane = 0
183
+ while (occupied.some((existing) => existing.lane === lane && feedbackIntervalsOverlap(existing, interval))) lane++
184
+ const railY = side === "bottom" ? feedbackLaneY + lane * laneGap : topLaneY - lane * laneGap
185
+ occupied.push({ ...interval, side, lane, railY })
186
+ allocations.set(interval.transition, { side, railY })
187
+ }
188
+ }
189
+
190
+ return allocations
191
+ }
192
+
193
+ function hasOpposingTopConnector(
194
+ diagram: StateVisibleDiagram,
195
+ transition: StateVisibleTransition,
196
+ bounds: ReadonlyMap<string, BoxBounds>,
197
+ ): boolean {
198
+ const from = bounds.get(transition.from)
199
+ const to = bounds.get(transition.to)
200
+ if (!from || !to || from.centerY === to.centerY) return false
201
+
202
+ const lowerId = from.centerY > to.centerY ? transition.from : transition.to
203
+ const lower = bounds.get(lowerId)!
204
+ const leavesLower = transition.from === lowerId
205
+ return diagram.transitions.some((other) => {
206
+ if (other === transition || (leavesLower ? other.to !== lowerId : other.from !== lowerId)) return false
207
+ const otherFrom = bounds.get(other.from)
208
+ const otherTo = bounds.get(other.to)
209
+ if (!otherFrom || !otherTo || Math.max(otherFrom.centerY, otherTo.centerY) !== lower.centerY) return false
210
+ if (other.from !== lowerId) return true
211
+ return !(
212
+ (diagram.direction === "LR" || diagram.direction === "RL") &&
213
+ isStateHorizontalFeedback(diagram, otherFrom, otherTo)
214
+ )
215
+ })
216
+ }
217
+
218
+ function verticalCorridorCrossesUnrelatedState(
219
+ diagram: StateVisibleDiagram,
220
+ transition: StateVisibleTransition,
221
+ from: BoxBounds,
222
+ to: BoxBounds,
223
+ bounds: ReadonlyMap<string, BoxBounds>,
224
+ ): boolean {
225
+ const top = Math.min(from.top + from.height, to.top + to.height)
226
+ const bottom = Math.max(from.top - 1, to.top - 1)
227
+ return diagram.states.some((state) => {
228
+ if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return false
229
+ const bound = bounds.get(state.id)
230
+ return Boolean(
231
+ bound &&
232
+ from.centerX >= bound.left &&
233
+ from.centerX < bound.left + bound.width &&
234
+ top < bound.top + bound.height &&
235
+ bottom >= bound.top,
236
+ )
237
+ })
238
+ }
239
+
240
+ function hasVerticalCorridor(from: BoxBounds, to: BoxBounds): boolean {
241
+ if (from.centerY < to.centerY) return from.top + from.height <= to.top - 1
242
+ return from.top - 1 >= to.top + to.height
243
+ }
244
+
245
+ function horizontalCorridorCrossesUnrelatedState(
246
+ diagram: StateVisibleDiagram,
247
+ transition: StateVisibleTransition,
248
+ from: BoxBounds,
249
+ to: BoxBounds,
250
+ bounds: ReadonlyMap<string, BoxBounds>,
251
+ ): boolean {
252
+ const leftToRight = from.centerX <= to.centerX
253
+ const startX = leftToRight ? from.left + from.width : from.left - 1
254
+ const endX = leftToRight ? to.left - 1 : to.left + to.width
255
+ const space = SpatialIndex.empty().add(
256
+ ...diagram.states.flatMap((state) => {
257
+ if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return []
258
+ const bound = bounds.get(state.id)
259
+ return bound ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] : []
260
+ }),
261
+ )
262
+ const corridor = spatialPathClaim(
263
+ `corridor:${transition.from}:${transition.to}`,
264
+ `transition:${transition.from}:${transition.to}`,
265
+ "route",
266
+ [
267
+ { x: startX, y: from.centerY },
268
+ { x: endX, y: from.centerY },
269
+ ],
270
+ )
271
+ return !space.isFree(corridor)
272
+ }
273
+
274
+ function bottomApproachX(
275
+ diagram: StateVisibleDiagram,
276
+ transition: StateVisibleTransition,
277
+ from: BoxBounds,
278
+ to: BoxBounds,
279
+ bounds: ReadonlyMap<string, BoxBounds>,
280
+ railY: number,
281
+ ): number {
282
+ const targetX = to.width > 1 ? (from.centerX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
283
+ const targetBottomY = to.top + to.height
284
+ const top = Math.min(targetBottomY, railY)
285
+ const bottom = Math.max(targetBottomY, railY)
286
+ const isClear = (x: number): boolean =>
287
+ !diagram.states.some((state) => {
288
+ if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return false
289
+ const bound = bounds.get(state.id)
290
+ return Boolean(
291
+ bound &&
292
+ x >= bound.left &&
293
+ x < bound.left + bound.width &&
294
+ top < bound.top + bound.height &&
295
+ bottom >= bound.top,
296
+ )
297
+ })
298
+
299
+ if (isClear(targetX)) return targetX
300
+ const maxX = Math.max(targetX, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 1
301
+ for (let distance = 1; distance <= maxX; distance++) {
302
+ const right = targetX + distance
303
+ if (isClear(right)) return right
304
+ const left = targetX - distance
305
+ if (left >= 0 && isClear(left)) return left
306
+ }
307
+ return targetX
308
+ }
309
+
310
+ function sideParallelTargetApproach(
311
+ diagram: StateVisibleDiagram,
312
+ transition: StateVisibleTransition,
313
+ from: BoxBounds,
314
+ to: BoxBounds,
315
+ bounds: ReadonlyMap<string, BoxBounds>,
316
+ railX: number,
317
+ ): "top" | "bottom" | undefined {
318
+ const space = SpatialIndex.empty().add(
319
+ ...diagram.states.flatMap((state) => {
320
+ if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return []
321
+ const bound = bounds.get(state.id)
322
+ return bound ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] : []
323
+ }),
324
+ )
325
+ const targetSideX = to.left + to.width
326
+ const claim = (points: readonly { x: number; y: number }[]) =>
327
+ spatialPathClaim(`side-target:${transition.from}:${transition.to}`, "side-target", "route", points)
328
+
329
+ if (
330
+ space.isFree(
331
+ claim([
332
+ { x: railX, y: to.centerY },
333
+ { x: targetSideX, y: to.centerY },
334
+ ]),
335
+ )
336
+ )
337
+ return undefined
338
+
339
+ const targetX = innerConnectorX(to, from.centerX)
340
+ const preferred = from.centerY > to.centerY ? "top" : "bottom"
341
+ return ([preferred, preferred === "top" ? "bottom" : "top"] as const).find((side) => {
342
+ const railY = side === "top" ? to.top - 2 : to.top + to.height + 1
343
+ const targetY = side === "top" ? to.top - 1 : to.top + to.height
344
+ return space.isFree(
345
+ claim([
346
+ { x: railX, y: railY },
347
+ { x: targetX, y: railY },
348
+ { x: targetX, y: targetY },
349
+ ]),
350
+ )
351
+ })
352
+ }
353
+
354
+ function containingCompositeIds(diagram: StateVisibleDiagram, id: string): string[] {
355
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
356
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
357
+ const ids: string[] = []
358
+ let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
359
+ while (parentId) {
360
+ ids.push(parentId)
361
+ parentId = compositesById.get(parentId)?.parentId
362
+ }
363
+ return ids
364
+ }
365
+
366
+ function innermostCommonComposite(
367
+ diagram: StateVisibleDiagram,
368
+ transition: StateVisibleTransition,
369
+ ): string | undefined {
370
+ const target = new Set(containingCompositeIds(diagram, transition.to))
371
+ return containingCompositeIds(diagram, transition.from).find((id) => target.has(id))
372
+ }
373
+
374
+ export function createStateTransitionRoutePlans(
375
+ diagram: StateVisibleDiagram,
376
+ bounds: ReadonlyMap<string, BoxBounds>,
377
+ feedbackLaneY: number,
378
+ feedbackTopY?: number,
379
+ ): StateTransitionRoutePlan[] {
380
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
381
+ const endpointOccurrences = new Map<string, number>()
382
+ const selfOccurrences = new Map<string, number>()
383
+ const parallelLaneGap = Math.max(
384
+ 3,
385
+ ...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
386
+ )
387
+ let nextSideRailX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 3
388
+ const sideRailLanes = new Map<string, number>()
389
+ const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
390
+ let nextBottomRailY =
391
+ Math.max(
392
+ feedbackLaneY - parallelLaneGap,
393
+ ...[...feedbackAllocations.values()]
394
+ .filter((allocation) => allocation.side === "bottom")
395
+ .map((allocation) => allocation.railY),
396
+ ) + parallelLaneGap
397
+ const allocateSideRail = (transition: StateVisibleTransition): number => {
398
+ const compositeId = innermostCommonComposite(diagram, transition)
399
+ const composite = compositeId ? bounds.get(compositeId) : undefined
400
+ if (compositeId && composite) {
401
+ const lane = sideRailLanes.get(compositeId) ?? 0
402
+ sideRailLanes.set(compositeId, lane + 1)
403
+ const descendantRight = Math.max(
404
+ composite.left + 1,
405
+ ...diagram.states.flatMap((state) => {
406
+ if (!containingCompositeIds(diagram, state.id).includes(compositeId)) return []
407
+ const bound = bounds.get(state.id)
408
+ return bound ? [bound.left + bound.width] : []
409
+ }),
410
+ )
411
+ return Math.min(descendantRight + 2 + lane * 3, composite.left + composite.width - 2)
412
+ }
413
+ const railX = nextSideRailX
414
+ nextSideRailX += 3
415
+ return railX
416
+ }
417
+ const allocateBottomRail = (): number => {
418
+ const railY = nextBottomRailY
419
+ nextBottomRailY += parallelLaneGap
420
+ return railY
421
+ }
422
+
423
+ return diagram.transitions.flatMap((transition): StateTransitionRoutePlan[] => {
424
+ const from = bounds.get(transition.from)
425
+ const to = bounds.get(transition.to)
426
+ if (!from || !to) return []
427
+
428
+ const targetState = statesById.get(transition.to)
429
+ const targetIsChoice = targetState?.kind === "choice"
430
+ const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
431
+ const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
432
+ const sideParallel = (): StateTransitionRoutePlan => {
433
+ const railX = allocateSideRail(transition)
434
+ return {
435
+ ...base,
436
+ kind: "side-parallel",
437
+ railX,
438
+ targetApproach: sideParallelTargetApproach(diagram, transition, from, to, bounds, railX),
439
+ }
440
+ }
441
+ if (transition.from === transition.to) {
442
+ const lane = selfOccurrences.get(transition.from) ?? 0
443
+ selfOccurrences.set(transition.from, lane + 1)
444
+ return [{ ...base, kind: "self", lane }]
445
+ }
446
+ const endpointKey = `${transition.from}\u0000${transition.to}`
447
+ const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
448
+ endpointOccurrences.set(endpointKey, parallelIndex + 1)
449
+ const feedback =
450
+ (diagram.direction === "LR" || diagram.direction === "RL") && isStateHorizontalFeedback(diagram, from, to)
451
+ const feedbackAllocation = feedbackAllocations.get(transition)
452
+ if (feedbackAllocation) {
453
+ if (feedbackAllocation.side === "bottom") {
454
+ return [
455
+ {
456
+ ...base,
457
+ kind: "bottom-feedback",
458
+ railY: feedbackAllocation.railY,
459
+ approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackAllocation.railY),
460
+ },
461
+ ]
462
+ }
463
+ return [
464
+ {
465
+ ...base,
466
+ kind: "top-feedback",
467
+ railY: feedbackAllocation.railY,
468
+ },
469
+ ]
470
+ }
471
+ if (parallelIndex > 0) {
472
+ if (from.centerY === to.centerY) {
473
+ const railY = allocateBottomRail()
474
+ return [
475
+ {
476
+ ...base,
477
+ kind: "bottom-parallel",
478
+ railY,
479
+ approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
480
+ },
481
+ ]
482
+ }
483
+ return [sideParallel()]
484
+ }
485
+ if (diagram.direction !== "LR" && diagram.direction !== "RL") {
486
+ const fromParent = statesById.get(transition.from)?.parentId
487
+ const toParent = statesById.get(transition.to)?.parentId
488
+ if (fromParent && toParent && fromParent !== toParent) {
489
+ return [sideParallel()]
490
+ }
491
+ if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
492
+ return [sideParallel()]
493
+ }
494
+ const verticalFeedback = diagram.direction === "BT" ? from.centerY < to.centerY : from.centerY > to.centerY
495
+ if (verticalFeedback) {
496
+ return [sideParallel()]
497
+ }
498
+ if (from.centerY === to.centerY) {
499
+ if (hasReverseTransition(diagram, transition) && from.centerX > to.centerX) {
500
+ const railY = allocateBottomRail()
501
+ return [
502
+ {
503
+ ...base,
504
+ kind: "bottom-parallel",
505
+ railY,
506
+ approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
507
+ },
508
+ ]
509
+ }
510
+ return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
511
+ }
512
+ if (!hasVerticalCorridor(from, to)) {
513
+ return [sideParallel()]
514
+ }
515
+ if (from.centerX !== to.centerX) {
516
+ return [{ ...base, kind: "vertical-elbow", hasReverse: false, offsetConnector: false }]
517
+ }
518
+ return [{ ...base, kind: "vertical" }]
519
+ }
520
+
521
+ if (from.centerY !== to.centerY) {
522
+ if (!hasVerticalCorridor(from, to)) {
523
+ return [sideParallel()]
524
+ }
525
+ if (from.centerY > to.centerY && feedback)
526
+ return [
527
+ {
528
+ ...base,
529
+ kind: "bottom-feedback",
530
+ railY: feedbackLaneY,
531
+ approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackLaneY),
532
+ },
533
+ ]
534
+ const hasReverse = hasReverseTransition(diagram, transition)
535
+ return [
536
+ {
537
+ ...base,
538
+ kind: "vertical-elbow",
539
+ hasReverse,
540
+ offsetConnector: hasReverse || hasOpposingTopConnector(diagram, transition, bounds),
541
+ },
542
+ ]
543
+ }
544
+ if (feedback)
545
+ return [
546
+ {
547
+ ...base,
548
+ kind: "bottom-feedback",
549
+ railY: feedbackLaneY,
550
+ approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackLaneY),
551
+ },
552
+ ]
553
+ if (horizontalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
554
+ const railY = allocateBottomRail()
555
+ return [
556
+ {
557
+ ...base,
558
+ kind: "bottom-parallel",
559
+ railY,
560
+ approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
561
+ },
562
+ ]
563
+ }
564
+ return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
565
+ })
566
+ }
567
+
568
+ function addCell(builder: StateTransitionRenderBuilder, cell: StateTransitionRenderCell): void {
569
+ builder.cells.push(cell)
570
+ builder.path.push([cell.x, cell.y])
571
+ }
572
+
573
+ function addPathPoint(builder: StateTransitionRenderBuilder, x: number, y: number): void {
574
+ builder.path.push([x, y])
575
+ }
576
+
577
+ function addLabel(builder: StateTransitionRenderBuilder, x: number, y: number, label: string): void {
578
+ const metrics = measureStateTransitionLabel(label)
579
+ if (metrics.lines.length > 0) builder.label = { x, y, lines: metrics.lines }
580
+ }
581
+
582
+ function addHorizontalLine(
583
+ builder: StateTransitionRenderBuilder,
584
+ fromX: number,
585
+ toX: number,
586
+ y: number,
587
+ direction: 1 | -1,
588
+ ): void {
589
+ for (let x = fromX; direction === 1 ? x <= toX : x >= toX; x += direction) {
590
+ addCell(builder, { x, y, char: "─" })
591
+ }
592
+ }
593
+
594
+ function addVerticalLine(
595
+ builder: StateTransitionRenderBuilder,
596
+ x: number,
597
+ fromY: number,
598
+ toY: number,
599
+ direction: 1 | -1,
600
+ ): void {
601
+ for (let y = fromY; direction === 1 ? y <= toY : y >= toY; y += direction) {
602
+ addCell(builder, { x, y, char: "│" })
603
+ }
604
+ }
605
+
606
+ function addRightDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
607
+ if (bounds.width <= 1 || bounds.height <= 1) return
608
+ addCell(builder, {
609
+ x: bounds.left + bounds.width - 1,
610
+ y: bounds.centerY,
611
+ char: BorderChars.rounded.leftT,
612
+ })
613
+ }
614
+
615
+ function addLeftDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
616
+ if (bounds.width <= 1 || bounds.height <= 1) return
617
+ addCell(builder, { x: bounds.left, y: bounds.centerY, char: BorderChars.rounded.rightT })
618
+ }
619
+
620
+ function addBottomDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
621
+ if (bounds.width <= 1 || bounds.height <= 1) return
622
+ addCell(builder, {
623
+ x,
624
+ y: bounds.top + bounds.height - 1,
625
+ char: BorderChars.rounded.topT,
626
+ })
627
+ }
628
+
629
+ function addTopDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
630
+ if (bounds.width <= 1 || bounds.height <= 1) return
631
+ addCell(builder, { x, y: bounds.top, char: BorderChars.rounded.bottomT })
632
+ }
633
+
634
+ function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
635
+ const { from, to, targetIsChoice, targetIsHiddenMarker, leftToRight, transition } = builder.route as Extract<
636
+ StateTransitionRoutePlan,
637
+ { kind: "horizontal-forward" }
638
+ >
639
+ const y = from.centerY
640
+ if (leftToRight) addRightDeparture(builder, from)
641
+ else addLeftDeparture(builder, from)
642
+ const step = leftToRight ? 1 : -1
643
+ const startX = leftToRight ? from.left + from.width : from.left - 1
644
+ const endX = leftToRight ? to.left - 1 : to.left + to.width
645
+ addHorizontalLine(builder, startX, endX - step, y, step)
646
+ addCell(
647
+ builder,
648
+ targetIsHiddenMarker ? { x: endX, y, char: "─" } : { x: endX, y, arrowDirection: leftToRight ? "right" : "left" },
649
+ )
650
+ if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, y)
651
+ if (!transition.label) return
652
+ const metrics = measureStateTransitionLabel(transition.label)
653
+ const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
654
+ addLabel(builder, labelX, Math.max(0, y - metrics.height), transition.label)
655
+ }
656
+
657
+ function addSelfTransition(builder: StateTransitionRenderBuilder): void {
658
+ const { from: bounds, transition, lane } = builder.route as Extract<StateTransitionRoutePlan, { kind: "self" }>
659
+ if (bounds.width <= 1 || bounds.height <= 1) {
660
+ const railX = bounds.left + 4 + lane * 4
661
+ const railY = bounds.top + 2 + lane * 2
662
+ addHorizontalLine(builder, bounds.left + 1, railX - 1, bounds.top, 1)
663
+ addCell(builder, { x: railX, y: bounds.top, char: "╮" })
664
+ addVerticalLine(builder, railX, bounds.top + 1, railY - 1, 1)
665
+ addCell(builder, { x: railX, y: railY, char: "╯" })
666
+ addHorizontalLine(builder, railX - 1, bounds.left + 1, railY, -1)
667
+ addCell(builder, { x: bounds.left, y: railY, char: "╰" })
668
+ for (let y = railY - 1; y > bounds.top + 1; y--) addCell(builder, { x: bounds.left, y, char: "│" })
669
+ addCell(builder, { x: bounds.left, y: bounds.top + 1, arrowDirection: "up" })
670
+ addPathPoint(builder, bounds.left, bounds.top)
671
+ if (transition.label) addLabel(builder, railX + 2, lane === 0 ? bounds.top + 1 : railY - 1, transition.label)
672
+ return
673
+ }
674
+ const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3))
675
+ const bottomY = bounds.top + bounds.height - 1
676
+ const railY = bottomY + 2 + lane * 3
677
+ const targetX =
678
+ Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3))) + lane * 4
679
+
680
+ addBottomDeparture(builder, bounds, sourceX)
681
+ addVerticalLine(builder, sourceX, bottomY + 1, railY - 1, 1)
682
+ addCell(builder, { x: sourceX, y: railY, char: "╰" })
683
+ for (let x = sourceX + 1; x < targetX; x++) addCell(builder, { x, y: railY, char: "─" })
684
+ addCell(builder, { x: targetX, y: railY, char: "╯" })
685
+ for (let y = railY - 1; y > bottomY + 1; y--) addCell(builder, { x: targetX, y, char: "│" })
686
+ addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
687
+ if (transition.label) addLabel(builder, targetX + 2, lane === 0 ? bottomY + 1 : railY - 1, transition.label)
688
+ }
689
+
690
+ function outsideBottomY(bounds: BoxBounds): number {
691
+ return bounds.top + bounds.height
692
+ }
693
+
694
+ function outsideTopY(bounds: BoxBounds): number {
695
+ return bounds.top - 1
696
+ }
697
+
698
+ function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
699
+ const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY, approachX } = builder.route as Extract<
700
+ StateTransitionRoutePlan,
701
+ { kind: "bottom-feedback" | "bottom-parallel" }
702
+ >
703
+ const sourceX = from.centerX
704
+ const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
705
+ const targetRailCutsSource = targetX >= from.left && targetX <= from.left + from.width - 1
706
+ const railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : approachX
707
+ const sourceBottomY = outsideBottomY(from)
708
+ const targetBottomY = outsideBottomY(to)
709
+ addBottomDeparture(builder, from, sourceX)
710
+ addVerticalLine(builder, sourceX, sourceBottomY, railY - 1, 1)
711
+ addCell(builder, { x: sourceX, y: railY, char: sourceX > railTargetX ? "╯" : "╰" })
712
+ if (sourceX !== railTargetX) {
713
+ const horizontalStep = sourceX < railTargetX ? 1 : -1
714
+ for (let x = sourceX + horizontalStep; x !== railTargetX; x += horizontalStep) {
715
+ addCell(builder, { x, y: railY, char: "─" })
716
+ }
717
+ }
718
+ addCell(builder, { x: railTargetX, y: railY, char: sourceX > railTargetX ? "╰" : "╯" })
719
+ for (let y = railY - 1; y > targetBottomY; y--) addCell(builder, { x: railTargetX, y, char: "│" })
720
+ if (railTargetX !== targetX) {
721
+ addCell(builder, { x: railTargetX, y: targetBottomY, char: railTargetX < targetX ? "╭" : "╮" })
722
+ const horizontalStep = railTargetX < targetX ? 1 : -1
723
+ for (let x = railTargetX + horizontalStep; x !== targetX; x += horizontalStep) {
724
+ addCell(builder, { x, y: targetBottomY, char: "─" })
725
+ }
726
+ }
727
+ addCell(
728
+ builder,
729
+ targetIsHiddenMarker
730
+ ? { x: targetX, y: targetBottomY, char: "│" }
731
+ : { x: targetX, y: targetBottomY, arrowDirection: "up" },
732
+ )
733
+ if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
734
+ if (!transition.label) return
735
+ const metrics = measureStateTransitionLabel(transition.label)
736
+ const horizontalRoom = Math.abs(sourceX - railTargetX) - 2
737
+ const labelX =
738
+ metrics.width <= horizontalRoom
739
+ ? Math.min(sourceX, railTargetX) + Math.max(1, Math.floor((Math.abs(sourceX - railTargetX) - metrics.width) / 2))
740
+ : railTargetX + 2
741
+ addLabel(builder, labelX, Math.max(0, railY - metrics.height), transition.label)
742
+ }
743
+
744
+ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
745
+ const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY } = builder.route as Extract<
746
+ StateTransitionRoutePlan,
747
+ { kind: "top-feedback" }
748
+ >
749
+ const sourceX = from.centerX
750
+ const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + to.width - 2 : to.left + 1) : to.centerX
751
+ const sourceTopY = outsideTopY(from)
752
+ const targetTopY = outsideTopY(to)
753
+ addTopDeparture(builder, from, sourceX)
754
+ addVerticalLine(builder, sourceX, sourceTopY, railY + 1, -1)
755
+ addCell(builder, { x: sourceX, y: railY, char: sourceX > targetX ? "╮" : "╭" })
756
+ if (sourceX !== targetX) {
757
+ const horizontalStep = sourceX < targetX ? 1 : -1
758
+ for (let x = sourceX + horizontalStep; x !== targetX; x += horizontalStep)
759
+ addCell(builder, { x, y: railY, char: "─" })
760
+ }
761
+ addCell(builder, { x: targetX, y: railY, char: sourceX > targetX ? "╭" : "╮" })
762
+ for (let y = railY + 1; y < targetTopY; y++) addCell(builder, { x: targetX, y, char: "│" })
763
+ addCell(
764
+ builder,
765
+ targetIsHiddenMarker
766
+ ? { x: targetX, y: targetTopY, char: "│" }
767
+ : { x: targetX, y: targetTopY, arrowDirection: "down" },
768
+ )
769
+ if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
770
+ if (!transition.label) return
771
+ const metrics = measureStateTransitionLabel(transition.label)
772
+ const horizontalRoom = Math.abs(sourceX - targetX) - 2
773
+ const labelX =
774
+ metrics.width <= horizontalRoom
775
+ ? Math.min(sourceX, targetX) + Math.max(1, Math.floor((Math.abs(sourceX - targetX) - metrics.width) / 2))
776
+ : targetX + 2
777
+ addLabel(builder, labelX, railY - metrics.height, transition.label)
778
+ }
779
+
780
+ function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
781
+ const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } =
782
+ builder.route as Extract<StateTransitionRoutePlan, { kind: "side-parallel" }>
783
+ const startX = from.left + from.width
784
+ const startY = from.centerY
785
+ const endY = targetApproach === "top" ? to.top - 2 : targetApproach === "bottom" ? to.top + to.height + 1 : to.centerY
786
+ const verticalStep: 1 | -1 = startY <= endY ? 1 : -1
787
+ addRightDeparture(builder, from)
788
+ addHorizontalLine(builder, startX, railX - 1, startY, 1)
789
+ addCell(builder, { x: railX, y: startY, char: verticalStep === 1 ? "╮" : "╯" })
790
+ for (let y = startY + verticalStep; y !== endY; y += verticalStep) addCell(builder, { x: railX, y, char: "│" })
791
+ addCell(builder, { x: railX, y: endY, char: verticalStep === 1 ? "╯" : "╮" })
792
+ if (targetApproach) {
793
+ const targetX = innerConnectorX(to, from.centerX)
794
+ for (let x = railX - 1; x > targetX; x--) addCell(builder, { x, y: endY, char: "─" })
795
+ addCell(builder, { x: targetX, y: endY, char: targetApproach === "top" ? "╭" : "╰" })
796
+ addCell(builder, {
797
+ x: targetX,
798
+ y: targetApproach === "top" ? endY + 1 : endY - 1,
799
+ arrowDirection: targetApproach === "top" ? "down" : "up",
800
+ })
801
+ if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
802
+ if (transition.label) {
803
+ const metrics = measureStateTransitionLabel(transition.label)
804
+ addLabel(
805
+ builder,
806
+ railX + 2,
807
+ Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)),
808
+ transition.label,
809
+ )
810
+ }
811
+ return
812
+ }
813
+ const endX = to.left + to.width
814
+ for (let x = railX - 1; x > endX; x--) addCell(builder, { x, y: endY, char: "─" })
815
+ addCell(
816
+ builder,
817
+ targetIsHiddenMarker ? { x: endX, y: endY, char: "─" } : { x: endX, y: endY, arrowDirection: "left" },
818
+ )
819
+ if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
820
+ if (transition.label) {
821
+ const metrics = measureStateTransitionLabel(transition.label)
822
+ const labelY = Math.max(0, Math.floor((startY + endY - metrics.height + 1) / 2))
823
+ addLabel(builder, railX + 2, labelY, transition.label)
824
+ }
825
+ }
826
+
827
+ function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
828
+ if (bounds.width <= 2) return bounds.centerX
829
+ return Math.max(bounds.left + 1, Math.min(bounds.left + bounds.width - 2, preferredX))
830
+ }
831
+
832
+ function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void {
833
+ const { from, to, transition, targetIsChoice, targetIsHiddenMarker, hasReverse, offsetConnector } =
834
+ builder.route as Extract<StateTransitionRoutePlan, { kind: "vertical-elbow" }>
835
+ const topToBottom = from.centerY < to.centerY
836
+ const offset = offsetConnector ? (topToBottom ? -2 : 2) : 0
837
+ const startX = innerConnectorX(from, from.centerX + offset)
838
+ const endX = innerConnectorX(to, to.centerX + offset)
839
+ const startY = topToBottom ? from.top + from.height : from.top - 1
840
+ const endY = topToBottom ? to.top - 1 : to.top + to.height
841
+ const verticalStep = topToBottom ? 1 : -1
842
+ if (topToBottom) addBottomDeparture(builder, from, startX)
843
+ else addTopDeparture(builder, from, startX)
844
+ const availableApproach = Math.max(0, Math.abs(endY - startY) - 1)
845
+ const bendY = startX === endX ? endY : topToBottom ? endY - verticalStep * Math.min(2, availableApproach) : startY
846
+ const targetApproachLength = Math.abs(endY - bendY)
847
+ const hasTargetApproach = targetApproachLength > 0
848
+ if (startY !== bendY) addVerticalLine(builder, startX, startY, bendY - verticalStep, verticalStep)
849
+ if (startX !== endX) {
850
+ const horizontalStep = startX < endX ? 1 : -1
851
+ addCell(builder, {
852
+ x: startX,
853
+ y: bendY,
854
+ char: topToBottom ? (startX < endX ? "╰" : "╯") : startX < endX ? "╭" : "╮",
855
+ })
856
+ for (let x = startX + horizontalStep; x !== endX; x += horizontalStep) addCell(builder, { x, y: bendY, char: "─" })
857
+ if (hasTargetApproach) {
858
+ addCell(builder, {
859
+ x: endX,
860
+ y: bendY,
861
+ char: topToBottom ? (startX < endX ? "╮" : "╭") : startX < endX ? "╯" : "╰",
862
+ })
863
+ for (let distance = 1; distance < targetApproachLength; distance++) {
864
+ addCell(builder, { x: endX, y: bendY + verticalStep * distance, char: "│" })
865
+ }
866
+ }
867
+ }
868
+ const targetChar = targetIsHiddenMarker
869
+ ? hasTargetApproach || startX === endX
870
+ ? "│"
871
+ : topToBottom
872
+ ? "┬"
873
+ : "┴"
874
+ : undefined
875
+ addCell(builder, {
876
+ x: endX,
877
+ y: endY,
878
+ ...(targetChar ? { char: targetChar } : { arrowDirection: topToBottom ? "down" : "up" }),
879
+ })
880
+ if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
881
+ if (!transition.label) return
882
+ const metrics = measureStateTransitionLabel(transition.label)
883
+ if (topToBottom) {
884
+ const leftLabelX = startX - metrics.width - 2
885
+ const labelX =
886
+ from.width === 1 && Math.abs(endX - startX) >= metrics.width + 2
887
+ ? Math.min(startX, endX) + Math.floor((Math.abs(endX - startX) - metrics.width) / 2)
888
+ : hasReverse || endX < startX
889
+ ? leftLabelX >= 0
890
+ ? leftLabelX
891
+ : startX + 4
892
+ : startX + 2
893
+ addLabel(
894
+ builder,
895
+ labelX,
896
+ hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.min(startY + 1, endY),
897
+ transition.label,
898
+ )
899
+ } else {
900
+ const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
901
+ addLabel(
902
+ builder,
903
+ startX === endX ? startX + 3 : labelX,
904
+ hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.max(0, startY),
905
+ transition.label,
906
+ )
907
+ }
908
+ }
909
+
910
+ function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
911
+ const { from, to, transition, targetIsChoice, targetIsHiddenMarker } = builder.route
912
+ const topToBottom = from.centerY <= to.centerY
913
+ const x = from.centerX
914
+ const startY = topToBottom ? from.top + from.height : from.top - 1
915
+ const endY = topToBottom ? to.top - 1 : to.top + to.height
916
+ const step = topToBottom ? 1 : -1
917
+ if (topToBottom) addBottomDeparture(builder, from, x)
918
+ else addTopDeparture(builder, from, x)
919
+ if (startY !== endY) addVerticalLine(builder, x, startY, endY - step, step)
920
+ addCell(builder, {
921
+ x,
922
+ y: endY,
923
+ ...(targetIsHiddenMarker ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
924
+ })
925
+ if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
926
+ if (transition.label) addLabel(builder, x + 2, Math.min(startY, endY) + 1, transition.label)
927
+ }
928
+
929
+ function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): StateTransitionRenderPlan {
930
+ const builder: StateTransitionRenderBuilder = { route, cells: [], path: [] }
931
+ switch (route.kind) {
932
+ case "self":
933
+ addSelfTransition(builder)
934
+ break
935
+ case "horizontal-forward":
936
+ addHorizontalForward(builder)
937
+ break
938
+ case "bottom-feedback":
939
+ case "bottom-parallel":
940
+ addBottomLaneTransition(builder)
941
+ break
942
+ case "top-feedback":
943
+ addTopFeedbackTransition(builder)
944
+ break
945
+ case "vertical-elbow":
946
+ addVerticalElbowTransition(builder)
947
+ break
948
+ case "vertical":
949
+ addVerticalTransition(builder)
950
+ break
951
+ case "side-parallel":
952
+ addSideParallelTransition(builder)
953
+ break
954
+ }
955
+ return builder
956
+ }
957
+
958
+ function pointIsInsideBounds(point: StateTransitionPathPoint, bounds: BoxBounds): boolean {
959
+ return (
960
+ point[0] >= bounds.left &&
961
+ point[0] < bounds.left + bounds.width &&
962
+ point[1] >= bounds.top &&
963
+ point[1] < bounds.top + bounds.height
964
+ )
965
+ }
966
+
967
+ function routeIntersectsUnrelatedState(
968
+ plan: StateTransitionRenderPlan,
969
+ diagram: StateVisibleDiagram,
970
+ bounds: ReadonlyMap<string, BoxBounds>,
971
+ noteBounds: readonly StateDiagramNoteBounds[],
972
+ ): boolean {
973
+ if (
974
+ diagram.states.some((state) => {
975
+ if (
976
+ state.id === plan.route.transition.from ||
977
+ state.id === plan.route.transition.to ||
978
+ isHiddenCompositeMarker(state)
979
+ )
980
+ return false
981
+ const bound = bounds.get(state.id)
982
+ return Boolean(bound && plan.path.some((point) => pointIsInsideBounds(point, bound)))
983
+ })
984
+ )
985
+ return true
986
+
987
+ return noteBounds.some((noteBound) => {
988
+ if (plan.path.some((point) => pointIsInsideBounds(point, noteBound))) return true
989
+ const target = bounds.get(noteBound.note.target)
990
+ if (!target) return false
991
+ const connector = spatialPathClaim(
992
+ `note-connector:${noteBound.id}`,
993
+ `note-connector:${noteBound.id}`,
994
+ "boundary",
995
+ stateDiagramNoteConnector(noteBound, target).points,
996
+ )
997
+ return plan.path.some(([x, y]) => connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX))
998
+ })
999
+ }
1000
+
1001
+ function findBodySafePath(
1002
+ start: StateTransitionPathPoint,
1003
+ end: StateTransitionPathPoint,
1004
+ diagram: StateVisibleDiagram,
1005
+ bounds: ReadonlyMap<string, BoxBounds>,
1006
+ plan: StateTransitionRenderPlan,
1007
+ search: StateSearchSpace,
1008
+ budget: StateSearchBudget,
1009
+ ): StateTransitionPathPoint[] | undefined {
1010
+ const margin = Math.max(8, bounds.size * 2)
1011
+ const compositeId = innermostCommonComposite(diagram, plan.route.transition)
1012
+ const composite = compositeId ? bounds.get(compositeId) : undefined
1013
+ const searchBounds = {
1014
+ minX: composite ? composite.left + 1 : search.minX - margin,
1015
+ minY: composite ? composite.top + 1 : Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
1016
+ maxX: composite
1017
+ ? composite.left + composite.width - 2
1018
+ : Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
1019
+ maxY: composite
1020
+ ? composite.top + composite.height - 2
1021
+ : Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
1022
+ }
1023
+ const isFree = ([x, y]: StateTransitionPathPoint) =>
1024
+ x >= searchBounds.minX &&
1025
+ x <= searchBounds.maxX &&
1026
+ y >= searchBounds.minY &&
1027
+ y <= searchBounds.maxY &&
1028
+ !search.blocked.has(`${x}:${y}`)
1029
+ const pathMinX = Math.min(...plan.path.map((point) => point[0]))
1030
+ const pathMinY = Math.min(...plan.path.map((point) => point[1]))
1031
+ const pathMaxX = Math.max(...plan.path.map((point) => point[0]))
1032
+ const pathMaxY = Math.max(...plan.path.map((point) => point[1]))
1033
+ const directCandidates: StateTransitionPathPoint[][] = [
1034
+ [start, [start[0], end[1]] as const, end],
1035
+ [start, [end[0], start[1]] as const, end],
1036
+ ...Array.from({ length: 4 }, (_, index) => index + 1).flatMap((offset): StateTransitionPathPoint[][] => [
1037
+ [start, [start[0], pathMinY - offset], [end[0], pathMinY - offset], end],
1038
+ [start, [start[0], pathMaxY + offset], [end[0], pathMaxY + offset], end],
1039
+ [start, [pathMinX - offset, start[1]], [pathMinX - offset, end[1]], end],
1040
+ [start, [pathMaxX + offset, start[1]], [pathMaxX + offset, end[1]], end],
1041
+ ]),
1042
+ ]
1043
+ const direct = directCandidates
1044
+ .map((points) => orthogonalPathPoints(points.map(([x, y]) => ({ x, y }))).map(({ x, y }) => [x, y] as const))
1045
+ .filter((points) => points.every(isFree))
1046
+ .sort((left, right) => left.length - right.length)[0]
1047
+ if (direct) return direct
1048
+ const path = findStateManhattanPath(
1049
+ [{ x: start[0], y: start[1] }],
1050
+ { x: end[0], y: end[1] },
1051
+ search,
1052
+ searchBounds,
1053
+ budget,
1054
+ )
1055
+ return path?.map((point) => [point.x, point.y] as const)
1056
+ }
1057
+
1058
+ function bodySafeTransitionPlan(
1059
+ plan: StateTransitionRenderPlan,
1060
+ diagram: StateVisibleDiagram,
1061
+ bounds: ReadonlyMap<string, BoxBounds>,
1062
+ noteBounds: readonly StateDiagramNoteBounds[],
1063
+ search: StateSearchSpace,
1064
+ budget: StateSearchBudget,
1065
+ forceRepair = false,
1066
+ ): StateTransitionRenderPlan {
1067
+ if (!forceRepair && !routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
1068
+ const sourceOutsideIndex = plan.path.findIndex((point) => !pointIsInsideBounds(point, plan.route.from))
1069
+ const targetOutsideIndex = plan.path.findLastIndex((point) => !pointIsInsideBounds(point, plan.route.to))
1070
+ if (sourceOutsideIndex < 0 || targetOutsideIndex < sourceOutsideIndex) return plan
1071
+ const safePath = findBodySafePath(
1072
+ plan.path[sourceOutsideIndex]!,
1073
+ plan.path[targetOutsideIndex]!,
1074
+ diagram,
1075
+ bounds,
1076
+ plan,
1077
+ search,
1078
+ budget,
1079
+ )
1080
+ if (!safePath) return alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
1081
+
1082
+ const prefix = plan.path.slice(0, sourceOutsideIndex)
1083
+ const suffix = plan.path.slice(targetOutsideIndex + 1)
1084
+ const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
1085
+ if (safePath.length <= plan.path.length + 4) return repaired
1086
+ const alternate = alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
1087
+ return alternate !== plan && alternate.path.length < repaired.path.length ? alternate : repaired
1088
+ }
1089
+
1090
+ function alternateBodySafeTransitionPlan(
1091
+ plan: StateTransitionRenderPlan,
1092
+ diagram: StateVisibleDiagram,
1093
+ bounds: ReadonlyMap<string, BoxBounds>,
1094
+ noteBounds: readonly StateDiagramNoteBounds[],
1095
+ search: StateSearchSpace,
1096
+ budget: StateSearchBudget,
1097
+ ): StateTransitionRenderPlan {
1098
+ const candidates: StateTransitionRenderPlan[] = []
1099
+ for (const source of stateRoutePorts(plan.route.from)) {
1100
+ for (const target of stateRoutePorts(plan.route.to)) {
1101
+ const safePath = findBodySafePath(source.outside, target.outside, diagram, bounds, plan, search, budget)
1102
+ if (!safePath) continue
1103
+ const prefix = plan.route.from.width > 1 && plan.route.from.height > 1 ? [source.border] : []
1104
+ const suffix =
1105
+ plan.route.targetIsChoice || plan.route.targetIsHiddenMarker
1106
+ ? ([[plan.route.to.left, plan.route.to.top]] as const)
1107
+ : []
1108
+ const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix, source.char)
1109
+ if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) candidates.push(repaired)
1110
+ }
1111
+ }
1112
+ return candidates.sort((left, right) => left.path.length - right.path.length)[0] ?? plan
1113
+ }
1114
+
1115
+ function stateRoutePorts(bounds: BoxBounds): Array<{
1116
+ outside: StateTransitionPathPoint
1117
+ border: StateTransitionPathPoint
1118
+ char: string
1119
+ }> {
1120
+ return [
1121
+ {
1122
+ outside: [bounds.centerX, bounds.top - 1] as const,
1123
+ border: [bounds.centerX, bounds.top] as const,
1124
+ char: BorderChars.rounded.bottomT,
1125
+ },
1126
+ {
1127
+ outside: [bounds.centerX, bounds.top + bounds.height] as const,
1128
+ border: [bounds.centerX, bounds.top + bounds.height - 1] as const,
1129
+ char: BorderChars.rounded.topT,
1130
+ },
1131
+ {
1132
+ outside: [bounds.left - 1, bounds.centerY] as const,
1133
+ border: [bounds.left, bounds.centerY] as const,
1134
+ char: BorderChars.rounded.rightT,
1135
+ },
1136
+ {
1137
+ outside: [bounds.left + bounds.width, bounds.centerY] as const,
1138
+ border: [bounds.left + bounds.width - 1, bounds.centerY] as const,
1139
+ char: BorderChars.rounded.leftT,
1140
+ },
1141
+ ].filter((port) => port.outside[0] >= 0)
1142
+ }
1143
+
1144
+ function renderBodySafeTransitionPlan(
1145
+ plan: StateTransitionRenderPlan,
1146
+ safePath: readonly StateTransitionPathPoint[],
1147
+ prefix: readonly StateTransitionPathPoint[],
1148
+ suffix: readonly StateTransitionPathPoint[],
1149
+ sourceChar?: string,
1150
+ ): StateTransitionRenderPlan {
1151
+ const prefixKeys = new Set(prefix.map(([x, y]) => `${x}:${y}`))
1152
+ const cells: StateTransitionRenderCell[] = sourceChar
1153
+ ? prefix.map(([x, y]) => ({ x, y, char: sourceChar }))
1154
+ : plan.cells.filter((cell) => prefixKeys.has(`${cell.x}:${cell.y}`))
1155
+ const fullPath = [...prefix, ...safePath, ...suffix]
1156
+ const previous = prefix.at(-1)
1157
+ for (const [index, point] of safePath.entries()) {
1158
+ if (index === safePath.length - 1) {
1159
+ const targetDirection = connectionDirection(point, [plan.route.to.centerX, plan.route.to.centerY])
1160
+ cells.push(
1161
+ plan.route.targetIsHiddenMarker
1162
+ ? { x: point[0], y: point[1], char: targetDirection === "left" || targetDirection === "right" ? "─" : "│" }
1163
+ : { x: point[0], y: point[1], arrowDirection: targetDirection },
1164
+ )
1165
+ continue
1166
+ }
1167
+ const before = index === 0 ? previous : safePath[index - 1]
1168
+ const after = safePath[index + 1]!
1169
+ const connections = new Set<DiagramDirection>()
1170
+ if (before) connections.add(connectionDirection(point, before))
1171
+ connections.add(connectionDirection(point, after))
1172
+ cells.push({ x: point[0], y: point[1], char: diagramLineGlyph(connections, "rounded") })
1173
+ }
1174
+
1175
+ return { ...plan, cells, path: fullPath, pathRepaired: true }
1176
+ }
1177
+
1178
+ function labelDistanceToPath(
1179
+ x: number,
1180
+ y: number,
1181
+ width: number,
1182
+ height: number,
1183
+ path: readonly StateTransitionPathPoint[],
1184
+ ): number {
1185
+ return Math.min(
1186
+ ...path.map(([pathX, pathY]) => {
1187
+ const dx = pathX < x ? x - pathX : pathX >= x + width ? pathX - (x + width - 1) : 0
1188
+ const dy = pathY < y ? y - pathY : pathY >= y + height ? pathY - (y + height - 1) : 0
1189
+ return dx + dy
1190
+ }),
1191
+ )
1192
+ }
1193
+
1194
+ function stateTransitionLabelCandidates(
1195
+ plan: StateTransitionRenderPlan,
1196
+ width: number,
1197
+ height: number,
1198
+ ): Array<{ x: number; y: number }> {
1199
+ const candidates = new Map<string, { x: number; y: number }>()
1200
+ const add = (x: number, y: number) => candidates.set(`${x}:${y}`, { x, y })
1201
+ if (
1202
+ plan.label &&
1203
+ (!plan.pathRepaired || labelDistanceToPath(plan.label.x, plan.label.y, width, height, plan.path) <= 4)
1204
+ ) {
1205
+ add(plan.label.x, plan.label.y)
1206
+ }
1207
+ for (const [x, y] of plan.path) {
1208
+ add(x + 2, y - Math.floor(height / 2))
1209
+ add(x - width - 2, y - Math.floor(height / 2))
1210
+ add(x - Math.floor(width / 2), y - height - 1)
1211
+ add(x - Math.floor(width / 2), y + 2)
1212
+ }
1213
+ const preferred = plan.label ?? { x: plan.path[0]?.[0] ?? 0, y: plan.path[0]?.[1] ?? 0 }
1214
+ return [...candidates.values()].sort((left, right) => {
1215
+ const leftDistance = Math.abs(left.x - preferred.x) + Math.abs(left.y - preferred.y)
1216
+ const rightDistance = Math.abs(right.x - preferred.x) + Math.abs(right.y - preferred.y)
1217
+ return leftDistance - rightDistance
1218
+ })
1219
+ }
1220
+
1221
+ function placeStateTransitionLabels(
1222
+ plans: readonly StateTransitionRenderPlan[],
1223
+ diagram: StateVisibleDiagram,
1224
+ bounds: ReadonlyMap<string, BoxBounds>,
1225
+ noteBounds: readonly StateDiagramNoteBounds[],
1226
+ ): StateTransitionRenderPlan[] {
1227
+ let space = SpatialIndex.empty().add(
1228
+ ...diagram.states.flatMap((state) => {
1229
+ const bound = bounds.get(state.id)
1230
+ return bound && !isHiddenCompositeMarker(state)
1231
+ ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)]
1232
+ : []
1233
+ }),
1234
+ ...plans.map((plan, index) =>
1235
+ spatialPathClaim(
1236
+ `route:${index}`,
1237
+ `route:${index}`,
1238
+ "route",
1239
+ plan.path.map(([x, y]) => ({ x, y })),
1240
+ ),
1241
+ ),
1242
+ ...diagram.composites.flatMap((composite) => {
1243
+ const bound = bounds.get(composite.id)
1244
+ if (!bound) return []
1245
+ return [
1246
+ spatialPathClaim(`composite:${composite.id}`, `composite:${composite.id}`, "boundary", [
1247
+ { x: bound.left, y: bound.top },
1248
+ { x: bound.left + bound.width - 1, y: bound.top },
1249
+ { x: bound.left + bound.width - 1, y: bound.top + bound.height - 1 },
1250
+ { x: bound.left, y: bound.top + bound.height - 1 },
1251
+ { x: bound.left, y: bound.top },
1252
+ ]),
1253
+ ]
1254
+ }),
1255
+ ...noteBounds.flatMap((noteBound) => {
1256
+ const target = bounds.get(noteBound.note.target)
1257
+ return [
1258
+ spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound),
1259
+ ...(target
1260
+ ? [
1261
+ spatialPathClaim(
1262
+ `note-connector:${noteBound.id}`,
1263
+ `note-connector:${noteBound.id}`,
1264
+ "boundary",
1265
+ stateDiagramNoteConnector(noteBound, target).points,
1266
+ ),
1267
+ ]
1268
+ : []),
1269
+ ]
1270
+ }),
1271
+ )
1272
+
1273
+ const placed = new Map<number, StateTransitionRenderPlan>()
1274
+ const endpointCounts = new Map<string, number>()
1275
+ for (const plan of plans) {
1276
+ const key = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
1277
+ endpointCounts.set(key, (endpointCounts.get(key) ?? 0) + 1)
1278
+ }
1279
+ const placementOrder = [...plans.keys()].sort(
1280
+ (left, right) => Number(Boolean(plans[left]!.pathRepaired)) - Number(Boolean(plans[right]!.pathRepaired)),
1281
+ )
1282
+ for (const planIndex of placementOrder) {
1283
+ const plan = plans[planIndex]!
1284
+ if (!plan.label) {
1285
+ placed.set(planIndex, plan)
1286
+ continue
1287
+ }
1288
+ const width = Math.max(...plan.label.lines.map(diagramTextWidth))
1289
+ const endpointKey = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
1290
+ const needsLaneClearance = (endpointCounts.get(endpointKey) ?? 0) > 1
1291
+ const statePadding = needsLaneClearance || plan.label.lines.length > 1 ? 1 : 0
1292
+ const labelClaim = (x: number, y: number) =>
1293
+ spatialRectClaim(`label:${planIndex}`, `label:${planIndex}`, "label", {
1294
+ left: x,
1295
+ top: y,
1296
+ width,
1297
+ height: plan.label!.lines.length,
1298
+ })
1299
+ const sharesLoopCorridor = plans.some(
1300
+ (candidate) =>
1301
+ candidate.route.transition.from === plan.route.transition.from &&
1302
+ candidate.route.kind === (plan.route.kind === "self" ? "vertical" : "self"),
1303
+ )
1304
+ const corridorTop =
1305
+ plan.route.kind === "side-parallel" &&
1306
+ plans.some((candidate) => candidate !== plan && candidate.route.transition.to === plan.route.transition.to)
1307
+ ? Math.min(...plan.path.map(([, y]) => y))
1308
+ : sharesLoopCorridor &&
1309
+ (plan.route.kind === "self" ||
1310
+ (plan.route.kind === "vertical" && plan.route.from.centerY < plan.route.to.centerY))
1311
+ ? plan.route.from.top + plan.route.from.height
1312
+ : undefined
1313
+ const corridorBottom = corridorTop === undefined ? undefined : Math.max(...plan.path.map(([, y]) => y))
1314
+ const isClear = (x: number, y: number): boolean => {
1315
+ if (x < 0 || y < 0 || (corridorTop !== undefined && (y < corridorTop || y > corridorBottom!))) return false
1316
+ return space.isFree(labelClaim(x, y), {
1317
+ clearance: {
1318
+ body: statePadding,
1319
+ label: { x: 1, y: 1 },
1320
+ route:
1321
+ plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance || corridorTop !== undefined
1322
+ ? {
1323
+ x: 1,
1324
+ y: 0,
1325
+ }
1326
+ : 0,
1327
+ },
1328
+ })
1329
+ }
1330
+
1331
+ const candidates = stateTransitionLabelCandidates(plan, width, plan.label.lines.length)
1332
+ const nearby = candidates.find(
1333
+ (candidate) =>
1334
+ (!(
1335
+ plan.pathRepaired ||
1336
+ plan.route.kind === "side-parallel" ||
1337
+ needsLaneClearance ||
1338
+ corridorTop !== undefined
1339
+ ) ||
1340
+ labelDistanceToPath(candidate.x, candidate.y, width, plan.label!.lines.length, plan.path) >= 2) &&
1341
+ isClear(candidate.x, candidate.y),
1342
+ )
1343
+ let x = nearby?.x ?? candidates[0]?.x ?? plan.label.x
1344
+ let y = nearby?.y ?? candidates[0]?.y ?? plan.label.y
1345
+ if (!nearby) {
1346
+ search: for (let distance = 1; distance < 500; distance++) {
1347
+ for (let dx = -distance; dx <= distance; dx++) {
1348
+ const dy = distance - Math.abs(dx)
1349
+ for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
1350
+ const candidateX = x + dx
1351
+ if (!isClear(candidateX, candidateY)) continue
1352
+ const pathDistance = labelDistanceToPath(candidateX, candidateY, width, plan.label!.lines.length, plan.path)
1353
+ if (pathDistance < 2 || pathDistance > 8) continue
1354
+ x = candidateX
1355
+ y = candidateY
1356
+ break search
1357
+ }
1358
+ }
1359
+ }
1360
+ }
1361
+ if (!isClear(x, y)) {
1362
+ fallback: for (let distance = 1; distance < 500; distance++) {
1363
+ for (let dx = -distance; dx <= distance; dx++) {
1364
+ const dy = distance - Math.abs(dx)
1365
+ for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
1366
+ const candidateX = x + dx
1367
+ if (!isClear(candidateX, candidateY)) continue
1368
+ x = candidateX
1369
+ y = candidateY
1370
+ break fallback
1371
+ }
1372
+ }
1373
+ }
1374
+ }
1375
+ if (!isClear(x, y)) throw new Error(`Transition ${endpointKey} has no clear label position`)
1376
+
1377
+ space = space.add(labelClaim(x, y))
1378
+ placed.set(planIndex, { ...plan, label: { ...plan.label, x, y } })
1379
+ }
1380
+ return plans.map((plan, index) => placed.get(index) ?? plan)
1381
+ }
1382
+
1383
+ export function createStateTransitionRenderPlans(
1384
+ diagram: StateVisibleDiagram,
1385
+ bounds: ReadonlyMap<string, BoxBounds>,
1386
+ feedbackLaneY: number,
1387
+ options: StateTransitionRenderOptions = {},
1388
+ ): StateTransitionRenderPlan[] {
1389
+ const noteBounds = options.noteBounds ?? []
1390
+ const budget = options.searchBudget ?? createStateSearchBudget()
1391
+ const plans = createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, options.feedbackTopY).map(
1392
+ createStateTransitionRenderPlan,
1393
+ )
1394
+ if (options.repairRoutes === false) return placeStateTransitionLabels(plans, diagram, bounds, noteBounds)
1395
+ const baseObstacles = transitionObstacles(diagram, bounds, noteBounds)
1396
+ const repaired: StateTransitionRenderPlan[] = []
1397
+ for (const [index, plan] of plans.entries()) {
1398
+ const disjoint = repaired.filter((previous) => transitionsHaveDisjointEndpoints(previous, plan))
1399
+ const routeObstacles = repaired.filter(
1400
+ (previous) => disjoint.includes(previous) || transitionsAreReciprocal(previous, plan),
1401
+ )
1402
+ const routeSpace = createStateSearchSpace(
1403
+ baseObstacles.add(
1404
+ ...routeObstacles.map((previous, previousIndex) =>
1405
+ spatialPathClaim(
1406
+ `transition:${index}:obstacle:${previousIndex}`,
1407
+ `transition:${index}:obstacle:${previousIndex}`,
1408
+ "route",
1409
+ previous.path.map(([x, y]) => ({ x, y })),
1410
+ ),
1411
+ ),
1412
+ ),
1413
+ )
1414
+ repaired.push(
1415
+ bodySafeTransitionPlan(
1416
+ plan,
1417
+ diagram,
1418
+ bounds,
1419
+ noteBounds,
1420
+ routeSpace,
1421
+ budget,
1422
+ disjoint.some((previous) => pathsIntersect(previous.path, plan.path)),
1423
+ ),
1424
+ )
1425
+ }
1426
+ return placeStateTransitionLabels(repaired, diagram, bounds, noteBounds)
1427
+ }
1428
+
1429
+ function transitionsHaveDisjointEndpoints(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
1430
+ const leftEndpoints = new Set([left.route.transition.from, left.route.transition.to])
1431
+ return !leftEndpoints.has(right.route.transition.from) && !leftEndpoints.has(right.route.transition.to)
1432
+ }
1433
+
1434
+ function transitionsAreReciprocal(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
1435
+ return (
1436
+ left.route.transition.from === right.route.transition.to && left.route.transition.to === right.route.transition.from
1437
+ )
1438
+ }
1439
+
1440
+ function pathsIntersect(
1441
+ left: readonly StateTransitionPathPoint[],
1442
+ right: readonly StateTransitionPathPoint[],
1443
+ ): boolean {
1444
+ const occupied = new Set(left.map(([x, y]) => `${x}:${y}`))
1445
+ return right.some(([x, y]) => occupied.has(`${x}:${y}`))
1446
+ }
1447
+
1448
+ function transitionObstacles(
1449
+ diagram: StateVisibleDiagram,
1450
+ bounds: ReadonlyMap<string, BoxBounds>,
1451
+ noteBounds: readonly StateDiagramNoteBounds[],
1452
+ ): SpatialIndex {
1453
+ return SpatialIndex.empty().add(
1454
+ ...diagram.states.flatMap((state) => {
1455
+ const bound = bounds.get(state.id)
1456
+ return bound && !isHiddenCompositeMarker(state)
1457
+ ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)]
1458
+ : []
1459
+ }),
1460
+ ...noteBounds.flatMap((noteBound) => {
1461
+ const target = bounds.get(noteBound.note.target)
1462
+ return [
1463
+ spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound),
1464
+ ...(target
1465
+ ? [
1466
+ spatialPathClaim(
1467
+ `note-connector:${noteBound.id}`,
1468
+ `note-connector:${noteBound.id}`,
1469
+ "boundary",
1470
+ stateDiagramNoteConnector(noteBound, target).points,
1471
+ ),
1472
+ ]
1473
+ : []),
1474
+ ]
1475
+ }),
1476
+ )
1477
+ }
1478
+
1479
+ function connectionDirection(from: StateTransitionPathPoint, to: StateTransitionPathPoint): DiagramDirection {
1480
+ const deltaX = to[0] - from[0]
1481
+ const deltaY = to[1] - from[1]
1482
+ if (Math.abs(deltaX) >= Math.abs(deltaY) && deltaX !== 0) return deltaX > 0 ? "right" : "left"
1483
+ if (deltaY !== 0) return deltaY > 0 ? "down" : "up"
1484
+ return "right"
1485
+ }
1486
+
1487
+ export function createStateTransitionJunctionPlans(
1488
+ diagram: StateVisibleDiagram,
1489
+ bounds: ReadonlyMap<string, BoxBounds>,
1490
+ renderPlans: readonly StateTransitionRenderPlan[],
1491
+ ): StateTransitionJunctionPlan[] {
1492
+ const renderPlanByTransition = new Map(renderPlans.map((plan) => [plan.route.transition, plan]))
1493
+ return diagram.states.flatMap((state): StateTransitionJunctionPlan[] => {
1494
+ const kind =
1495
+ state.kind === "choice" || state.kind === "fork" || state.kind === "join"
1496
+ ? state.kind : isHiddenCompositeMarker(state) ? "hidden-composite-marker" : undefined
1497
+ if (!kind) return []
1498
+ const stateBounds = bounds.get(state.id)
1499
+ if (!stateBounds) return []
1500
+
1501
+ const connections = new Set<DiagramDirection>()
1502
+ const transitions: StateVisibleTransition[] = []
1503
+ for (const transition of diagram.transitions) {
1504
+ const renderPlan = renderPlanByTransition.get(transition)
1505
+ let connected = false
1506
+ if (transition.to === state.id) {
1507
+ const junction = renderPlan?.path.at(-1)
1508
+ const neighbor = renderPlan?.path.at(-2)
1509
+ if (junction && neighbor) connections.add(connectionDirection(junction, neighbor))
1510
+ connected = true
1511
+ }
1512
+ if (transition.from === state.id) {
1513
+ const neighbor = renderPlan?.path[0]
1514
+ if (neighbor) connections.add(connectionDirection([stateBounds.left, stateBounds.top], neighbor))
1515
+ connected = true
1516
+ }
1517
+ if (connected) transitions.push(transition)
1518
+ }
1519
+
1520
+ return [{ state, bounds: stateBounds, connections, transitions, kind }]
1521
+ })
1522
+ }