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,531 @@
1
+ import {
2
+ diagramBoundsFromRect,
3
+ translateDiagramBounds,
4
+ type DiagramBounds,
5
+ type DiagramPoint,
6
+ } from "../core/geometry.js"
7
+ import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
8
+ import type { ClassDefinition, ClassDiagram, ClassDiagramDirection, ClassRelationship } from "./types.js"
9
+
10
+ export interface ClassBox {
11
+ definition: ClassDefinition
12
+ bounds: DiagramBounds
13
+ titleLines: string[]
14
+ attributeLines: string[]
15
+ methodLines: string[]
16
+ contentWidth: number
17
+ }
18
+
19
+ export interface ClassEdge {
20
+ relationship: ClassRelationship
21
+ fromId: string
22
+ toId: string
23
+ from: DiagramBounds
24
+ to: DiagramBounds
25
+ path: DiagramPoint[]
26
+ }
27
+
28
+ export interface ClassDiagramLayout {
29
+ direction: ClassDiagramDirection
30
+ boxes: ClassBox[]
31
+ edges: ClassEdge[]
32
+ width: number
33
+ height: number
34
+ }
35
+
36
+ /** Left offset of a box's content from the box's left border (1 border + 1 pad). */
37
+ export const CLASS_CONTENT_OFFSET_X = 2
38
+
39
+ const CONTENT_PADDING = 2
40
+ const NODE_H_GAP = 6
41
+ const NODE_MIN_GAP = 3
42
+ const MARGIN_TOP = 2
43
+ const MARGIN_RIGHT = 4
44
+ const MARGIN_BOTTOM = 2
45
+
46
+ export interface BoxMetrics {
47
+ titleLines: string[]
48
+ attributeLines: string[]
49
+ methodLines: string[]
50
+ contentWidth: number
51
+ width: number
52
+ height: number
53
+ }
54
+
55
+ interface RankBand {
56
+ start: number
57
+ end: number
58
+ }
59
+
60
+ interface EdgeRecord {
61
+ relationship: ClassRelationship
62
+ from: ClassDefinition
63
+ to: ClassDefinition
64
+ fromRank: number
65
+ toRank: number
66
+ }
67
+
68
+ type CorridorSide = "low" | "high"
69
+
70
+ /** Convert `List~int~` generics into `List<int>` for display only. */
71
+ export function displayClassName(id: string): string {
72
+ let open = true
73
+ return id.replace(/~/g, () => {
74
+ const glyph = open ? "<" : ">"
75
+ open = !open
76
+ return glyph
77
+ })
78
+ }
79
+
80
+ export function classBoxSectionLines(
81
+ definition: ClassDefinition,
82
+ ): Pick<BoxMetrics, "titleLines" | "attributeLines" | "methodLines"> {
83
+ return {
84
+ titleLines: [...(definition.stereotype ? [`«${definition.stereotype}»`] : []),
85
+ displayClassName(definition.label ?? definition.id)],
86
+ attributeLines: [
87
+ ...definition.members.filter((member) => !member.method).map((member) => member.text),
88
+ ...(definition.notes ?? []).map((note) => `ⓘ ${note}`),
89
+ ],
90
+ methodLines: definition.members.filter((member) => member.method).map((member) => member.text),
91
+ }
92
+ }
93
+
94
+ export function measureClassBox(definition: ClassDefinition): BoxMetrics {
95
+ const { titleLines, attributeLines, methodLines } = classBoxSectionLines(definition)
96
+ const contentWidth = Math.max(
97
+ 1,
98
+ ...titleLines.map(diagramTextWidth),
99
+ ...attributeLines.map(diagramTextWidth),
100
+ ...methodLines.map(diagramTextWidth),
101
+ )
102
+ let height = 2 + titleLines.length
103
+ if (attributeLines.length) height += 1 + attributeLines.length
104
+ if (methodLines.length) height += 1 + methodLines.length
105
+ return { titleLines, attributeLines, methodLines, contentWidth, width: contentWidth + CONTENT_PADDING * 2, height }
106
+ }
107
+
108
+ export function layoutClassDiagram(diagram: ClassDiagram, direction: ClassDiagramDirection): ClassDiagramLayout {
109
+ const vertical = direction === "TB" || direction === "BT"
110
+ const metrics = new Map(diagram.classes.map((definition) => [definition.id, measureClassBox(definition)]))
111
+ const rank = computeRanks(diagram, direction)
112
+ const byId = new Map(diagram.classes.map((definition) => [definition.id, definition]))
113
+ const groups = new Map<number, ClassDefinition[]>()
114
+ for (const definition of diagram.classes) {
115
+ const key = rank.get(definition.id) ?? 0
116
+ const list = groups.get(key) ?? []
117
+ list.push(definition)
118
+ groups.set(key, list)
119
+ }
120
+ const rankKeys = [...groups.keys()].sort((left, right) => left - right)
121
+ const records: EdgeRecord[] = diagram.relationships.map((relationship) => ({
122
+ relationship,
123
+ from: byId.get(relationship.from)!,
124
+ to: byId.get(relationship.to)!,
125
+ fromRank: rank.get(relationship.from) ?? 0,
126
+ toRank: rank.get(relationship.to) ?? 0,
127
+ }))
128
+ const spacing = computeSpacing(records, vertical)
129
+ const boxes: ClassBox[] = []
130
+ const boxById = new Map<string, ClassBox>()
131
+ const bands = new Map<number, RankBand>()
132
+
133
+ let mainExtent = 0
134
+ if (vertical) {
135
+ const laneWidths = rankKeys.map((key) => laneWidth(groups.get(key)!, metrics))
136
+ const totalWidth = Math.max(0, ...laneWidths)
137
+ let y = 0
138
+ rankKeys.forEach((key, index) => {
139
+ const nodes = groups.get(key)!
140
+ const bandHeight = Math.max(0, ...nodes.map((node) => metrics.get(node.id)!.height))
141
+ let x = Math.floor((totalWidth - laneWidths[index]!) / 2)
142
+ for (const definition of nodes) {
143
+ const size = metrics.get(definition.id)!
144
+ const box = makeClassBox(definition, x, y, size)
145
+ boxes.push(box)
146
+ boxById.set(definition.id, box)
147
+ x += size.width + NODE_H_GAP
148
+ }
149
+ bands.set(key, { start: y, end: y + bandHeight - 1 })
150
+ y += bandHeight + spacing.gapFor(key)
151
+ })
152
+ mainExtent = Math.max(0, ...boxes.map((box) => box.bounds.left + box.bounds.width))
153
+ } else {
154
+ const laneHeights = rankKeys.map((key) => laneHeight(groups.get(key)!, metrics))
155
+ const totalHeight = Math.max(0, ...laneHeights)
156
+ let x = 0
157
+ rankKeys.forEach((key, index) => {
158
+ const nodes = groups.get(key)!
159
+ const bandWidth = Math.max(0, ...nodes.map((node) => metrics.get(node.id)!.width))
160
+ let y = Math.floor((totalHeight - laneHeights[index]!) / 2)
161
+ for (const definition of nodes) {
162
+ const size = metrics.get(definition.id)!
163
+ const box = makeClassBox(definition, x, y, size)
164
+ boxes.push(box)
165
+ boxById.set(definition.id, box)
166
+ y += size.height + NODE_MIN_GAP
167
+ }
168
+ bands.set(key, { start: x, end: x + bandWidth - 1 })
169
+ x += bandWidth + spacing.gapFor(key)
170
+ })
171
+ mainExtent = Math.max(0, ...boxes.map((box) => box.bounds.top + box.bounds.height))
172
+ }
173
+
174
+ const corridors: Record<CorridorSide, number> = { low: 0, high: 0 }
175
+ const takeCorridor = (side: CorridorSide): number => {
176
+ const index = corridors[side]++
177
+ return side === "low" ? -3 - index : mainExtent + 3 + index
178
+ }
179
+ const bandFor = (rank: number): RankBand => bands.get(rank)!
180
+ const edges = records.map((record, edgeIndex) => {
181
+ const from = boxById.get(record.from.id)!
182
+ const to = boxById.get(record.to.id)!
183
+ const lane = (gap: number): number =>
184
+ bands.get(gap)!.end + spacing.laneBase + spacing.laneFor(gap, edgeIndex) * spacing.stride
185
+ const tieBreak: CorridorSide = edgeIndex % 2 === 0 ? "low" : "high"
186
+ const path = vertical
187
+ ? verticalRoute(record, from.bounds, to.bounds, lane, bandFor, mainExtent, tieBreak, takeCorridor)
188
+ : horizontalRoute(record, from.bounds, to.bounds, lane, bandFor, mainExtent, tieBreak, takeCorridor)
189
+ return {
190
+ relationship: record.relationship,
191
+ fromId: record.from.id,
192
+ toId: record.to.id,
193
+ from: from.bounds,
194
+ to: to.bounds,
195
+ path: dedupePath(path),
196
+ }
197
+ })
198
+
199
+ return finalizeClassDiagramLayout(boxes, edges, direction)
200
+ }
201
+
202
+ export function makeClassBox(definition: ClassDefinition, x: number, y: number, size: BoxMetrics): ClassBox {
203
+ return {
204
+ definition,
205
+ bounds: diagramBoundsFromRect(x, y, size.width, size.height),
206
+ titleLines: size.titleLines,
207
+ attributeLines: size.attributeLines,
208
+ methodLines: size.methodLines,
209
+ contentWidth: size.contentWidth,
210
+ }
211
+ }
212
+
213
+ function laneWidth(nodes: readonly ClassDefinition[], metrics: Map<string, BoxMetrics>): number {
214
+ if (nodes.length === 0) return 0
215
+ return nodes.reduce((sum, node) => sum + metrics.get(node.id)!.width, 0) + (nodes.length - 1) * NODE_H_GAP
216
+ }
217
+
218
+ function laneHeight(nodes: readonly ClassDefinition[], metrics: Map<string, BoxMetrics>): number {
219
+ if (nodes.length === 0) return 0
220
+ return nodes.reduce((sum, node) => sum + metrics.get(node.id)!.height, 0) + (nodes.length - 1) * NODE_MIN_GAP
221
+ }
222
+
223
+ function gapIndices(record: EdgeRecord): number[] {
224
+ const upper = Math.min(record.fromRank, record.toRank)
225
+ const lower = Math.max(record.fromRank, record.toRank)
226
+ // Long relationships use fixed rows next to their boxes and a side corridor.
227
+ // They do not reserve lanes in the rank gaps, so one back edge cannot inflate
228
+ // the spacing of the whole diagram.
229
+ return lower - upper <= 1 ? [upper] : []
230
+ }
231
+
232
+ interface SpacingPlan {
233
+ stride: number
234
+ laneBase: number
235
+ gapFor: (gap: number) => number
236
+ laneFor: (gap: number, edgeIndex: number) => number
237
+ }
238
+
239
+ function computeSpacing(records: readonly EdgeRecord[], vertical: boolean): SpacingPlan {
240
+ const groupsByGap = new Map<number, Map<string, number>>()
241
+ const laneByEdge = new Map<string, number>()
242
+ const labelWidthByGap = new Map<number, number>()
243
+ for (const [edgeIndex, record] of records.entries()) {
244
+ const label = record.relationship.label ?? ""
245
+ for (const gap of new Set(gapIndices(record))) {
246
+ const groups = groupsByGap.get(gap) ?? new Map<string, number>()
247
+ const groupKey = `${record.from.id}\u0000${label}`
248
+ let slot = groups.get(groupKey)
249
+ if (slot === undefined) {
250
+ slot = groups.size
251
+ groups.set(groupKey, slot)
252
+ }
253
+ groupsByGap.set(gap, groups)
254
+ laneByEdge.set(`${edgeIndex}:${gap}`, slot)
255
+ if (label) {
256
+ const labelWidth = Math.max(...splitDiagramLines(label).map(diagramTextWidth))
257
+ labelWidthByGap.set(gap, Math.max(labelWidthByGap.get(gap) ?? 0, labelWidth))
258
+ }
259
+ }
260
+ }
261
+
262
+ const stride = vertical
263
+ ? 2
264
+ : Math.max(2, Math.max(0, ...labelWidthByGap.values()) + 2)
265
+ const laneBase = vertical ? 2 : 1
266
+ const gapSizes = new Map<number, number>()
267
+ for (const [gap, groups] of groupsByGap) {
268
+ // Slot lanes occupy band.end + laneBase + slot*stride. Reserve one extra
269
+ // stride so the deepest lane never lands on `child.top - 1`; that would
270
+ // collapse the final vertical drop onto the box border.
271
+ const gapSize = vertical
272
+ ? Math.max(NODE_MIN_GAP, laneBase + groups.size * stride)
273
+ : Math.max(NODE_MIN_GAP, Math.max(0, groups.size - 1) * stride + (labelWidthByGap.get(gap) ?? 0) + 3)
274
+ gapSizes.set(gap, gapSize)
275
+ }
276
+
277
+ return {
278
+ stride,
279
+ laneBase,
280
+ gapFor: (gap) => gapSizes.get(gap) ?? NODE_MIN_GAP,
281
+ laneFor: (gap, edgeIndex) => laneByEdge.get(`${edgeIndex}:${gap}`) ?? 0,
282
+ }
283
+ }
284
+
285
+ function chooseCorridorSide(from: number, to: number, extent: number, tieBreak: CorridorSide): CorridorSide {
286
+ const low = -3
287
+ const high = extent + 3
288
+ const lowCost = Math.abs(from - low) + Math.abs(to - low)
289
+ const highCost = Math.abs(from - high) + Math.abs(to - high)
290
+ if (lowCost === highCost) return tieBreak
291
+ return lowCost < highCost ? "low" : "high"
292
+ }
293
+
294
+ function sideInset(size: number): number {
295
+ return Math.max(1, Math.min(2, Math.floor((size - 2) / 2)))
296
+ }
297
+
298
+ function sidePortX(bounds: DiagramBounds, side: CorridorSide): number {
299
+ const inset = sideInset(bounds.width)
300
+ return side === "low" ? bounds.left + inset : bounds.left + bounds.width - 1 - inset
301
+ }
302
+
303
+ function sidePortY(bounds: DiagramBounds, side: CorridorSide): number {
304
+ const inset = sideInset(bounds.height)
305
+ return side === "low" ? bounds.top + inset : bounds.top + bounds.height - 1 - inset
306
+ }
307
+
308
+ function verticalRoute(
309
+ record: EdgeRecord,
310
+ from: DiagramBounds,
311
+ to: DiagramBounds,
312
+ laneFor: (gap: number) => number,
313
+ bandFor: (rank: number) => RankBand,
314
+ mainExtent: number,
315
+ tieBreak: CorridorSide,
316
+ takeCorridor: (side: CorridorSide) => number,
317
+ ): DiagramPoint[] {
318
+ const { fromRank, toRank } = record
319
+ if (fromRank === toRank) {
320
+ const lane = laneFor(fromRank)
321
+ return [
322
+ point(from.centerX, from.top + from.height),
323
+ point(from.centerX, lane),
324
+ point(to.centerX, lane),
325
+ point(to.centerX, to.top + to.height),
326
+ ]
327
+ }
328
+ const upper = Math.min(fromRank, toRank)
329
+ const lower = Math.max(fromRank, toRank)
330
+ if (lower - upper === 1) {
331
+ const lane = laneFor(upper)
332
+ return fromRank < toRank
333
+ ? [
334
+ point(from.centerX, from.top + from.height),
335
+ point(from.centerX, lane),
336
+ point(to.centerX, lane),
337
+ point(to.centerX, to.top - 1),
338
+ ]
339
+ : [
340
+ point(from.centerX, from.top - 1),
341
+ point(from.centerX, lane),
342
+ point(to.centerX, lane),
343
+ point(to.centerX, to.top + to.height),
344
+ ]
345
+ }
346
+ const side = chooseCorridorSide(from.centerX, to.centerX, mainExtent, tieBreak)
347
+ const corridorX = takeCorridor(side)
348
+ const sourceX = sidePortX(from, side)
349
+ const targetX = sidePortX(to, side)
350
+ if (fromRank < toRank) {
351
+ const sourceLane = bandFor(fromRank + 1).start - 1
352
+ const targetLane = to.top - 2
353
+ return [
354
+ point(sourceX, from.top + from.height),
355
+ point(sourceX, sourceLane),
356
+ point(corridorX, sourceLane),
357
+ point(corridorX, targetLane),
358
+ point(targetX, targetLane),
359
+ point(targetX, to.top - 1),
360
+ ]
361
+ }
362
+ const sourceLane = bandFor(fromRank).start - 1
363
+ const targetLane = bandFor(toRank + 1).start - 1
364
+ return [
365
+ point(sourceX, from.top - 1),
366
+ point(sourceX, sourceLane),
367
+ point(corridorX, sourceLane),
368
+ point(corridorX, targetLane),
369
+ point(targetX, targetLane),
370
+ point(targetX, to.top + to.height),
371
+ ]
372
+ }
373
+
374
+ function horizontalRoute(
375
+ record: EdgeRecord,
376
+ from: DiagramBounds,
377
+ to: DiagramBounds,
378
+ laneFor: (gap: number) => number,
379
+ bandFor: (rank: number) => RankBand,
380
+ mainExtent: number,
381
+ tieBreak: CorridorSide,
382
+ takeCorridor: (side: CorridorSide) => number,
383
+ ): DiagramPoint[] {
384
+ const { fromRank, toRank } = record
385
+ if (fromRank === toRank) {
386
+ const lane = laneFor(fromRank)
387
+ return [
388
+ point(from.left + from.width, from.centerY),
389
+ point(lane, from.centerY),
390
+ point(lane, to.centerY),
391
+ point(to.left + to.width, to.centerY),
392
+ ]
393
+ }
394
+ const upper = Math.min(fromRank, toRank)
395
+ const lower = Math.max(fromRank, toRank)
396
+ if (lower - upper === 1) {
397
+ const lane = laneFor(upper)
398
+ return fromRank < toRank
399
+ ? [
400
+ point(from.left + from.width, from.centerY),
401
+ point(lane, from.centerY),
402
+ point(lane, to.centerY),
403
+ point(to.left - 1, to.centerY),
404
+ ]
405
+ : [
406
+ point(from.left - 1, from.centerY),
407
+ point(lane, from.centerY),
408
+ point(lane, to.centerY),
409
+ point(to.left + to.width, to.centerY),
410
+ ]
411
+ }
412
+ const side = chooseCorridorSide(from.centerY, to.centerY, mainExtent, tieBreak)
413
+ const corridorY = takeCorridor(side)
414
+ const sourceY = sidePortY(from, side)
415
+ const targetY = sidePortY(to, side)
416
+ if (fromRank < toRank) {
417
+ const sourceLane = bandFor(fromRank + 1).start - 1
418
+ const targetLane = to.left - 2
419
+ return [
420
+ point(from.left + from.width, sourceY),
421
+ point(sourceLane, sourceY),
422
+ point(sourceLane, corridorY),
423
+ point(targetLane, corridorY),
424
+ point(targetLane, targetY),
425
+ point(to.left - 1, targetY),
426
+ ]
427
+ }
428
+ const sourceLane = bandFor(fromRank).start - 1
429
+ const targetLane = bandFor(toRank + 1).start - 1
430
+ return [
431
+ point(from.left - 1, sourceY),
432
+ point(sourceLane, sourceY),
433
+ point(sourceLane, corridorY),
434
+ point(targetLane, corridorY),
435
+ point(targetLane, targetY),
436
+ point(to.left + to.width, targetY),
437
+ ]
438
+ }
439
+
440
+ function point(x: number, y: number): DiagramPoint {
441
+ return { x, y }
442
+ }
443
+
444
+ function dedupePath(points: readonly DiagramPoint[]): DiagramPoint[] {
445
+ const result: DiagramPoint[] = []
446
+ for (const point of points) {
447
+ const last = result[result.length - 1]
448
+ if (!last || last.x !== point.x || last.y !== point.y) result.push(point)
449
+ }
450
+ return result
451
+ }
452
+
453
+ function computeRanks(diagram: ClassDiagram, direction: ClassDiagramDirection): Map<string, number> {
454
+ const ids = diagram.classes.map((definition) => definition.id)
455
+ const adjacency = new Map<string, Array<{ to: string; key: string }>>(ids.map((id) => [id, []]))
456
+ const edges: Array<{ from: string; to: string; key: string }> = []
457
+ for (const relationship of diagram.relationships) {
458
+ const key = `${relationship.from}\u0000${relationship.to}\u0000${edges.length}`
459
+ edges.push({ from: relationship.from, to: relationship.to, key })
460
+ adjacency.get(relationship.from)?.push({ to: relationship.to, key })
461
+ }
462
+
463
+ const state = new Map<string, 0 | 1 | 2>()
464
+ const back = new Set<string>()
465
+ const visit = (id: string): void => {
466
+ state.set(id, 1)
467
+ for (const { to, key } of adjacency.get(id) ?? []) {
468
+ const seen = state.get(to) ?? 0
469
+ if (seen === 1) back.add(key)
470
+ else if (seen === 0) visit(to)
471
+ }
472
+ state.set(id, 2)
473
+ }
474
+ for (const id of ids) if ((state.get(id) ?? 0) === 0) visit(id)
475
+
476
+ const rank = new Map(ids.map((id) => [id, 0]))
477
+ let changed = true
478
+ let guard = ids.length + 1
479
+ while (changed && guard-- > 0) {
480
+ changed = false
481
+ for (const edge of edges) {
482
+ if (back.has(edge.key)) continue
483
+ const next = (rank.get(edge.from) ?? 0) + 1
484
+ if (next > (rank.get(edge.to) ?? 0)) {
485
+ rank.set(edge.to, next)
486
+ changed = true
487
+ }
488
+ }
489
+ }
490
+
491
+ if (direction === "BT" || direction === "RL") {
492
+ const maxRank = Math.max(0, ...rank.values())
493
+ for (const [id, value] of rank) rank.set(id, maxRank - value)
494
+ }
495
+ return rank
496
+ }
497
+
498
+ export function finalizeClassDiagramLayout(boxes: ClassBox[], edges: ClassEdge[], direction: ClassDiagramDirection): ClassDiagramLayout {
499
+ const points: DiagramPoint[] = [
500
+ ...boxes.flatMap((box) => [
501
+ { x: box.bounds.left, y: box.bounds.top },
502
+ { x: box.bounds.left + box.bounds.width, y: box.bounds.top + box.bounds.height },
503
+ ]),
504
+ ...edges.flatMap((edge) => edge.path),
505
+ ]
506
+ const minX = Math.min(0, ...points.map((p) => p.x))
507
+ const minY = Math.min(0, ...points.map((p) => p.y))
508
+ const dx = -minX
509
+ const dy = -minY + MARGIN_TOP
510
+ if (dx !== 0 || dy !== 0) {
511
+ for (const box of boxes) translateDiagramBounds(box.bounds, dx, dy)
512
+ for (const edge of edges) {
513
+ for (const p of edge.path) {
514
+ p.x += dx
515
+ p.y += dy
516
+ }
517
+ }
518
+ }
519
+
520
+ const maxX = Math.max(
521
+ 0,
522
+ ...boxes.map((box) => box.bounds.left + box.bounds.width),
523
+ ...edges.flatMap((edge) => edge.path.map((p) => p.x + 1)),
524
+ )
525
+ const maxY = Math.max(
526
+ 0,
527
+ ...boxes.map((box) => box.bounds.top + box.bounds.height),
528
+ ...edges.flatMap((edge) => edge.path.map((p) => p.y + 1)),
529
+ )
530
+ return { direction, boxes, edges, width: maxX + MARGIN_RIGHT, height: maxY + MARGIN_BOTTOM }
531
+ }