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,1057 @@
1
+ import {
2
+ diagramBoundsFromBounds,
3
+ diagramBoundsFromPoints,
4
+ segmentBetween,
5
+ segmentSpan,
6
+ translateDiagramBounds,
7
+ } from "../core/geometry.js"
8
+ import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
9
+ import {
10
+ flowchartEdgeLabelLayout,
11
+ flowchartHorizontalLabelRankGap,
12
+ flowchartLabelWidth,
13
+ flowchartRouteLabelLayout,
14
+ flowchartVerticalBranchLabelGap,
15
+ } from "./labels.js"
16
+ import type { FlowchartDiagramRenderOptions } from "./options.js"
17
+ import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js"
18
+ import type {
19
+ FlowchartDiagram,
20
+ FlowchartDirection,
21
+ FlowchartEdge,
22
+ FlowchartEdgeRoute,
23
+ FlowchartNode,
24
+ FlowchartNodeBounds,
25
+ FlowchartNodeSize,
26
+ FlowchartPoint,
27
+ FlowchartSubgraphBounds,
28
+ } from "./types.js"
29
+
30
+ export const DEFAULT_MIN_NODE_GAP = 5
31
+ export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
32
+ const DEFAULT_MAX_UNLABELED_RANK_WIDTH = 120
33
+ export const DEFAULT_MIN_RANK_GAP = 7
34
+ export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
35
+ export const COMPACT_MIN_RANK_GAP = 4
36
+ export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
37
+ const SUBGRAPH_PADDING_X = 2
38
+ const SUBGRAPH_PADDING_TOP = 1
39
+ const SUBGRAPH_PADDING_BOTTOM = 1
40
+
41
+ export interface FlowchartLayout {
42
+ diagram: FlowchartDiagram
43
+ bounds: Map<string, FlowchartNodeBounds>
44
+ routes: FlowchartEdgeRoute[]
45
+ subgraphBounds: Map<string, FlowchartSubgraphBounds>
46
+ width: number
47
+ height: number
48
+ }
49
+
50
+ type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
51
+
52
+ function horizontalRankGaps(
53
+ diagram: FlowchartDiagram,
54
+ normalizedRanks: ReadonlyMap<string, number>,
55
+ rankKeys: readonly number[],
56
+ fallback: number,
57
+ ): number[] {
58
+ const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
59
+ const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
60
+
61
+ for (const edge of diagram.edges) {
62
+ if (!edge.label) continue
63
+ const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
64
+ const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
65
+ if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
66
+
67
+ const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
68
+ for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
69
+ gaps[index] = Math.max(gaps[index]!, labelGap)
70
+ }
71
+ }
72
+
73
+ return gaps
74
+ }
75
+
76
+ function verticalRankGaps(
77
+ diagram: FlowchartDiagram,
78
+ normalizedRanks: ReadonlyMap<string, number>,
79
+ rankKeys: readonly number[],
80
+ fallback: number,
81
+ ): number[] {
82
+ const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
83
+ const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
84
+
85
+ for (const edge of diagram.edges) {
86
+ if (!edge.label) continue
87
+ const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
88
+ const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
89
+ if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
90
+
91
+ const labelHeight = splitDiagramLines(edge.label).length
92
+ for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
93
+ gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
94
+ }
95
+ }
96
+
97
+ return gaps
98
+ }
99
+
100
+ function isHorizontalDirection(direction: FlowchartDirection): boolean {
101
+ return direction === "LR" || direction === "RL"
102
+ }
103
+
104
+ export function visualLength(value: string): number {
105
+ return diagramTextWidth(value)
106
+ }
107
+
108
+ export function normalizePositiveInt(value: number | undefined, fallback: number): number {
109
+ if (value === undefined || !Number.isFinite(value)) return fallback
110
+ return Math.max(1, Math.trunc(value))
111
+ }
112
+
113
+ function nodeSize(node: FlowchartNode): FlowchartNodeSize {
114
+ const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
115
+ const innerWidth = width - 4
116
+ if (node.shape === "decision") {
117
+ const width = innerWidth + 6
118
+ return {
119
+ width: width % 2 === 0 ? width + 1 : width,
120
+ height: Math.max(5, lines.length + 4),
121
+ lines,
122
+ }
123
+ }
124
+ if (node.shape === "circle") return { width: Math.max(9, innerWidth + 6), height: Math.max(5, lines.length + 4), lines }
125
+ if (node.shape === "hexagon") return { width: Math.max(9, innerWidth + 6), height: Math.max(5, lines.length + 4), lines }
126
+ if (node.shape === "parallelogram") return { width: innerWidth + 6, height: lines.length + 2, lines }
127
+ if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
128
+ if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
129
+ return { width: innerWidth + 4, height: lines.length + 2, lines }
130
+ }
131
+
132
+ function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
133
+ const ranks = new Map<string, number>()
134
+ const outgoing = new Map<string, string[]>()
135
+ const incoming = new Set<string>()
136
+ const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
137
+
138
+ const nodeIds = new Set(diagram.nodes.map((node) => node.id))
139
+ const rankEdges = diagram.edges.flatMap((edge) => {
140
+ if (!edge.orderOnly) return [edge]
141
+ const fromIds = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)]
142
+ const toIds = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)]
143
+ return fromIds.flatMap((from) => toIds.map((to) => ({ ...edge, from, to })))
144
+ })
145
+
146
+ for (const edge of rankEdges) {
147
+ const list = outgoing.get(edge.from) ?? []
148
+ list.push(edge.to)
149
+ outgoing.set(edge.from, list)
150
+ incoming.add(edge.to)
151
+ incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
152
+ }
153
+
154
+ const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
155
+ if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
156
+
157
+ const queue = starts.map((node) => node.id)
158
+ for (const node of starts) ranks.set(node.id, 0)
159
+
160
+ for (let index = 0; index < queue.length; index++) {
161
+ const id = queue[index]!
162
+ const rank = ranks.get(id) ?? 0
163
+ for (const to of outgoing.get(id) ?? []) {
164
+ const nextRank = rank + 1
165
+ if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
166
+ ranks.set(to, nextRank)
167
+ queue.push(to)
168
+ }
169
+ }
170
+
171
+ for (const node of diagram.nodes) {
172
+ if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
173
+ }
174
+
175
+ const acyclicRanks = new Map<string, number>()
176
+ const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
177
+ for (const id of acyclicQueue) acyclicRanks.set(id, 0)
178
+ for (let index = 0; index < acyclicQueue.length; index++) {
179
+ const id = acyclicQueue[index]!
180
+ const rank = acyclicRanks.get(id) ?? 0
181
+ for (const to of outgoing.get(id) ?? []) {
182
+ acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
183
+ const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
184
+ incomingCounts.set(to, remainingIncoming)
185
+ if (remainingIncoming === 0) acyclicQueue.push(to)
186
+ }
187
+ }
188
+ for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
189
+
190
+ return ranks
191
+ }
192
+
193
+ function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
194
+ translateDiagramBounds(bounds, dx, dy)
195
+ }
196
+
197
+ function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
198
+ for (const route of routes) {
199
+ for (const point of route.points) {
200
+ point.x += dx
201
+ point.y += dy
202
+ }
203
+ if (route.labelPoint) {
204
+ route.labelPoint.x += dx
205
+ route.labelPoint.y += dy
206
+ }
207
+ }
208
+ }
209
+
210
+ function freezeRouteLabelPoints(routes: readonly FlowchartEdgeRoute[]): void {
211
+ for (const route of routes) {
212
+ if (!route.edge.label || route.labelPoint) continue
213
+ route.labelPoint = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis).point
214
+ }
215
+ }
216
+
217
+ function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
218
+ return diagramBoundsFromBounds(children)
219
+ }
220
+
221
+ function subgraphBoundFromChildren(
222
+ id: string,
223
+ label: string,
224
+ children: readonly FlowchartBounds[],
225
+ ): FlowchartSubgraphBounds {
226
+ const labelLines = splitDiagramLines(label)
227
+ const labelHeight = labelLines.length
228
+ let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
229
+ const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
230
+ let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
231
+ const bottom =
232
+ Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
233
+ const minWidth = Math.max(...labelLines.map(visualLength)) + 5
234
+
235
+ if (right - left < minWidth) {
236
+ const extra = minWidth - (right - left)
237
+ left -= Math.floor(extra / 2)
238
+ right += Math.ceil(extra / 2)
239
+ }
240
+
241
+ const width = right - left
242
+ const height = Math.max(3, bottom - top)
243
+ return {
244
+ id,
245
+ label,
246
+ left,
247
+ top,
248
+ width,
249
+ height,
250
+ centerX: left + Math.floor(width / 2),
251
+ centerY: top + Math.floor(height / 2),
252
+ labelSide: "top",
253
+ }
254
+ }
255
+
256
+ function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
257
+ return leftStart <= rightEnd && rightStart <= leftEnd
258
+ }
259
+
260
+ function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
261
+ const lines = splitDiagramLines(bounds.label)
262
+ const left = bounds.left + 2
263
+ const height = lines.length
264
+ const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
265
+ const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
266
+ return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
267
+ }
268
+
269
+ function segmentOverlapsSlot(
270
+ from: { x: number; y: number },
271
+ to: { x: number; y: number },
272
+ slot: FlowchartBounds,
273
+ ): boolean {
274
+ const segment = segmentBetween(from, to)
275
+ if (!segment) return false
276
+
277
+ const slotRight = slot.left + slot.width - 1
278
+ const slotBottom = slot.top + slot.height - 1
279
+ const span = segmentSpan(segment)
280
+ if (segment.axis === "x") {
281
+ return (
282
+ segment.from.y >= slot.top &&
283
+ segment.from.y <= slotBottom &&
284
+ spansOverlap(span.start, span.end, slot.left, slotRight)
285
+ )
286
+ }
287
+ return (
288
+ segment.from.x >= slot.left &&
289
+ segment.from.x <= slotRight &&
290
+ spansOverlap(span.start, span.end, slot.top, slotBottom)
291
+ )
292
+ }
293
+
294
+ function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
295
+ for (let index = 1; index < route.points.length; index++) {
296
+ if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
297
+ }
298
+
299
+ const routeLabelBounds = labelBounds(route)
300
+ if (
301
+ !routeLabelBounds ||
302
+ !spansOverlap(
303
+ routeLabelBounds.top,
304
+ routeLabelBounds.top + routeLabelBounds.height - 1,
305
+ slot.top,
306
+ slot.top + slot.height - 1,
307
+ )
308
+ ) {
309
+ return false
310
+ }
311
+ return spansOverlap(
312
+ routeLabelBounds.left,
313
+ routeLabelBounds.left + routeLabelBounds.width - 1,
314
+ slot.left,
315
+ slot.left + slot.width - 1,
316
+ )
317
+ }
318
+
319
+ function chooseSubgraphLabelSide(
320
+ bounds: FlowchartSubgraphBounds,
321
+ routes: readonly FlowchartEdgeRoute[],
322
+ ): FlowchartSubgraphBounds["labelSide"] {
323
+ const topSlot = labelSlot(bounds, "top")
324
+ if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
325
+
326
+ const bottomSlot = labelSlot(bounds, "bottom")
327
+ return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
328
+ }
329
+
330
+ function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
331
+ return diagramBoundsFromPoints(points)
332
+ }
333
+
334
+ function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
335
+ if (!route.edge.label) return undefined
336
+ const label = flowchartRouteLabelLayout(route, visualLength)
337
+ const { point, width, height } = label
338
+ return {
339
+ left: point.x,
340
+ top: point.y,
341
+ width,
342
+ height,
343
+ centerX: point.x + Math.floor(width / 2),
344
+ centerY: point.y + Math.floor(height / 2),
345
+ }
346
+ }
347
+
348
+ function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
349
+ return routeRenderBounds(
350
+ routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
351
+ )
352
+ }
353
+
354
+ function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
355
+ const bounds: FlowchartBounds[] = []
356
+ for (const route of routes) {
357
+ const routeBounds = pathBounds(route.points)
358
+ if (routeBounds) bounds.push(routeBounds)
359
+ const routeLabelBounds = labelBounds(route)
360
+ if (routeLabelBounds) bounds.push(routeLabelBounds)
361
+ }
362
+ return bounds
363
+ }
364
+
365
+ function layoutRankedNodes(
366
+ diagram: FlowchartDiagram,
367
+ direction: FlowchartDirection,
368
+ sizes: ReadonlyMap<string, FlowchartNodeSize>,
369
+ minNodeGap: number,
370
+ requestedMinRankGap: number,
371
+ targetWidth?: number,
372
+ ): { bounds: Map<string, FlowchartNodeBounds>; wrapped: boolean } {
373
+ const horizontal = isHorizontalDirection(direction)
374
+ const ranks = rankNodes(diagram)
375
+ const maxRank = Math.max(0, ...ranks.values())
376
+ const ranksByIndex = new Map<number, FlowchartNode[]>()
377
+ const normalizedRanks = new Map<string, number>()
378
+
379
+ for (const node of diagram.nodes) {
380
+ const rank = ranks.get(node.id) ?? 0
381
+ const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
382
+ normalizedRanks.set(node.id, normalizedRank)
383
+ const nodes = ranksByIndex.get(normalizedRank) ?? []
384
+ nodes.push(node)
385
+ ranksByIndex.set(normalizedRank, nodes)
386
+ }
387
+
388
+ const spaciousNodeGap = Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP)
389
+ const widestUnlabeledRank = Math.max(
390
+ 0,
391
+ ...[...ranksByIndex.values()].map(
392
+ (nodes) =>
393
+ nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
394
+ Math.max(0, nodes.length - 1) * spaciousNodeGap,
395
+ ),
396
+ )
397
+ const verticalNodeGap = (rank: number): number => {
398
+ const widestLabel = Math.max(
399
+ 0,
400
+ ...diagram.edges
401
+ .filter(
402
+ (edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
403
+ )
404
+ .map((edge) => flowchartLabelWidth(edge.label, visualLength)),
405
+ )
406
+ if (widestLabel > 0) return Math.max(spaciousNodeGap, flowchartVerticalBranchLabelGap(widestLabel))
407
+ return widestUnlabeledRank > DEFAULT_MAX_UNLABELED_RANK_WIDTH ? minNodeGap : spaciousNodeGap
408
+ }
409
+
410
+ const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
411
+ const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
412
+ const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
413
+ const bounds = new Map<string, FlowchartNodeBounds>()
414
+ let wrapped = false
415
+
416
+ if (horizontal) {
417
+ const columnWidths = rankKeys.map((rank) =>
418
+ Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
419
+ )
420
+ const columnHeights = rankKeys.map((rank) => {
421
+ const nodes = ranksByIndex.get(rank)!
422
+ return (
423
+ nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
424
+ Math.max(0, nodes.length - 1) * minNodeGap
425
+ )
426
+ })
427
+ const canvasHeight = Math.max(1, ...columnHeights)
428
+ const centerBaseline = Math.floor(canvasHeight / 2)
429
+ let x = 0
430
+ for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
431
+ const rank = rankKeys[rankIndex]!
432
+ const nodes = ranksByIndex.get(rank)!
433
+ const columnWidth = columnWidths[rankIndex]!
434
+ let y = centerBaseline - Math.floor(columnHeights[rankIndex]! / 2)
435
+ for (const node of nodes) {
436
+ const size = sizes.get(node.id)!
437
+ const left = x + Math.floor((columnWidth - size.width) / 2)
438
+ bounds.set(node.id, {
439
+ id: node.id,
440
+ ...size,
441
+ left,
442
+ top: y,
443
+ centerX: left + Math.floor(size.width / 2),
444
+ centerY: y + Math.floor(size.height / 2),
445
+ })
446
+ y += size.height + minNodeGap
447
+ }
448
+ x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
449
+ }
450
+ } else {
451
+ const rankBands = rankKeys.map((rank) => {
452
+ const nodes = ranksByIndex.get(rank)!
453
+ const roomyNodeGap = verticalNodeGap(rank)
454
+ const naturalWidth =
455
+ nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
456
+ Math.max(0, nodes.length - 1) * roomyNodeGap
457
+ const labeledEdges = diagram.edges.filter(
458
+ (edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
459
+ )
460
+ const needsLabelLanes =
461
+ labeledEdges.length > 1 &&
462
+ labeledEdges.some((edge) => {
463
+ const targets = new Set(
464
+ labeledEdges.filter((candidate) => candidate.from === edge.from).map((candidate) => candidate.to),
465
+ )
466
+ const sources = new Set(
467
+ labeledEdges.filter((candidate) => candidate.to === edge.to).map((candidate) => candidate.from),
468
+ )
469
+ const grouped = (ids: readonly string[]) =>
470
+ !diagram.subgraphs?.length ||
471
+ diagram.subgraphs.some((subgraph) => ids.every((id) => subgraph.nodeIds.includes(id)))
472
+ return (
473
+ (targets.size > 1 && grouped([edge.from, ...targets])) ||
474
+ (sources.size > 1 && grouped([edge.to, ...sources]))
475
+ )
476
+ })
477
+ const nodeGap =
478
+ targetWidth !== undefined && naturalWidth > targetWidth && (!needsLabelLanes || !diagram.subgraphs?.length)
479
+ ? minNodeGap
480
+ : roomyNodeGap
481
+ const bands: { nodes: FlowchartNode[]; width: number; height: number }[] = []
482
+ for (const node of nodes) {
483
+ const size = sizes.get(node.id)!
484
+ const current = bands.at(-1)
485
+ const width = current ? current.width + nodeGap + size.width : size.width
486
+ if (current && targetWidth !== undefined && width > targetWidth) {
487
+ wrapped = true
488
+ bands.push({ nodes: [node], width: size.width, height: size.height })
489
+ continue
490
+ }
491
+ if (!current) {
492
+ bands.push({ nodes: [node], width: size.width, height: size.height })
493
+ continue
494
+ }
495
+ current.nodes.push(node)
496
+ current.width = width
497
+ current.height = Math.max(current.height, size.height)
498
+ }
499
+ return { bands, nodeGap }
500
+ })
501
+ const canvasWidth = Math.max(1, ...rankBands.flatMap((rank) => rank.bands.map((band) => band.width)))
502
+ let y = 0
503
+ for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
504
+ const rank = rankBands[rankIndex]!
505
+ for (const [bandIndex, band] of rank.bands.entries()) {
506
+ let x = Math.floor((canvasWidth - band.width) / 2)
507
+ for (const node of band.nodes) {
508
+ const size = sizes.get(node.id)!
509
+ const top = y + Math.floor((band.height - size.height) / 2)
510
+ bounds.set(node.id, {
511
+ id: node.id,
512
+ ...size,
513
+ left: x,
514
+ top,
515
+ centerX: x + Math.floor(size.width / 2),
516
+ centerY: top + Math.floor(size.height / 2),
517
+ })
518
+ x += size.width + rank.nodeGap
519
+ }
520
+ y += band.height + (bandIndex < rank.bands.length - 1 ? minNodeGap : 0)
521
+ }
522
+ y += verticalGaps[rankIndex] ?? 0
523
+ }
524
+ }
525
+
526
+ return { bounds, wrapped }
527
+ }
528
+
529
+ function layoutLocalSubgraphDirections(
530
+ diagram: FlowchartDiagram,
531
+ nodeBounds: Map<string, FlowchartNodeBounds>,
532
+ minNodeGap: number,
533
+ requestedMinRankGap: number,
534
+ targetWidth?: number,
535
+ ): boolean {
536
+ let wrapped = false
537
+ const subgraphs = diagram.subgraphs ?? []
538
+ const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
539
+ for (const subgraph of [...subgraphs].reverse()) {
540
+ let parent = subgraph.parentId ? subgraphById.get(subgraph.parentId) : undefined
541
+ while (parent && !parent.direction) parent = parent.parentId ? subgraphById.get(parent.parentId) : undefined
542
+ const direction =
543
+ subgraph.direction ??
544
+ (parent && subgraphs.filter((child) => child.parentId === parent.id).length > 1 ? parent.direction : undefined)
545
+ if (!direction || direction === diagram.direction) continue
546
+ const childSubgraphs = subgraphs.filter((child) => child.parentId === subgraph.id)
547
+ const coveredNodeIds = new Set(childSubgraphs.flatMap((child) => [...collectSubgraphNodeIds(diagram, child.id)]))
548
+ const items = [
549
+ ...childSubgraphs.flatMap((child) => {
550
+ const nodeIds = [...collectSubgraphNodeIds(diagram, child.id)]
551
+ const content = boundsFromChildren(nodeIds.flatMap((id) => nodeBounds.get(id) ?? []))
552
+ const bounds = content ? subgraphBoundFromChildren(child.id, child.label, [content]) : undefined
553
+ return bounds ? [{ id: `subgraph:${child.id}`, nodeIds, bounds, childId: child.id }] : []
554
+ }),
555
+ ...subgraph.nodeIds.flatMap((id) => {
556
+ if (coveredNodeIds.has(id)) return []
557
+ const bounds = nodeBounds.get(id)
558
+ return bounds ? [{ id, nodeIds: [id], bounds, childId: undefined }] : []
559
+ }),
560
+ ]
561
+ if (items.length === 0) continue
562
+
563
+ const currentBounds = boundsFromChildren(items.map((item) => item.bounds))
564
+ if (!currentBounds) continue
565
+
566
+ const itemByEndpoint = new Map<string, string>()
567
+ for (const item of items) {
568
+ for (const nodeId of item.nodeIds) itemByEndpoint.set(nodeId, item.id)
569
+ if (item.childId) itemByEndpoint.set(item.childId, item.id)
570
+ }
571
+ const nodes = items.map((item): FlowchartNode => ({ id: item.id, label: item.id, shape: "box" }))
572
+ const localDiagram: FlowchartDiagram = {
573
+ direction,
574
+ nodes,
575
+ edges: diagram.edges.flatMap((edge) => {
576
+ const from = itemByEndpoint.get(edge.from)
577
+ const to = itemByEndpoint.get(edge.to)
578
+ return from && to && from !== to ? [{ ...edge, from, to }] : []
579
+ }),
580
+ subgraphs: [],
581
+ }
582
+ const itemSizes = new Map(
583
+ items.map((item) => [item.id, { width: item.bounds.width, height: item.bounds.height, lines: [item.id] }]),
584
+ )
585
+ const localLayout = layoutRankedNodes(
586
+ localDiagram,
587
+ direction,
588
+ itemSizes,
589
+ Math.max(minNodeGap, SUBGRAPH_PADDING_X * 2 + 1),
590
+ requestedMinRankGap,
591
+ targetWidth,
592
+ )
593
+ const localBounds = localLayout.bounds
594
+ wrapped ||= localLayout.wrapped
595
+ const localExtent = boundsFromChildren([...localBounds.values()])
596
+ if (!localExtent) continue
597
+
598
+ const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
599
+ const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
600
+ const dx = targetLeft - localExtent.left
601
+ const dy = targetTop - localExtent.top
602
+
603
+ const translations = new Map<string, { dx: number; dy: number }>()
604
+ for (const item of items) {
605
+ const bound = localBounds.get(item.id)!
606
+ const itemDx = bound.left + dx - item.bounds.left
607
+ const itemDy = bound.top + dy - item.bounds.top
608
+ for (const nodeId of item.nodeIds) translations.set(nodeId, { dx: itemDx, dy: itemDy })
609
+ }
610
+
611
+ let groupOffset = { x: 0, y: 0 }
612
+ if (targetWidth !== undefined) {
613
+ const localNodeIds = new Set(translations.keys())
614
+ const external = [...nodeBounds.entries()].filter(([id]) => !localNodeIds.has(id)).map(([, bound]) => bound)
615
+ const overlaps = (offset: FlowchartPoint) =>
616
+ [...translations].some(([id, translation]) => {
617
+ const bound = nodeBounds.get(id)!
618
+ const left = bound.left + translation.dx + offset.x
619
+ const top = bound.top + translation.dy + offset.y
620
+ return external.some(
621
+ (other) =>
622
+ left < other.left + other.width + minNodeGap &&
623
+ left + bound.width + minNodeGap > other.left &&
624
+ top < other.top + other.height + minNodeGap &&
625
+ top + bound.height + minNodeGap > other.top,
626
+ )
627
+ })
628
+ if (overlaps(groupOffset)) {
629
+ const vertical = !isHorizontalDirection(diagram.direction)
630
+ const sign = diagram.direction === "RL" || diagram.direction === "BT" ? -1 : 1
631
+ let found = false
632
+ search: for (let distance = 1; distance < 1_000; distance++) {
633
+ const candidates = vertical
634
+ ? [
635
+ { x: 0, y: sign * distance },
636
+ { x: 0, y: -sign * distance },
637
+ { x: distance, y: 0 },
638
+ { x: -distance, y: 0 },
639
+ ]
640
+ : [
641
+ { x: sign * distance, y: 0 },
642
+ { x: -sign * distance, y: 0 },
643
+ { x: 0, y: distance },
644
+ { x: 0, y: -distance },
645
+ ]
646
+ for (const candidate of candidates) {
647
+ if (overlaps(candidate)) continue
648
+ groupOffset = candidate
649
+ found = true
650
+ break search
651
+ }
652
+ }
653
+ if (!found) throw new Error(`Subgraph ${subgraph.id} has no collision-free responsive position`)
654
+ }
655
+ }
656
+
657
+ for (const [nodeId, translation] of translations) {
658
+ const nodeBound = nodeBounds.get(nodeId)
659
+ if (nodeBound) translateBounds(nodeBound, translation.dx + groupOffset.x, translation.dy + groupOffset.y)
660
+ }
661
+ }
662
+ return wrapped
663
+ }
664
+
665
+ function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
666
+ for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
667
+ if (!subgraph.direction) continue
668
+ if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
669
+ }
670
+ return diagram.direction
671
+ }
672
+
673
+ function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
674
+ const nodeIds = new Set<string>()
675
+ for (const subgraph of diagram.subgraphs ?? []) {
676
+ if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
677
+ for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
678
+ if (subgraph.parentId === subgraphId) {
679
+ for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
680
+ }
681
+ }
682
+ return nodeIds
683
+ }
684
+
685
+ function rankGraphComponents(
686
+ ids: readonly string[],
687
+ outgoing: ReadonlyMap<string, ReadonlySet<string>>,
688
+ ): Map<string, number> {
689
+ const reachable = new Map<string, Set<string>>()
690
+ for (const id of ids) {
691
+ const seen = new Set<string>()
692
+ const queue = [id]
693
+ for (let index = 0; index < queue.length; index++) {
694
+ const current = queue[index]!
695
+ if (seen.has(current)) continue
696
+ seen.add(current)
697
+ queue.push(...(outgoing.get(current) ?? []))
698
+ }
699
+ reachable.set(id, seen)
700
+ }
701
+
702
+ const componentById = new Map<string, number>()
703
+ const components: string[][] = []
704
+ for (const id of ids) {
705
+ if (componentById.has(id)) continue
706
+ const component = ids.filter(
707
+ (candidate) =>
708
+ !componentById.has(candidate) && reachable.get(id)!.has(candidate) && reachable.get(candidate)!.has(id),
709
+ )
710
+ const componentIndex = components.length
711
+ components.push(component)
712
+ for (const member of component) componentById.set(member, componentIndex)
713
+ }
714
+
715
+ const componentOutgoing = new Map(components.map((_, index) => [index, new Set<number>()]))
716
+ const incoming = new Map(components.map((_, index) => [index, 0]))
717
+ for (const [from, targets] of outgoing) {
718
+ const fromComponent = componentById.get(from)!
719
+ for (const to of targets) {
720
+ const toComponent = componentById.get(to)!
721
+ if (fromComponent === toComponent || componentOutgoing.get(fromComponent)!.has(toComponent)) continue
722
+ componentOutgoing.get(fromComponent)!.add(toComponent)
723
+ incoming.set(toComponent, incoming.get(toComponent)! + 1)
724
+ }
725
+ }
726
+
727
+ const componentRanks = new Map<number, number>()
728
+ const queue = components.map((_, index) => index).filter((index) => incoming.get(index) === 0)
729
+ for (const component of queue) componentRanks.set(component, 0)
730
+ for (let index = 0; index < queue.length; index++) {
731
+ const component = queue[index]!
732
+ for (const to of componentOutgoing.get(component)!) {
733
+ componentRanks.set(to, Math.max(componentRanks.get(to) ?? 0, componentRanks.get(component)! + 1))
734
+ incoming.set(to, incoming.get(to)! - 1)
735
+ if (incoming.get(to) === 0) queue.push(to)
736
+ }
737
+ }
738
+
739
+ return new Map(ids.map((id) => [id, componentRanks.get(componentById.get(id)!) ?? 0]))
740
+ }
741
+
742
+ function separateTopLevelItems(
743
+ diagram: FlowchartDiagram,
744
+ nodeBounds: Map<string, FlowchartNodeBounds>,
745
+ subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
746
+ gap: number,
747
+ targetWidth?: number,
748
+ ): boolean {
749
+ const hasLocalDirection = (diagram.subgraphs ?? []).some(
750
+ (subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
751
+ )
752
+ const coveredNodeIds = new Set<string>()
753
+ const items: {
754
+ id: string
755
+ bounds: FlowchartBounds
756
+ nodeIds: Set<string>
757
+ rank: number
758
+ }[] = []
759
+ const itemByEndpoint = new Map<string, string>()
760
+ const subgraphs = diagram.subgraphs ?? []
761
+ const topLevelIds = new Set(subgraphs.filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id))
762
+ const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
763
+ const topLevelSubgraphId = (id: string): string => {
764
+ let current = subgraphById.get(id)
765
+ while (current?.parentId) current = subgraphById.get(current.parentId)
766
+ return current?.id ?? id
767
+ }
768
+ for (const subgraph of subgraphs) {
769
+ if (subgraph.parentId) continue
770
+ const bounds = subgraphBounds.get(subgraph.id)
771
+ const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
772
+ if (!bounds || nodeIds.size === 0) continue
773
+ items.push({ id: subgraph.id, bounds, nodeIds, rank: 0 })
774
+ itemByEndpoint.set(subgraph.id, subgraph.id)
775
+ for (const nodeId of nodeIds) {
776
+ coveredNodeIds.add(nodeId)
777
+ itemByEndpoint.set(nodeId, subgraph.id)
778
+ }
779
+ }
780
+ for (const subgraph of subgraphs) itemByEndpoint.set(subgraph.id, topLevelSubgraphId(subgraph.id))
781
+
782
+ for (const node of diagram.nodes) {
783
+ if (coveredNodeIds.has(node.id)) continue
784
+ const bounds = nodeBounds.get(node.id)
785
+ if (!bounds) continue
786
+ items.push({ id: node.id, bounds, nodeIds: new Set([node.id]), rank: 0 })
787
+ itemByEndpoint.set(node.id, node.id)
788
+ }
789
+ if (items.length < 2) return false
790
+
791
+ const horizontal = isHorizontalDirection(diagram.direction)
792
+ const moveItem = (item: (typeof items)[number], dx: number, dy: number): void => {
793
+ for (const nodeId of item.nodeIds) {
794
+ const bounds = nodeBounds.get(nodeId)
795
+ if (bounds) translateBounds(bounds, dx, dy)
796
+ }
797
+ if (!topLevelIds.has(item.id)) return
798
+ for (const subgraph of subgraphs) {
799
+ if (topLevelSubgraphId(subgraph.id) !== item.id) continue
800
+ const bounds = subgraphBounds.get(subgraph.id)
801
+ if (bounds) translateBounds(bounds, dx, dy)
802
+ }
803
+ }
804
+ if (hasLocalDirection) {
805
+ const outgoing = new Map(items.map((item) => [item.id, new Set<string>()]))
806
+ for (const edge of diagram.edges) {
807
+ const from = itemByEndpoint.get(edge.from)
808
+ const to = itemByEndpoint.get(edge.to)
809
+ if (from && to && from !== to) outgoing.get(from)?.add(to)
810
+ }
811
+ const ranks = rankGraphComponents(
812
+ items.map((item) => item.id),
813
+ outgoing,
814
+ )
815
+ items.sort(
816
+ (a, b) =>
817
+ ranks.get(a.id)! - ranks.get(b.id)! ||
818
+ (horizontal ? a.bounds.centerX - b.bounds.centerX : a.bounds.centerY - b.bounds.centerY),
819
+ )
820
+ let cursor: number | undefined
821
+ let moved = false
822
+ for (const item of items) {
823
+ const start = horizontal ? item.bounds.left : item.bounds.top
824
+ const size = horizontal ? item.bounds.width : item.bounds.height
825
+ if (cursor === undefined) {
826
+ cursor = start + size + gap
827
+ continue
828
+ }
829
+ const shift = cursor - start
830
+ moved ||= shift !== 0
831
+ moveItem(item, horizontal ? shift : 0, horizontal ? 0 : shift)
832
+ cursor = start + shift + size + gap
833
+ }
834
+ return moved
835
+ }
836
+
837
+ const rankedItems = items.filter((item) => topLevelIds.has(item.id))
838
+ if (rankedItems.length < 2) return false
839
+
840
+ const itemById = new Map(rankedItems.map((item) => [item.id, item]))
841
+ const outgoing = new Map(rankedItems.map((item) => [item.id, new Set<string>()]))
842
+ for (const edge of diagram.edges) {
843
+ const from = itemByEndpoint.get(edge.from)
844
+ const to = itemByEndpoint.get(edge.to)
845
+ if (!from || !to || from === to || !itemById.has(from) || !itemById.has(to) || outgoing.get(from)!.has(to)) continue
846
+ outgoing.get(from)!.add(to)
847
+ }
848
+
849
+ const ranks = rankGraphComponents(
850
+ rankedItems.map((item) => item.id),
851
+ outgoing,
852
+ )
853
+ for (const item of rankedItems) item.rank = ranks.get(item.id)!
854
+
855
+ const reversed = diagram.direction === "RL" || diagram.direction === "BT"
856
+ const primaryStart = (item: (typeof items)[number]): number => {
857
+ const start = horizontal ? item.bounds.left : item.bounds.top
858
+ const size = horizontal ? item.bounds.width : item.bounds.height
859
+ return reversed ? -(start + size) : start
860
+ }
861
+ const itemsByRank = Map.groupBy(rankedItems, (item) => item.rank)
862
+ const rankKeys = [...itemsByRank.keys()].sort((a, b) => a - b)
863
+ let cursor: number | undefined
864
+ let moved = false
865
+ for (const rank of rankKeys) {
866
+ const rankItems = itemsByRank.get(rank)!
867
+ const start = Math.min(...rankItems.map(primaryStart))
868
+ const end = Math.max(
869
+ ...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)),
870
+ )
871
+ if (cursor === undefined) {
872
+ cursor = end + gap
873
+ continue
874
+ }
875
+ const shift = Math.max(0, cursor - start)
876
+ if (shift > 0) {
877
+ moved = true
878
+ for (const item of rankItems) {
879
+ const offset = reversed ? -shift : shift
880
+ moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset)
881
+ }
882
+ }
883
+ cursor = end + shift + gap
884
+ }
885
+
886
+ for (const rank of rankKeys) {
887
+ const rankItems = itemsByRank
888
+ .get(rank)!
889
+ .toSorted((a, b) => (horizontal ? a.bounds.top - b.bounds.top : a.bounds.left - b.bounds.left))
890
+ let crossCursor: number | undefined
891
+ for (const item of rankItems) {
892
+ const start = horizontal ? item.bounds.top : item.bounds.left
893
+ const size = horizontal ? item.bounds.height : item.bounds.width
894
+ if (crossCursor === undefined) {
895
+ crossCursor = start + size + gap
896
+ continue
897
+ }
898
+ const shift = Math.max(0, crossCursor - start)
899
+ if (shift > 0) {
900
+ moved = true
901
+ moveItem(item, horizontal ? 0 : shift, horizontal ? shift : 0)
902
+ }
903
+ crossCursor = start + shift + size + gap
904
+ }
905
+ }
906
+ if (targetWidth !== undefined && !horizontal) {
907
+ const intersects = (left: (typeof items)[number], right: (typeof items)[number]): boolean =>
908
+ [...left.nodeIds].some((leftId) => {
909
+ const leftBounds = nodeBounds.get(leftId)!
910
+ return [...right.nodeIds].some((rightId) => {
911
+ const rightBounds = nodeBounds.get(rightId)!
912
+ return (
913
+ leftBounds.left <= rightBounds.left + rightBounds.width - 1 &&
914
+ leftBounds.left + leftBounds.width - 1 >= rightBounds.left &&
915
+ leftBounds.top <= rightBounds.top + rightBounds.height - 1 &&
916
+ leftBounds.top + leftBounds.height - 1 >= rightBounds.top
917
+ )
918
+ })
919
+ })
920
+ for (let rightIndex = 1; rightIndex < items.length; rightIndex++) {
921
+ const right = items[rightIndex]!
922
+ for (let leftIndex = 0; leftIndex < rightIndex; leftIndex++) {
923
+ const left = items[leftIndex]!
924
+ if (!intersects(left, right)) continue
925
+ const leftBounds = boundsFromChildren([...left.nodeIds].map((id) => nodeBounds.get(id)!))!
926
+ const rightBounds = boundsFromChildren([...right.nodeIds].map((id) => nodeBounds.get(id)!))!
927
+ const shift = reversed
928
+ ? leftBounds.top - gap - (rightBounds.top + rightBounds.height)
929
+ : leftBounds.top + leftBounds.height + gap - rightBounds.top
930
+ moved ||= shift !== 0
931
+ moveItem(right, 0, shift)
932
+ }
933
+ }
934
+ }
935
+ return moved
936
+ }
937
+
938
+ function layoutSubgraphs(
939
+ diagram: FlowchartDiagram,
940
+ nodeBounds: Map<string, FlowchartNodeBounds>,
941
+ routes: readonly FlowchartEdgeRoute[],
942
+ ): Map<string, FlowchartSubgraphBounds> {
943
+ const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
944
+ const subgraphs = diagram.subgraphs ?? []
945
+
946
+ for (const subgraph of [...subgraphs].reverse()) {
947
+ const children: FlowchartBounds[] = []
948
+ for (const nodeId of subgraph.nodeIds) {
949
+ const bound = nodeBounds.get(nodeId)
950
+ if (bound) children.push(bound)
951
+ }
952
+ children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
953
+ for (const childSubgraph of subgraphs) {
954
+ if (childSubgraph.parentId !== subgraph.id) continue
955
+ const bound = subgraphBounds.get(childSubgraph.id)
956
+ if (bound) children.push(bound)
957
+ }
958
+ if (children.length > 0) {
959
+ const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
960
+ bound.labelSide = chooseSubgraphLabelSide(bound, routes)
961
+ subgraphBounds.set(subgraph.id, bound)
962
+ }
963
+ }
964
+
965
+ return subgraphBounds
966
+ }
967
+
968
+ function layoutFlowchartWithDirection(
969
+ sourceDiagram: FlowchartDiagram,
970
+ options: FlowchartDiagramRenderOptions,
971
+ direction: FlowchartDirection,
972
+ responsiveFallback = false,
973
+ ): FlowchartLayout {
974
+ const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
975
+ const horizontal = isHorizontalDirection(direction)
976
+ const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
977
+ const requestedMinRankGap = normalizePositiveInt(
978
+ options.minRankGap,
979
+ options.compact
980
+ ? horizontal
981
+ ? COMPACT_MIN_RANK_GAP
982
+ : COMPACT_MIN_VERTICAL_RANK_GAP
983
+ : horizontal
984
+ ? DEFAULT_MIN_RANK_GAP
985
+ : DEFAULT_MIN_VERTICAL_RANK_GAP,
986
+ )
987
+ const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
988
+ const targetWidth =
989
+ !horizontal && options.layoutMaxWidth !== undefined && Number.isFinite(options.layoutMaxWidth)
990
+ ? Math.max(1, Math.trunc(options.layoutMaxWidth))
991
+ : undefined
992
+ const ranked = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap, targetWidth)
993
+ const bounds = ranked.bounds
994
+ const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth)
995
+ const directionAligned = responsiveFallback || responsive || ranked.wrapped
996
+ const route = (frames?: ReadonlyMap<string, FlowchartSubgraphBounds>) =>
997
+ routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), frames, targetWidth, directionAligned)
998
+
999
+ const subgraphs = diagram.subgraphs ?? []
1000
+ let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
1001
+ let routes: FlowchartEdgeRoute[]
1002
+ if (subgraphs.length === 0) {
1003
+ routes = route()
1004
+ } else {
1005
+ routes = route()
1006
+ subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
1007
+ const moved = separateTopLevelItems(
1008
+ diagram,
1009
+ bounds,
1010
+ subgraphBounds,
1011
+ Math.max(1, Math.floor(requestedMinRankGap / 2)),
1012
+ targetWidth,
1013
+ )
1014
+ if (moved) {
1015
+ routes = route()
1016
+ subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
1017
+ }
1018
+ routes = route(subgraphBounds)
1019
+ subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
1020
+ // Frame-aware routes can expand a group after initial separation. Move the expanded frame rigidly
1021
+ // before rerouting so external edges cannot grow it back across neighboring top-level items.
1022
+ const expanded = separateTopLevelItems(
1023
+ diagram,
1024
+ bounds,
1025
+ subgraphBounds,
1026
+ Math.max(1, Math.floor(requestedMinRankGap / 2)),
1027
+ targetWidth,
1028
+ )
1029
+ if (expanded) routes = route(subgraphBounds)
1030
+ avoidFlowchartFrameBorders(routes, bounds, subgraphBounds)
1031
+ }
1032
+ freezeRouteLabelPoints(routes)
1033
+ const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
1034
+ const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
1035
+ const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
1036
+ if (dx > 0 || dy > 0) {
1037
+ for (const bound of allBounds) translateBounds(bound, dx, dy)
1038
+ translateRoutes(routes, dx, dy)
1039
+ }
1040
+
1041
+ const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
1042
+ const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
1043
+ return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
1044
+ }
1045
+
1046
+ export function layoutFlowchartDiagram(
1047
+ sourceDiagram: FlowchartDiagram,
1048
+ options: FlowchartDiagramRenderOptions = {},
1049
+ ): FlowchartLayout {
1050
+ const direction = options.direction ?? sourceDiagram.direction
1051
+ const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
1052
+ const maxWidth = options.layoutMaxWidth
1053
+ if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
1054
+ if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
1055
+
1056
+ return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD", true)
1057
+ }