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,1134 @@
1
+ import { orthogonalPathPoints, translateDiagramBounds, type DiagramPoint } from "../core/geometry.js"
2
+ import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
3
+ import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
4
+ import { stateDiagramNoteConnector, type StateDiagramNoteConnector } from "./note.js"
5
+ import {
6
+ createStateTransitionRenderPlans,
7
+ hasReverseTransition,
8
+ isStateHorizontalFeedback,
9
+ measureStateTransitionLabel,
10
+ type StateTransitionRenderPlan,
11
+ } from "./routing.js"
12
+ import {
13
+ createStateSearchBudget,
14
+ createStateSearchSpace,
15
+ findStateManhattanPath,
16
+ type StateSearchBudget,
17
+ type StateSearchSpace,
18
+ } from "./search.js"
19
+ import type {
20
+ StateDiagram,
21
+ StateDiagramCompositeState,
22
+ StateDiagramNote,
23
+ StateDiagramState,
24
+ StateDiagramTransition,
25
+ } from "./types.js"
26
+
27
+ const MAX_STRICT_NOTE_PLACEMENTS = 24
28
+
29
+ export interface StateDiagramBoxBounds {
30
+ id: string
31
+ left: number
32
+ top: number
33
+ width: number
34
+ height: number
35
+ centerX: number
36
+ centerY: number
37
+ }
38
+
39
+ export interface StateDiagramLayout {
40
+ bounds: Map<string, StateDiagramBoxBounds>
41
+ sizes: Map<string, { width: number; height: number; lines: string[] }>
42
+ compositeBounds: Map<string, StateDiagramBoxBounds>
43
+ noteBounds: StateDiagramNoteBounds[]
44
+ }
45
+
46
+ export interface StateDiagramNoteBounds extends StateDiagramBoxBounds {
47
+ note: StateDiagramNote
48
+ lines: string[]
49
+ connector?: StateDiagramNoteConnector
50
+ }
51
+
52
+ export interface StateDiagramLayoutOptions {
53
+ minStateGap: number
54
+ searchBudget?: StateSearchBudget
55
+ }
56
+
57
+ function computeRanks(diagram: StateDiagram): Map<string, number> {
58
+ const ranks = new Map<string, number>()
59
+ const outgoing = new Map<string, string[]>()
60
+ for (const transition of diagram.transitions) {
61
+ const list = outgoing.get(transition.from) ?? []
62
+ list.push(transition.to)
63
+ outgoing.set(transition.from, list)
64
+ }
65
+
66
+ const first = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
67
+ if (!first) return ranks
68
+ ranks.set(first, 0)
69
+ const queue = [first]
70
+ while (queue.length > 0) {
71
+ const id = queue.shift()!
72
+ const rank = ranks.get(id) ?? 0
73
+ for (const to of outgoing.get(id) ?? []) {
74
+ const nextRank = rank + 1
75
+ if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
76
+ ranks.set(to, nextRank)
77
+ queue.push(to)
78
+ }
79
+ }
80
+
81
+ for (const state of diagram.states) {
82
+ if (!ranks.has(state.id)) ranks.set(state.id, ranks.size)
83
+ }
84
+ return ranks
85
+ }
86
+
87
+ function outgoingTransitions(diagram: StateDiagram): Map<string, StateDiagramTransition[]> {
88
+ const outgoing = new Map<string, StateDiagramTransition[]>()
89
+ for (const transition of diagram.transitions) {
90
+ const list = outgoing.get(transition.from) ?? []
91
+ list.push(transition)
92
+ outgoing.set(transition.from, list)
93
+ }
94
+ return outgoing
95
+ }
96
+
97
+ function reaches(
98
+ outgoing: ReadonlyMap<string, readonly StateDiagramTransition[]>,
99
+ from: string,
100
+ target: string,
101
+ ): boolean {
102
+ const visited = new Set<string>()
103
+ const stack = [from]
104
+ while (stack.length > 0) {
105
+ const id = stack.pop()!
106
+ if (id === target) return true
107
+ if (visited.has(id)) continue
108
+ visited.add(id)
109
+ for (const transition of outgoing.get(id) ?? []) stack.push(transition.to)
110
+ }
111
+ return false
112
+ }
113
+
114
+ function computeMainPath(diagram: StateDiagram): string[] {
115
+ const outgoing = outgoingTransitions(diagram)
116
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
117
+ const start = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
118
+ if (!start) return []
119
+
120
+ const path = [start]
121
+ const visited = new Set(path)
122
+ let current = start
123
+ while (true) {
124
+ const candidates = (outgoing.get(current) ?? []).filter((transition) => !visited.has(transition.to))
125
+ if (candidates.length === 0) break
126
+ const next =
127
+ candidates.find((transition) => statesById.get(transition.to)?.kind === "end") ??
128
+ candidates.find((transition) => !reaches(outgoing, transition.to, current)) ??
129
+ candidates.find((transition) => !hasReverseTransition(diagram, transition)) ??
130
+ candidates.find((transition) => {
131
+ const fromParent = statesById.get(current)?.parentId
132
+ const toParent = statesById.get(transition.to)?.parentId
133
+ return Boolean(fromParent && toParent && fromParent !== toParent)
134
+ }) ??
135
+ (candidates.length === 1 ? candidates[0] : undefined)
136
+ if (!next) break
137
+ path.push(next.to)
138
+ visited.add(next.to)
139
+ current = next.to
140
+ }
141
+
142
+ return path
143
+ }
144
+
145
+ function stateSize(state: StateDiagramState): { width: number; height: number; lines: string[] } {
146
+ if (state.kind !== "state") return { width: 1, height: 1, lines: [state.label] }
147
+ return measureDiagramTextBox(state.label, { paddingX: 2, paddingY: 1 })
148
+ }
149
+
150
+ function noteLines(note: StateDiagramNote): string[] {
151
+ const lines = note.lines.flatMap(splitDiagramLines).map((line) => line.trim())
152
+ return lines.length > 0 ? lines : [""]
153
+ }
154
+
155
+ function noteSize(note: StateDiagramNote): { width: number; height: number; lines: string[] } {
156
+ const lines = noteLines(note)
157
+ const innerWidth = Math.max(...lines.map(diagramTextWidth), 1)
158
+ return { width: innerWidth + 4, height: lines.length + 2, lines }
159
+ }
160
+
161
+ function emptyLayout(
162
+ bounds: Map<string, StateDiagramBoxBounds>,
163
+ sizes: Map<string, { width: number; height: number; lines: string[] }>,
164
+ ): StateDiagramLayout {
165
+ return { bounds, sizes, compositeBounds: new Map(), noteBounds: [] }
166
+ }
167
+
168
+ function isNoteBound(bound: StateDiagramBoxBounds): bound is StateDiagramNoteBounds {
169
+ return "note" in bound
170
+ }
171
+
172
+ function shiftBounds(bounds: Iterable<StateDiagramBoxBounds>, dx: number, dy: number): void {
173
+ for (const bound of bounds) {
174
+ translateDiagramBounds(bound, dx, dy)
175
+ if (!isNoteBound(bound) || !bound.connector) continue
176
+ bound.connector = {
177
+ connectorY: bound.connector.connectorY + dy,
178
+ points: bound.connector.points.map((point) => ({ x: point.x + dx, y: point.y + dy })),
179
+ }
180
+ }
181
+ }
182
+
183
+ export function translateStateDiagramLayout(layout: StateDiagramLayout, dx: number, dy: number): void {
184
+ shiftBounds(uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds), dx, dy)
185
+ }
186
+
187
+ function uniqueBounds(...bounds: Iterable<StateDiagramBoxBounds>[]): StateDiagramBoxBounds[] {
188
+ return [...new Set(bounds.flatMap((group) => [...group]))]
189
+ }
190
+
191
+ function normalizeLayout(layout: StateDiagramLayout): void {
192
+ const allBounds = uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds)
193
+ if (allBounds.length === 0) return
194
+ const connectorPoints = layout.noteBounds.flatMap((bound) => bound.connector?.points ?? [])
195
+ const minX = Math.min(0, ...allBounds.map((bound) => bound.left), ...connectorPoints.map((point) => point.x))
196
+ const minY = Math.min(0, ...allBounds.map((bound) => bound.top), ...connectorPoints.map((point) => point.y))
197
+ if (minX === 0 && minY === 0) return
198
+ shiftBounds(allBounds, -minX, -minY)
199
+ }
200
+
201
+ function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
202
+ const statesByParent = new Map<string, string[]>()
203
+ const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
204
+ for (const state of diagram.states) {
205
+ if (!state.parentId) continue
206
+ const states = statesByParent.get(state.parentId) ?? []
207
+ states.push(state.id)
208
+ statesByParent.set(state.parentId, states)
209
+ }
210
+ for (const composite of diagram.composites) {
211
+ if (!composite.parentId) continue
212
+ const composites = compositesByParent.get(composite.parentId) ?? []
213
+ composites.push(composite)
214
+ compositesByParent.set(composite.parentId, composites)
215
+ }
216
+
217
+ const addComposite = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
218
+ const existing = layout.compositeBounds.get(composite.id)
219
+ if (existing) return existing
220
+
221
+ for (const child of compositesByParent.get(composite.id) ?? []) addComposite(child)
222
+
223
+ const childBounds = [
224
+ ...(statesByParent.get(composite.id) ?? []),
225
+ ...(compositesByParent.get(composite.id) ?? []).map((child) => child.id),
226
+ ]
227
+ .map((id) => layout.bounds.get(id))
228
+ .filter((bound): bound is StateDiagramBoxBounds => Boolean(bound))
229
+ if (childBounds.length === 0) return undefined
230
+
231
+ const left = Math.min(...childBounds.map((bound) => bound.left)) - 2
232
+ const top = Math.min(...childBounds.map((bound) => bound.top)) - 2
233
+ const right = Math.max(...childBounds.map((bound) => bound.left + bound.width)) + 2
234
+ const bottom = Math.max(...childBounds.map((bound) => bound.top + bound.height)) + 2
235
+ const width = Math.max(right - left, diagramTextWidth(composite.label) + 5)
236
+ const bound = {
237
+ id: composite.id,
238
+ left,
239
+ top,
240
+ width,
241
+ height: bottom - top,
242
+ centerX: left + Math.floor(width / 2),
243
+ centerY: top + Math.floor((bottom - top) / 2),
244
+ }
245
+ layout.compositeBounds.set(composite.id, bound)
246
+ layout.bounds.set(composite.id, bound)
247
+ return bound
248
+ }
249
+
250
+ for (const composite of diagram.composites) addComposite(composite)
251
+ }
252
+
253
+ function intersects(
254
+ left: number,
255
+ top: number,
256
+ width: number,
257
+ height: number,
258
+ bound: StateDiagramBoxBounds,
259
+ padding = 1,
260
+ ): boolean {
261
+ return (
262
+ left < bound.left + bound.width + padding &&
263
+ left + width + padding > bound.left &&
264
+ top < bound.top + bound.height + padding &&
265
+ top + height + padding > bound.top
266
+ )
267
+ }
268
+
269
+ function createNoteBound(
270
+ note: StateDiagramNote,
271
+ index: number,
272
+ left: number,
273
+ top: number,
274
+ size: { width: number; height: number; lines: string[] },
275
+ ): StateDiagramNoteBounds {
276
+ return {
277
+ id: `${note.target}-note-${index}`,
278
+ left,
279
+ top,
280
+ width: size.width,
281
+ height: size.height,
282
+ centerX: left + Math.floor(size.width / 2),
283
+ centerY: top + Math.floor(size.height / 2),
284
+ note,
285
+ lines: size.lines,
286
+ }
287
+ }
288
+
289
+ function findNoteConnector(
290
+ search: StateSearchSpace,
291
+ bounds: StateDiagramNoteBounds,
292
+ target: StateDiagramBoxBounds,
293
+ budget: StateSearchBudget,
294
+ ): StateDiagramNoteConnector | undefined {
295
+ const connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
296
+ const end = {
297
+ x: bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width,
298
+ y: connectorY,
299
+ }
300
+ const goal = { x: end.x + (bounds.note.position === "right" ? -1 : 1), y: end.y }
301
+ const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
302
+ const preferredStarts = [
303
+ { x: targetX, y: target.centerY },
304
+ { x: targetX, y: target.top - 1 },
305
+ { x: targetX, y: target.top + target.height },
306
+ ]
307
+ const perimeterStarts = [
308
+ ...Array.from({ length: target.height }, (_, offset) => ({ x: target.left - 1, y: target.top + offset })),
309
+ ...Array.from({ length: target.height }, (_, offset) => ({
310
+ x: target.left + target.width,
311
+ y: target.top + offset,
312
+ })),
313
+ ...Array.from({ length: target.width }, (_, offset) => ({ x: target.left + offset, y: target.top - 1 })),
314
+ ...Array.from({ length: target.width }, (_, offset) => ({
315
+ x: target.left + offset,
316
+ y: target.top + target.height,
317
+ })),
318
+ ]
319
+ const starts = [...preferredStarts, ...perimeterStarts]
320
+ const isFree = (point: DiagramPoint): boolean =>
321
+ point.x >= 0 &&
322
+ !search.blocked.has(`${point.x}:${point.y}`) &&
323
+ !(
324
+ point.x >= bounds.left &&
325
+ point.x < bounds.left + bounds.width &&
326
+ point.y >= bounds.top &&
327
+ point.y < bounds.top + bounds.height
328
+ )
329
+ if (!isFree(end) || !isFree(goal)) return undefined
330
+
331
+ for (const start of starts.filter(isFree)) {
332
+ const directPaths = [
333
+ [start, { x: start.x, y: goal.y }, goal, end],
334
+ [start, { x: goal.x, y: start.y }, goal, end],
335
+ ]
336
+ const direct = directPaths.find((points) => orthogonalPathPoints(points).every(isFree))
337
+ if (direct) return { connectorY, points: direct }
338
+ }
339
+
340
+ const margin = 8
341
+ const minY = Math.min(target.top, bounds.top, search.minY) - margin
342
+ const maxX = Math.max(target.left + target.width, bounds.left + bounds.width, search.maxX) + margin
343
+ const maxY = Math.max(target.top + target.height, bounds.top + bounds.height, search.maxY) + margin
344
+ const path = findStateManhattanPath(starts, goal, search, { minX: 0, minY, maxX, maxY }, budget, isFree)
345
+ return path ? { connectorY, points: [...path, end] } : undefined
346
+ }
347
+
348
+ function belongsToComposite(
349
+ id: string,
350
+ compositeId: string,
351
+ statesById: Map<string, StateDiagramState>,
352
+ compositesById: Map<string, StateDiagramCompositeState>,
353
+ ): boolean {
354
+ let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
355
+
356
+ while (parentId) {
357
+ if (parentId === compositeId) return true
358
+ parentId = compositesById.get(parentId)?.parentId
359
+ }
360
+
361
+ return false
362
+ }
363
+
364
+ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagramLayout): void {
365
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
366
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
367
+
368
+ for (const composite of [...diagram.composites].reverse()) {
369
+ const bound = layout.compositeBounds.get(composite.id)
370
+ if (!bound) continue
371
+
372
+ const descendantNotes = layout.noteBounds.filter((noteBound) =>
373
+ belongsToComposite(noteBound.note.target, composite.id, statesById, compositesById),
374
+ )
375
+ if (descendantNotes.length === 0) continue
376
+
377
+ const connectorPoints = descendantNotes.flatMap((note) => note.connector?.points ?? [])
378
+ const left = Math.min(
379
+ bound.left,
380
+ ...descendantNotes.map((note) => note.left - 2),
381
+ ...connectorPoints.map((point) => point.x - 1),
382
+ )
383
+ const top = Math.min(
384
+ bound.top,
385
+ ...descendantNotes.map((note) => note.top - 1),
386
+ ...connectorPoints.map((point) => point.y - 1),
387
+ )
388
+ const right = Math.max(
389
+ bound.left + bound.width,
390
+ ...descendantNotes.map((note) => note.left + note.width + 2),
391
+ ...connectorPoints.map((point) => point.x + 2),
392
+ )
393
+ const bottom = Math.max(
394
+ bound.top + bound.height,
395
+ ...descendantNotes.map((note) => note.top + note.height + 1),
396
+ ...connectorPoints.map((point) => point.y + 2),
397
+ )
398
+
399
+ bound.left = left
400
+ bound.top = top
401
+ bound.width = Math.max(right - left, diagramTextWidth(composite.label) + 5)
402
+ bound.height = bottom - top
403
+ bound.centerX = bound.left + Math.floor(bound.width / 2)
404
+ bound.centerY = bound.top + Math.floor(bound.height / 2)
405
+ }
406
+ }
407
+
408
+ function expandCompositeBoundsForInternalRouting(diagram: StateDiagram, layout: StateDiagramLayout): void {
409
+ if (diagram.direction === "LR" || diagram.direction === "RL") return
410
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
411
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
412
+
413
+ for (const composite of [...diagram.composites].reverse()) {
414
+ const bound = layout.compositeBounds.get(composite.id)
415
+ if (!bound) continue
416
+ const internal = diagram.transitions.filter(
417
+ (transition) =>
418
+ transition.from !== transition.to &&
419
+ innermostCommonCompositeId(transition, statesById, compositesById) === composite.id,
420
+ )
421
+ const endpointOccurrences = new Map<string, number>()
422
+ const hasNestedComposite = diagram.composites.some((candidate) => candidate.parentId === composite.id)
423
+ const sideRoutes = internal.filter((transition) => {
424
+ const from = layout.bounds.get(transition.from)
425
+ const to = layout.bounds.get(transition.to)
426
+ if (!from || !to) return false
427
+ const key = `${transition.from}\u0000${transition.to}`
428
+ const occurrence = endpointOccurrences.get(key) ?? 0
429
+ endpointOccurrences.set(key, occurrence + 1)
430
+ const fromParent = statesById.get(transition.from)?.parentId
431
+ const toParent = statesById.get(transition.to)?.parentId
432
+ return (
433
+ occurrence > 0 ||
434
+ (from.centerY > to.centerY && (!hasNestedComposite || from.left - 1 <= bound.left)) ||
435
+ (fromParent !== toParent &&
436
+ (from.centerX !== to.centerX ||
437
+ statesById.get(transition.from)?.kind !== "state" ||
438
+ statesById.get(transition.to)?.kind !== "state"))
439
+ )
440
+ })
441
+ if (sideRoutes.length === 0) continue
442
+ const childRight = Math.max(
443
+ ...diagram.states.flatMap((state) => {
444
+ if (!belongsToComposite(state.id, composite.id, statesById, compositesById)) return []
445
+ const child = layout.bounds.get(state.id)
446
+ return child ? [child.left + child.width] : []
447
+ }),
448
+ ...diagram.composites.flatMap((childComposite) => {
449
+ if (childComposite.parentId !== composite.id) return []
450
+ const child = layout.compositeBounds.get(childComposite.id)
451
+ return child ? [child.left + child.width] : []
452
+ }),
453
+ )
454
+ const labelWidth = Math.max(...sideRoutes.map((transition) => measureStateTransitionLabel(transition.label).width))
455
+ const right = childRight + labelWidth + sideRoutes.length * 3 + 6
456
+ if (right <= bound.left + bound.width) continue
457
+ bound.width = right - bound.left
458
+ bound.centerX = bound.left + Math.floor(bound.width / 2)
459
+ }
460
+
461
+ enforceCompositeMargins(diagram, layout.compositeBounds)
462
+ }
463
+
464
+ function innermostCommonCompositeId(
465
+ transition: StateDiagramTransition,
466
+ statesById: Map<string, StateDiagramState>,
467
+ compositesById: Map<string, StateDiagramCompositeState>,
468
+ ): string | undefined {
469
+ const containers = (id: string) => {
470
+ const ids: string[] = []
471
+ let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
472
+ while (parentId) {
473
+ ids.push(parentId)
474
+ parentId = compositesById.get(parentId)?.parentId
475
+ }
476
+ return ids
477
+ }
478
+ const target = new Set(containers(transition.to))
479
+ return containers(transition.from).find((id) => target.has(id))
480
+ }
481
+
482
+ function enforceCompositeMargins(diagram: StateDiagram, compositeBounds: Map<string, StateDiagramBoxBounds>): void {
483
+ const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
484
+ for (const composite of diagram.composites) {
485
+ if (!composite.parentId) continue
486
+ const children = compositesByParent.get(composite.parentId) ?? []
487
+ children.push(composite)
488
+ compositesByParent.set(composite.parentId, children)
489
+ }
490
+
491
+ const expand = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
492
+ const bound = compositeBounds.get(composite.id)
493
+ if (!bound) return undefined
494
+ const children = (compositesByParent.get(composite.id) ?? [])
495
+ .map(expand)
496
+ .filter((child): child is StateDiagramBoxBounds => Boolean(child))
497
+ if (children.length === 0) return bound
498
+ const left = Math.min(bound.left, ...children.map((child) => child.left - 2))
499
+ const top = Math.min(bound.top, ...children.map((child) => child.top - 2))
500
+ const right = Math.max(bound.left + bound.width, ...children.map((child) => child.left + child.width + 2))
501
+ const bottom = Math.max(bound.top + bound.height, ...children.map((child) => child.top + child.height + 2))
502
+ bound.left = left
503
+ bound.top = top
504
+ bound.width = right - left
505
+ bound.height = bottom - top
506
+ bound.centerX = left + Math.floor(bound.width / 2)
507
+ bound.centerY = top + Math.floor(bound.height / 2)
508
+ return bound
509
+ }
510
+
511
+ for (const composite of diagram.composites.filter((candidate) => !candidate.parentId)) expand(composite)
512
+ }
513
+
514
+ function boundsIntersect(left: StateDiagramBoxBounds, right: StateDiagramBoxBounds): boolean {
515
+ return intersects(left.left, left.top, left.width, left.height, right, 0)
516
+ }
517
+
518
+ export function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): boolean {
519
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
520
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
521
+ let shifted = false
522
+
523
+ for (const composite of diagram.composites) {
524
+ const compositeBound = layout.compositeBounds.get(composite.id)
525
+ if (!compositeBound) continue
526
+
527
+ for (const state of diagram.states) {
528
+ if (belongsToComposite(state.id, composite.id, statesById, compositesById)) continue
529
+ const bound = layout.bounds.get(state.id)
530
+ if (!bound || !boundsIntersect(bound, compositeBound)) continue
531
+
532
+ const dx = compositeBound.left + compositeBound.width + 4 - bound.left
533
+ if (dx <= 0) continue
534
+ const leftThreshold = bound.left
535
+ const boundsToShift: StateDiagramBoxBounds[] = []
536
+
537
+ for (const candidate of diagram.states) {
538
+ if (belongsToComposite(candidate.id, composite.id, statesById, compositesById)) continue
539
+ const candidateBound = layout.bounds.get(candidate.id)
540
+ if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
541
+ }
542
+
543
+ for (const candidate of diagram.composites) {
544
+ if (candidate.id === composite.id || belongsToComposite(candidate.id, composite.id, statesById, compositesById))
545
+ continue
546
+ const candidateBound = layout.compositeBounds.get(candidate.id)
547
+ if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
548
+ }
549
+
550
+ for (const noteBound of layout.noteBounds) {
551
+ const target = layout.bounds.get(noteBound.note.target)
552
+ if (target && boundsToShift.includes(target)) boundsToShift.push(noteBound)
553
+ }
554
+
555
+ shiftBounds(uniqueBounds(boundsToShift), dx, 0)
556
+ shifted = true
557
+ }
558
+ }
559
+ return shifted
560
+ }
561
+
562
+ function finalizeLayout(
563
+ diagram: StateDiagram,
564
+ layout: StateDiagramLayout,
565
+ budget: StateSearchBudget,
566
+ ): StateDiagramLayout {
567
+ if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
568
+ addCompositeBounds(diagram, layout)
569
+ normalizeLayout(layout)
570
+ expandCompositeBoundsForInternalRouting(diagram, layout)
571
+ if (diagram.notes.length > 0) {
572
+ const allBounds = [...layout.bounds.values()]
573
+ placeStateDiagramNotesAroundTransitions(
574
+ diagram,
575
+ layout,
576
+ createStateTransitionRenderPlans(
577
+ diagram,
578
+ layout.bounds,
579
+ Math.max(0, ...allBounds.map((bound) => bound.top + bound.height)) + 3,
580
+ {
581
+ feedbackTopY: Math.min(0, ...allBounds.map((bound) => bound.top)) - 3,
582
+ repairRoutes: false,
583
+ searchBudget: budget,
584
+ },
585
+ ),
586
+ budget,
587
+ )
588
+ }
589
+ expandCompositeBoundsForNotes(diagram, layout)
590
+ expandCompositeBoundsForInternalRouting(diagram, layout)
591
+ separateExternalBoundsFromComposites(diagram, layout)
592
+ normalizeLayout(layout)
593
+ return layout
594
+ }
595
+
596
+ export function createStateDiagramLayout(
597
+ diagram: StateDiagram,
598
+ options: StateDiagramLayoutOptions,
599
+ ): StateDiagramLayout {
600
+ const budget = options.searchBudget ?? createStateSearchBudget()
601
+ if (diagram.direction === "LR" || diagram.direction === "RL") {
602
+ return finalizeLayout(diagram, createHorizontalLayout(diagram, options), budget)
603
+ }
604
+
605
+ const ranks = computeRanks(diagram)
606
+ const maxRank = Math.max(0, ...ranks.values())
607
+ const byRank = new Map<number, StateDiagramState[]>()
608
+ for (const state of diagram.states) {
609
+ const rank = diagram.direction === "BT" ? maxRank - (ranks.get(state.id) ?? 0) : (ranks.get(state.id) ?? 0)
610
+ const list = byRank.get(rank) ?? []
611
+ list.push(state)
612
+ byRank.set(rank, list)
613
+ }
614
+
615
+ const rankKeys = [...byRank.keys()].sort((a, b) => a - b)
616
+ const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
617
+ const bounds = new Map<string, StateDiagramBoxBounds>()
618
+ const outgoingLabelRows = new Map<string, number>()
619
+ const selfTransitionCounts = new Map<string, number>()
620
+ for (const transition of diagram.transitions) {
621
+ const rows = measureStateTransitionLabel(transition.label).height
622
+ outgoingLabelRows.set(transition.from, Math.max(outgoingLabelRows.get(transition.from) ?? 0, rows))
623
+ if (transition.from === transition.to) {
624
+ selfTransitionCounts.set(transition.from, (selfTransitionCounts.get(transition.from) ?? 0) + 1)
625
+ }
626
+ }
627
+
628
+ const singleColumnCenter = Math.max(
629
+ 0,
630
+ ...rankKeys.flatMap((rank) => {
631
+ const states = byRank.get(rank)!
632
+ return states.length === 1 ? [Math.floor(sizes.get(states[0]!.id)!.width / 2)] : []
633
+ }),
634
+ )
635
+ let y = 0
636
+ for (const rank of rankKeys) {
637
+ const states = byRank.get(rank)!
638
+ const rowHeight = Math.max(...states.map((state) => sizes.get(state.id)!.height))
639
+ let x = 0
640
+ for (const state of states) {
641
+ const size = sizes.get(state.id)!
642
+ const top = y + Math.floor((rowHeight - size.height) / 2)
643
+ const left = states.length === 1 ? singleColumnCenter - Math.floor(size.width / 2) : x
644
+ bounds.set(state.id, {
645
+ id: state.id,
646
+ left,
647
+ top,
648
+ width: size.width,
649
+ height: size.height,
650
+ centerX: left + Math.floor(size.width / 2),
651
+ centerY: top + Math.floor(size.height / 2),
652
+ })
653
+ x += size.width + options.minStateGap + 8
654
+ }
655
+ const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
656
+ const selfTransitionRows = states.reduce(
657
+ (rows, state) => Math.max(rows, (selfTransitionCounts.get(state.id) ?? 0) * 3 + 1),
658
+ 0,
659
+ )
660
+ const pseudoStateApproachClearance = states.some((state) => state.kind === "choice") ? 2 : 0
661
+ y += rowHeight + Math.max(4, labelRows + 3, selfTransitionRows) + pseudoStateApproachClearance
662
+ }
663
+
664
+ return finalizeLayout(diagram, emptyLayout(bounds, sizes), budget)
665
+ }
666
+
667
+ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayoutOptions): StateDiagramLayout {
668
+ const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
669
+ const bounds = new Map<string, StateDiagramBoxBounds>()
670
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
671
+ const mainPath = computeMainPath(diagram)
672
+ const mainIds = new Set(mainPath)
673
+ const mainPathIndex = new Map(mainPath.map((id, index) => [id, index]))
674
+ const baselineY = Math.max(
675
+ 1,
676
+ ...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height),
677
+ )
678
+ const defaultGap = options.minStateGap + 8
679
+ let x = 0
680
+
681
+ for (const [index, id] of mainPath.entries()) {
682
+ const state = statesById.get(id)
683
+ const size = sizes.get(id)
684
+ if (!state || !size) continue
685
+ const top = state.kind === "state" ? baselineY - Math.floor(size.height / 2) : baselineY
686
+ bounds.set(id, {
687
+ id,
688
+ left: x,
689
+ top,
690
+ width: size.width,
691
+ height: size.height,
692
+ centerX: x + Math.floor(size.width / 2),
693
+ centerY: top + Math.floor(size.height / 2),
694
+ })
695
+ const nextId = mainPath[index + 1]
696
+ const adjacentLabelWidth = diagram.transitions
697
+ .filter((transition) => transition.from === id && transition.to === nextId)
698
+ .reduce((width, transition) => Math.max(width, measureStateTransitionLabel(transition.label).width), 0)
699
+ const crossesCompositeBoundary = Boolean(
700
+ nextId && statesById.get(id)?.parentId !== statesById.get(nextId)?.parentId,
701
+ )
702
+ x += size.width + Math.max(defaultGap, adjacentLabelWidth + (crossesCompositeBoundary ? 6 : 2))
703
+ }
704
+
705
+ const branchesByParent = new Map<string, string[]>()
706
+ for (const transition of diagram.transitions) {
707
+ if (!mainIds.has(transition.from) || mainIds.has(transition.to)) continue
708
+ const list = branchesByParent.get(transition.from) ?? []
709
+ if (!list.includes(transition.to)) list.push(transition.to)
710
+ branchesByParent.set(transition.from, list)
711
+ }
712
+
713
+ for (const [parentId, branchIds] of branchesByParent) {
714
+ const parent = bounds.get(parentId)
715
+ if (!parent) continue
716
+ const branchGap = defaultGap
717
+ const branchSizes = branchIds.map((id) => sizes.get(id)!).filter(Boolean)
718
+ const totalWidth =
719
+ branchSizes.reduce((sum, size) => sum + size.width, 0) + Math.max(0, branchSizes.length - 1) * branchGap
720
+ const parentIndex = mainPathIndex.get(parentId)
721
+ const joinIds = branchIds.map(
722
+ (id) =>
723
+ diagram.transitions.find(
724
+ (transition) =>
725
+ transition.from === id &&
726
+ mainIds.has(transition.to) &&
727
+ parentIndex !== undefined &&
728
+ (mainPathIndex.get(transition.to) ?? -1) > parentIndex + 1,
729
+ )?.to,
730
+ )
731
+ const commonJoin = joinIds[0] && joinIds.every((id) => id === joinIds[0]) ? joinIds[0] : undefined
732
+ const parallelLane = commonJoin && parentIndex !== undefined ? bounds.get(mainPath[parentIndex + 1]!) : undefined
733
+ let left = (parallelLane?.centerX ?? parent.centerX) - Math.floor(totalWidth / 2)
734
+ for (const branchId of branchIds) {
735
+ if (bounds.has(branchId)) continue
736
+ const size = sizes.get(branchId)
737
+ if (!size) continue
738
+ const top = availableStateTop(
739
+ [...bounds.values()],
740
+ left,
741
+ baselineY + (parallelLane ? 6 : 5),
742
+ size.width,
743
+ size.height,
744
+ )
745
+ bounds.set(branchId, {
746
+ id: branchId,
747
+ left,
748
+ top,
749
+ width: size.width,
750
+ height: size.height,
751
+ centerX: left + Math.floor(size.width / 2),
752
+ centerY: top + Math.floor(size.height / 2),
753
+ })
754
+ left += size.width + branchGap
755
+ }
756
+ }
757
+
758
+ const ranks = computeRanks(diagram)
759
+ const fallbackStates = diagram.states
760
+ .filter((state) => !bounds.has(state.id))
761
+ .sort((left, right) => (ranks.get(left.id) ?? 0) - (ranks.get(right.id) ?? 0))
762
+ for (const state of fallbackStates) {
763
+ const size = sizes.get(state.id)!
764
+ const top = baselineY + 5
765
+ const rank = ranks.get(state.id) ?? bounds.size
766
+ let left = rank * (size.width + defaultGap)
767
+ while (true) {
768
+ const collision = [...bounds.values()].find(
769
+ (bound) =>
770
+ left < bound.left + bound.width + defaultGap &&
771
+ left + size.width + defaultGap > bound.left &&
772
+ top < bound.top + bound.height &&
773
+ top + size.height > bound.top,
774
+ )
775
+ if (!collision) break
776
+ left = collision.left + collision.width + defaultGap
777
+ }
778
+ bounds.set(state.id, {
779
+ id: state.id,
780
+ left,
781
+ top,
782
+ width: size.width,
783
+ height: size.height,
784
+ centerX: left + Math.floor(size.width / 2),
785
+ centerY: top + Math.floor(size.height / 2),
786
+ })
787
+ }
788
+
789
+ const minX = Math.min(0, ...[...bounds.values()].map((bound) => bound.left))
790
+ if (minX < 0) {
791
+ for (const bound of bounds.values()) {
792
+ bound.left -= minX
793
+ bound.centerX -= minX
794
+ }
795
+ }
796
+
797
+ if (diagram.direction === "RL") {
798
+ const right = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width))
799
+ for (const bound of bounds.values()) {
800
+ bound.left = right - bound.left - bound.width
801
+ bound.centerX = bound.left + Math.floor(bound.width / 2)
802
+ }
803
+
804
+ const branchLabelGutter = Math.max(
805
+ 0,
806
+ ...diagram.transitions.flatMap((transition) => {
807
+ const from = bounds.get(transition.from)
808
+ const to = bounds.get(transition.to)
809
+ return from && to && from.centerY !== to.centerY
810
+ ? [measureStateTransitionLabel(transition.label).width + 2]
811
+ : []
812
+ }),
813
+ )
814
+ if (branchLabelGutter > 0) {
815
+ shiftBounds(bounds.values(), branchLabelGutter, 0)
816
+ }
817
+ }
818
+
819
+ return emptyLayout(bounds, sizes)
820
+ }
821
+
822
+ function availableStateTop(
823
+ bounds: readonly StateDiagramBoxBounds[],
824
+ left: number,
825
+ top: number,
826
+ width: number,
827
+ height: number,
828
+ ): number {
829
+ let available = top
830
+ while (true) {
831
+ const collision = bounds.find((bound) => intersects(left, available, width, height, bound, 0))
832
+ if (!collision) return available
833
+ available = collision.top + collision.height + 3
834
+ }
835
+ }
836
+
837
+ export function expandCompositeBoundsForFeedback(
838
+ diagram: StateDiagram,
839
+ bounds: Map<string, StateDiagramBoxBounds>,
840
+ compositeBounds: Map<string, StateDiagramBoxBounds>,
841
+ feedbackLaneY: number,
842
+ ): void {
843
+ if (diagram.direction !== "LR" && diagram.direction !== "RL") return
844
+
845
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
846
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
847
+
848
+ for (const composite of diagram.composites) {
849
+ const compositeBound = compositeBounds.get(composite.id)
850
+ if (!compositeBound) continue
851
+
852
+ const hasInternalFeedback = diagram.transitions.some((transition) => {
853
+ if (!belongsToComposite(transition.from, composite.id, statesById, compositesById)) return false
854
+ if (!belongsToComposite(transition.to, composite.id, statesById, compositesById)) return false
855
+ const from = bounds.get(transition.from)
856
+ const to = bounds.get(transition.to)
857
+ return Boolean(from && to && isStateHorizontalFeedback(diagram, from, to))
858
+ })
859
+ if (!hasInternalFeedback) continue
860
+
861
+ const bottom = Math.max(compositeBound.top + compositeBound.height, feedbackLaneY + 2)
862
+ compositeBound.height = bottom - compositeBound.top
863
+ compositeBound.centerY = compositeBound.top + Math.floor(compositeBound.height / 2)
864
+ }
865
+ }
866
+
867
+ function placeStateDiagramNotesAroundTransitions(
868
+ diagram: StateDiagram,
869
+ layout: StateDiagramLayout,
870
+ transitionPlans: readonly StateTransitionRenderPlan[],
871
+ budget: StateSearchBudget,
872
+ ): void {
873
+ if (diagram.notes.length === 0) return
874
+ const compositeIds = new Set(diagram.composites.map((composite) => composite.id))
875
+ let noteSpace = SpatialIndex.empty().add(
876
+ ...[...layout.bounds.values()].flatMap((bound) =>
877
+ compositeIds.has(bound.id) ? [] : [spatialRectClaim(`state:${bound.id}`, `state:${bound.id}`, "body", bound)],
878
+ ),
879
+ )
880
+ let reserved = noteSpace.add(
881
+ ...transitionPlans.flatMap((plan, planIndex) => [
882
+ spatialPathClaim(
883
+ `transition-terminal:${planIndex}:source`,
884
+ `transition-terminal:${planIndex}:source`,
885
+ "route",
886
+ plan.path.slice(0, 2).map(([x, y]) => ({ x, y })),
887
+ ),
888
+ spatialPathClaim(
889
+ `transition-terminal:${planIndex}:target`,
890
+ `transition-terminal:${planIndex}:target`,
891
+ "route",
892
+ plan.path.slice(-2).map(([x, y]) => ({ x, y })),
893
+ ),
894
+ ]),
895
+ )
896
+ let space = reserved.add(
897
+ ...transitionPlans.flatMap((plan, planIndex) => [
898
+ spatialPathClaim(
899
+ `transition:${planIndex}`,
900
+ `transition:${planIndex}`,
901
+ "route",
902
+ plan.path.map(([x, y]) => ({ x, y })),
903
+ ),
904
+ ...(plan.label
905
+ ? [
906
+ spatialRectClaim(`transition-label:${planIndex}`, `transition-label:${planIndex}`, "label", {
907
+ left: plan.label.x,
908
+ top: plan.label.y,
909
+ width: Math.max(...plan.label.lines.map(diagramTextWidth)),
910
+ height: plan.label.lines.length,
911
+ }),
912
+ ]
913
+ : []),
914
+ ]),
915
+ )
916
+ const noteBounds: StateDiagramNoteBounds[] = []
917
+
918
+ for (const [index, note] of diagram.notes.entries()) {
919
+ const target = layout.bounds.get(note.target)
920
+ if (!target) continue
921
+ const size = noteSize(note)
922
+ const gap = 4
923
+ const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap
924
+ const baseTop = target.centerY - Math.floor(size.height / 2)
925
+ const candidates = [
926
+ baseTop,
927
+ target.top - size.height - 2,
928
+ target.top + target.height + 2,
929
+ ...Array.from({ length: 12 }, (_, distance) => [baseTop - distance - 1, baseTop + distance + 1]).flat(),
930
+ ]
931
+ const candidateBounds = Array.from({ length: 5 }, (_, outward) =>
932
+ candidates.map((top) =>
933
+ createNoteBound(
934
+ note,
935
+ index,
936
+ baseLeft + (note.position === "right" ? 1 : -1) * outward * (size.width + 2),
937
+ top,
938
+ size,
939
+ ),
940
+ ),
941
+ ).flat()
942
+ const findPlacement = (candidateSpace: SpatialIndex, limit: number) => {
943
+ const connectorSearch = createStateSearchSpace(candidateSpace, (role) => (role === "label" ? 1 : 0))
944
+ for (const bound of candidateBounds.slice(0, limit)) {
945
+ if (bound.left < 0) continue
946
+ const owner = `note:${index}`
947
+ if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 })) continue
948
+ const connector = findNoteConnector(connectorSearch, bound, target, budget)
949
+ if (connector) return { bound: { ...bound, connector }, connector }
950
+ }
951
+ return undefined
952
+ }
953
+ const placement =
954
+ findPlacement(space, 1) ??
955
+ findPlacement(noteSpace, 1) ??
956
+ findPlacement(space, MAX_STRICT_NOTE_PLACEMENTS) ??
957
+ findPlacement(reserved, candidateBounds.length) ??
958
+ outsideNotePlacement(noteSpace, note, index, target, size)
959
+ const owner = `note:${index}`
960
+ const claims = [
961
+ spatialRectClaim(`${owner}:body`, owner, "body", placement.bound),
962
+ spatialPathClaim(`${owner}:connector`, owner, "boundary", placement.connector.points),
963
+ ] as const
964
+ noteBounds.push(placement.bound)
965
+ noteSpace = noteSpace.add(...claims)
966
+ reserved = reserved.add(...claims)
967
+ space = space.add(...claims)
968
+ }
969
+
970
+ layout.noteBounds.splice(0, layout.noteBounds.length, ...noteBounds)
971
+ }
972
+
973
+ function outsideNotePlacement(
974
+ space: SpatialIndex,
975
+ note: StateDiagramNote,
976
+ index: number,
977
+ target: StateDiagramBoxBounds,
978
+ size: { width: number; height: number; lines: string[] },
979
+ ): { bound: StateDiagramNoteBounds; connector: StateDiagramNoteConnector } {
980
+ const search = createStateSearchSpace(space)
981
+ const top = search.maxY + 4
982
+ const owner = `note:${index}:outside`
983
+
984
+ for (const position of [note.position, note.position === "left" ? "right" : "left"] as const) {
985
+ const aligned = createNoteBound(
986
+ { ...note, position },
987
+ index,
988
+ position === "left" ? search.minX - size.width - 4 : search.maxX + 4,
989
+ target.centerY - Math.floor(size.height / 2),
990
+ size,
991
+ )
992
+ const alignedNoteX = position === "left" ? aligned.left + aligned.width : aligned.left - 1
993
+ const alignedConnectorY = Math.max(aligned.top + 1, Math.min(target.centerY, aligned.top + aligned.height - 2))
994
+ const alignedTargetX = position === "left" ? target.left - 1 : target.left + target.width
995
+ const alignedConnector = {
996
+ connectorY: alignedConnectorY,
997
+ points: [
998
+ { x: alignedTargetX, y: target.centerY },
999
+ { x: alignedNoteX, y: alignedConnectorY },
1000
+ ],
1001
+ }
1002
+ if (
1003
+ space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", aligned), { clearance: 1 }) &&
1004
+ space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", alignedConnector.points))
1005
+ )
1006
+ return { bound: { ...aligned, connector: alignedConnector }, connector: alignedConnector }
1007
+
1008
+ const railX = position === "left" ? search.minX - 2 : search.maxX + 2
1009
+ const bound = createNoteBound(
1010
+ { ...note, position },
1011
+ index,
1012
+ position === "left" ? railX - size.width - 2 : railX + 3,
1013
+ top,
1014
+ size,
1015
+ )
1016
+ const noteX = position === "left" ? bound.left + bound.width : bound.left - 1
1017
+ const connectorY = bound.top + 1
1018
+ const sideX = position === "left" ? target.left - 1 : target.left + target.width
1019
+ const escapes = [
1020
+ ...[target.top, target.centerY, target.top + target.height - 1].map((y) => [
1021
+ { x: sideX, y },
1022
+ { x: railX, y },
1023
+ ]),
1024
+ [
1025
+ { x: target.centerX, y: target.top - 1 },
1026
+ { x: railX, y: target.top - 1 },
1027
+ ],
1028
+ [
1029
+ { x: target.centerX, y: target.top + target.height },
1030
+ { x: railX, y: target.top + target.height },
1031
+ ],
1032
+ ]
1033
+
1034
+ for (const escape of escapes) {
1035
+ const connector = {
1036
+ connectorY,
1037
+ points: [...escape, { x: railX, y: connectorY }, { x: noteX, y: connectorY }],
1038
+ }
1039
+ if (
1040
+ space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) &&
1041
+ space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points))
1042
+ )
1043
+ return { bound: { ...bound, connector }, connector }
1044
+ }
1045
+ }
1046
+
1047
+ for (const vertical of ["below", "above"] as const) {
1048
+ const railY = vertical === "below" ? search.maxY + 2 : search.minY - 2
1049
+ const bound = createNoteBound(
1050
+ note,
1051
+ index,
1052
+ note.position === "left" ? search.minX - size.width - 4 : search.maxX + 4,
1053
+ vertical === "below" ? railY + 2 : railY - size.height - 2,
1054
+ size,
1055
+ )
1056
+ const noteX = note.position === "left" ? bound.left + bound.width : bound.left - 1
1057
+ const connectorY = bound.top + 1
1058
+ const targetY = vertical === "below" ? target.top + target.height : target.top - 1
1059
+ const escapes = [
1060
+ ...[target.left, target.centerX, target.left + target.width - 1].map((x) => [
1061
+ { x, y: targetY },
1062
+ { x, y: railY },
1063
+ ]),
1064
+ [
1065
+ { x: target.left, y: targetY },
1066
+ { x: target.left - 1, y: targetY },
1067
+ { x: target.left - 1, y: railY },
1068
+ ],
1069
+ [
1070
+ { x: target.left + target.width - 1, y: targetY },
1071
+ { x: target.left + target.width, y: targetY },
1072
+ { x: target.left + target.width, y: railY },
1073
+ ],
1074
+ ]
1075
+
1076
+ for (const escape of escapes) {
1077
+ const connector = {
1078
+ connectorY,
1079
+ points: [...escape, { x: noteX, y: railY }, { x: noteX, y: connectorY }],
1080
+ }
1081
+ if (
1082
+ space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) &&
1083
+ space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points))
1084
+ )
1085
+ return { bound: { ...bound, connector }, connector }
1086
+ }
1087
+ }
1088
+
1089
+ throw new Error(`State ${note.target} has no exterior note corridor`)
1090
+ }
1091
+
1092
+ export function expandCompositeBoundsForInternalTransitions(
1093
+ diagram: StateDiagram,
1094
+ compositeBounds: Map<string, StateDiagramBoxBounds>,
1095
+ transitionPlans: readonly StateTransitionRenderPlan[],
1096
+ ): void {
1097
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
1098
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
1099
+
1100
+ for (const composite of diagram.composites) {
1101
+ const bound = compositeBounds.get(composite.id)
1102
+ if (!bound) continue
1103
+ const internalPlans = transitionPlans.filter(
1104
+ (plan) =>
1105
+ belongsToComposite(plan.route.transition.from, composite.id, statesById, compositesById) &&
1106
+ belongsToComposite(plan.route.transition.to, composite.id, statesById, compositesById),
1107
+ )
1108
+ const occupied = internalPlans.flatMap((plan) => [
1109
+ ...plan.cells.map((cell) => ({ x: cell.x, y: cell.y })),
1110
+ ...(plan.label
1111
+ ? plan.label.lines.flatMap((line, row) =>
1112
+ Array.from({ length: diagramTextWidth(line) }, (_, column) => ({
1113
+ x: plan.label!.x + column,
1114
+ y: plan.label!.y + row,
1115
+ })),
1116
+ )
1117
+ : []),
1118
+ ])
1119
+ if (occupied.length === 0) continue
1120
+
1121
+ const left = Math.min(bound.left, Math.min(...occupied.map((point) => point.x)) - 1)
1122
+ const top = Math.min(bound.top, Math.min(...occupied.map((point) => point.y)) - 1)
1123
+ const right = Math.max(bound.left + bound.width, Math.max(...occupied.map((point) => point.x)) + 2)
1124
+ const bottom = Math.max(bound.top + bound.height, Math.max(...occupied.map((point) => point.y)) + 2)
1125
+ bound.left = left
1126
+ bound.top = top
1127
+ bound.width = right - left
1128
+ bound.height = bottom - top
1129
+ bound.centerX = bound.left + Math.floor(bound.width / 2)
1130
+ bound.centerY = bound.top + Math.floor(bound.height / 2)
1131
+ }
1132
+
1133
+ enforceCompositeMargins(diagram, compositeBounds)
1134
+ }