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