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,343 @@
|
|
|
1
|
+
import { BorderChars } from "@opentui/core"
|
|
2
|
+
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
|
3
|
+
import { diagramLineGlyph, drawDiagramFrame, drawOrthogonalPath, mergeDiagramLineGlyph } from "../core/drawing.js"
|
|
4
|
+
import { directionBetween, type DiagramDirection, type DiagramPoint } from "../core/geometry.js"
|
|
5
|
+
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
|
6
|
+
import { layoutDagreClassDiagram } from "./dagre-layout.js"
|
|
7
|
+
import { classLabelAnchor } from "./labels.js"
|
|
8
|
+
import {
|
|
9
|
+
CLASS_CONTENT_OFFSET_X,
|
|
10
|
+
layoutClassDiagram,
|
|
11
|
+
type ClassBox,
|
|
12
|
+
type ClassDiagramLayout,
|
|
13
|
+
type ClassEdge,
|
|
14
|
+
} from "./layout.js"
|
|
15
|
+
import type { ClassGrid } from "./render-grid.js"
|
|
16
|
+
import type {
|
|
17
|
+
ClassCellStyle,
|
|
18
|
+
ClassDiagram,
|
|
19
|
+
ClassDiagramDirection,
|
|
20
|
+
ClassDiagramRenderOptions,
|
|
21
|
+
ClassRelationshipKind,
|
|
22
|
+
} from "./types.js"
|
|
23
|
+
|
|
24
|
+
type ClassCell = DiagramCanvasCell<ClassCellStyle>
|
|
25
|
+
|
|
26
|
+
const HOLLOW_TRIANGLE: Record<DiagramDirection, string> = { up: "△", down: "▽", left: "◁", right: "▷" }
|
|
27
|
+
const OPEN_ARROW: Record<DiagramDirection, string> = { up: "↑", down: "↓", left: "←", right: "→" }
|
|
28
|
+
const FILLED_DIAMOND = "◆"
|
|
29
|
+
const HOLLOW_DIAMOND = "◇"
|
|
30
|
+
|
|
31
|
+
export function drawClassDiagramGrid(diagram: ClassDiagram, options: ClassDiagramRenderOptions = {}): ClassGrid {
|
|
32
|
+
return drawClassDiagramLayout(createClassDiagramLayout(diagram, options))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createClassDiagramLayout(
|
|
36
|
+
diagram: ClassDiagram,
|
|
37
|
+
options: ClassDiagramRenderOptions = {},
|
|
38
|
+
): ClassDiagramLayout {
|
|
39
|
+
const place = options.classLayout === "dagre" ? layoutDagreClassDiagram : layoutClassDiagram
|
|
40
|
+
const requested = diagram.direction
|
|
41
|
+
const maxWidth =
|
|
42
|
+
options.layoutMaxWidth !== undefined && Number.isFinite(options.layoutMaxWidth)
|
|
43
|
+
? Math.max(1, Math.trunc(options.layoutMaxWidth))
|
|
44
|
+
: undefined
|
|
45
|
+
if (maxWidth === undefined || (requested !== "LR" && requested !== "RL")) {
|
|
46
|
+
return place(diagram, requested)
|
|
47
|
+
}
|
|
48
|
+
const wide = place(diagram, requested)
|
|
49
|
+
if (wide.width <= maxWidth) return wide
|
|
50
|
+
const fallbackDirection: ClassDiagramDirection = requested === "RL" ? "BT" : "TB"
|
|
51
|
+
const fallback = place(diagram, fallbackDirection)
|
|
52
|
+
return fallback.width < wide.width ? fallback : wide
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function drawClassDiagramLayout(layout: ClassDiagramLayout): ClassGrid {
|
|
56
|
+
if (layout.boxes.length === 0) return new DiagramCanvas(0, 0)
|
|
57
|
+
const grid = makeGrid(Math.max(1, layout.width), Math.max(1, layout.height))
|
|
58
|
+
const vertical = layout.direction === "TB" || layout.direction === "BT"
|
|
59
|
+
for (const box of layout.boxes) drawBox(grid, box)
|
|
60
|
+
for (const edge of layout.edges) drawEdgeLine(grid, edge)
|
|
61
|
+
for (const edge of layout.edges) drawEdgeEndpoints(grid, edge)
|
|
62
|
+
bridgeIndependentCrossings(grid, layout.edges)
|
|
63
|
+
for (const edge of layout.edges) drawEdgeLabels(grid, edge, vertical)
|
|
64
|
+
return grid
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeGrid(width: number, height: number): ClassGrid {
|
|
68
|
+
return new DiagramCanvas(width, height, {
|
|
69
|
+
mergeCell: (existing, incoming): ClassCell => ({
|
|
70
|
+
...incoming,
|
|
71
|
+
char:
|
|
72
|
+
existing.style === "edge" && incoming.style === "edge"
|
|
73
|
+
? (mergeDiagramLineGlyph(existing.char, incoming.char) ?? incoming.char)
|
|
74
|
+
: incoming.char,
|
|
75
|
+
}),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function setCell(grid: ClassGrid, x: number, y: number, char: string, style?: ClassCellStyle): void {
|
|
80
|
+
grid.setCell(x, y, char, style)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function drawBox(grid: ClassGrid, box: ClassBox): void {
|
|
84
|
+
const bounds = box.bounds
|
|
85
|
+
drawDiagramFrame(bounds, BorderChars.single, (x, y, char) => setCell(grid, x, y, char, "border"))
|
|
86
|
+
|
|
87
|
+
let y = bounds.top + 1
|
|
88
|
+
box.titleLines.forEach((line, index) => {
|
|
89
|
+
const x = bounds.left + CLASS_CONTENT_OFFSET_X + Math.floor((box.contentWidth - diagramTextWidth(line)) / 2)
|
|
90
|
+
setText(grid, x, y + index, line, box.definition.stereotype && index === 0 ? "stereotype" : "title")
|
|
91
|
+
})
|
|
92
|
+
y += box.titleLines.length
|
|
93
|
+
|
|
94
|
+
if (box.attributeLines.length) {
|
|
95
|
+
drawSeparator(grid, bounds, y)
|
|
96
|
+
y += 1
|
|
97
|
+
box.attributeLines.forEach((line, index) => setText(grid, bounds.left + CLASS_CONTENT_OFFSET_X, y + index, line, "member"))
|
|
98
|
+
y += box.attributeLines.length
|
|
99
|
+
}
|
|
100
|
+
if (box.methodLines.length) {
|
|
101
|
+
drawSeparator(grid, bounds, y)
|
|
102
|
+
y += 1
|
|
103
|
+
box.methodLines.forEach((line, index) => setText(grid, bounds.left + CLASS_CONTENT_OFFSET_X, y + index, line, "member"))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function drawSeparator(grid: ClassGrid, bounds: ClassBox["bounds"], y: number): void {
|
|
108
|
+
const left = bounds.left
|
|
109
|
+
const right = bounds.left + bounds.width - 1
|
|
110
|
+
setCell(grid, left, y, "├", "border")
|
|
111
|
+
for (let x = left + 1; x < right; x++) setCell(grid, x, y, "─", "border")
|
|
112
|
+
setCell(grid, right, y, "┤", "border")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function drawEdgeLine(grid: ClassGrid, edge: ClassEdge): void {
|
|
116
|
+
const { relationship, path } = edge
|
|
117
|
+
if (path.length < 2) return
|
|
118
|
+
drawOrthogonalPath(path, (x, y, char) => setCell(grid, x, y, char, "edge"), {
|
|
119
|
+
cornerStyle: "square",
|
|
120
|
+
lineStyle: relationship.dashed ? "dashed" : "single",
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface CrossingAxis {
|
|
125
|
+
through: number[]
|
|
126
|
+
ends: number[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface CrossingCell {
|
|
130
|
+
horizontal: CrossingAxis
|
|
131
|
+
vertical: CrossingAxis
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rememberOwner(owners: number[], owner: number): void {
|
|
135
|
+
// Two distinct owners suffice to determine whether the other axis is independent.
|
|
136
|
+
if (owners.length < 2 && !owners.includes(owner)) owners.push(owner)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function independent(first: readonly number[], second: readonly number[]): boolean {
|
|
140
|
+
return first.length > 0 && second.length > 0 &&
|
|
141
|
+
(first.length > 1 || second.length > 1 || first[0] !== second[0])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function bridgeIndependentCrossings(grid: ClassGrid, edges: readonly ClassEdge[]): void {
|
|
145
|
+
const cells = new Map<number, CrossingCell>()
|
|
146
|
+
const seenPaths = new Set<string>()
|
|
147
|
+
|
|
148
|
+
edges.forEach((edge, edgeIndex) => {
|
|
149
|
+
// Repeated relationships can have identical routes; their geometry only
|
|
150
|
+
// needs to be recorded once, regardless of how many times it was drawn.
|
|
151
|
+
const pathKey = edge.path.map(({ x, y }) => `${x}:${y}`).join(";")
|
|
152
|
+
if (seenPaths.has(pathKey)) return
|
|
153
|
+
seenPaths.add(pathKey)
|
|
154
|
+
|
|
155
|
+
for (let index = 1; index < edge.path.length; index++) {
|
|
156
|
+
const from = edge.path[index - 1]!
|
|
157
|
+
const to = edge.path[index]!
|
|
158
|
+
const horizontal = from.y === to.y
|
|
159
|
+
if (!horizontal && from.x !== to.x) continue
|
|
160
|
+
const start = horizontal ? from.x : from.y
|
|
161
|
+
const end = horizontal ? to.x : to.y
|
|
162
|
+
for (let position = Math.min(start, end); position <= Math.max(start, end); position++) {
|
|
163
|
+
const x = horizontal ? position : from.x
|
|
164
|
+
const y = horizontal ? from.y : position
|
|
165
|
+
const cellKey = y * grid.width + x
|
|
166
|
+
const cell = cells.get(cellKey) ?? {
|
|
167
|
+
horizontal: { through: [], ends: [] },
|
|
168
|
+
vertical: { through: [], ends: [] },
|
|
169
|
+
}
|
|
170
|
+
const axis = horizontal ? cell.horizontal : cell.vertical
|
|
171
|
+
if (position !== start && position !== end) rememberOwner(axis.through, edgeIndex)
|
|
172
|
+
else if ((position === start && index === 1) || (position === end && index === edge.path.length - 1)) {
|
|
173
|
+
rememberOwner(axis.ends, edgeIndex)
|
|
174
|
+
}
|
|
175
|
+
cells.set(cellKey, cell)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
for (const [cellKey, cell] of cells) {
|
|
181
|
+
const x = cellKey % grid.width
|
|
182
|
+
const y = Math.floor(cellKey / grid.width)
|
|
183
|
+
if (grid.getCell(x, y)?.char !== "┼") continue
|
|
184
|
+
if (independent(cell.horizontal.through, cell.vertical.through) ||
|
|
185
|
+
independent(cell.horizontal.ends, cell.vertical.through)) {
|
|
186
|
+
grid.replaceCell(x, y, "─", "edge")
|
|
187
|
+
} else if (independent(cell.horizontal.through, cell.vertical.ends)) {
|
|
188
|
+
// A vertical endpoint must still reach its box; break the passing line instead.
|
|
189
|
+
grid.replaceCell(x, y, "│", "edge")
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function drawEdgeEndpoints(grid: ClassGrid, edge: ClassEdge): void {
|
|
195
|
+
const { relationship, path } = edge
|
|
196
|
+
if (path.length < 2) return
|
|
197
|
+
drawStartCell(grid, path, relationship.headAt === "from" ? relationship.kind : undefined)
|
|
198
|
+
drawEndCell(grid, path, relationship.headAt === "to" ? relationship.kind : undefined)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function drawStartCell(grid: ClassGrid, path: DiagramPoint[], markerKind: ClassRelationshipKind | undefined): void {
|
|
202
|
+
const start = path[0]!
|
|
203
|
+
const next = path[1]!
|
|
204
|
+
const direction = directionBetween(start, next) ?? "right"
|
|
205
|
+
const marker = markerKind ? relationshipMarker(markerKind, opposite(direction)) : undefined
|
|
206
|
+
if (marker) setCell(grid, start.x, start.y, marker, "edge")
|
|
207
|
+
else setCell(grid, start.x, start.y, diagramLineGlyph(new Set([direction])), "edge")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function drawEndCell(grid: ClassGrid, path: DiagramPoint[], markerKind: ClassRelationshipKind | undefined): void {
|
|
211
|
+
const end = path[path.length - 1]!
|
|
212
|
+
const previous = path[path.length - 2]!
|
|
213
|
+
const direction = directionBetween(previous, end) ?? "right"
|
|
214
|
+
const marker = markerKind ? relationshipMarker(markerKind, direction) : undefined
|
|
215
|
+
if (marker) setCell(grid, end.x, end.y, marker, "edge")
|
|
216
|
+
else setCell(grid, end.x, end.y, diagramLineGlyph(new Set([direction])), "edge")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function relationshipMarker(kind: ClassRelationshipKind, direction: DiagramDirection): string | undefined {
|
|
220
|
+
switch (kind) {
|
|
221
|
+
case "inheritance":
|
|
222
|
+
case "realization":
|
|
223
|
+
return HOLLOW_TRIANGLE[direction]
|
|
224
|
+
case "composition":
|
|
225
|
+
return FILLED_DIAMOND
|
|
226
|
+
case "aggregation":
|
|
227
|
+
return HOLLOW_DIAMOND
|
|
228
|
+
case "association":
|
|
229
|
+
case "dependency":
|
|
230
|
+
return OPEN_ARROW[direction]
|
|
231
|
+
case "link":
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function opposite(direction: DiagramDirection): DiagramDirection {
|
|
237
|
+
switch (direction) {
|
|
238
|
+
case "up":
|
|
239
|
+
return "down"
|
|
240
|
+
case "down":
|
|
241
|
+
return "up"
|
|
242
|
+
case "left":
|
|
243
|
+
return "right"
|
|
244
|
+
case "right":
|
|
245
|
+
return "left"
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function drawEdgeLabels(grid: ClassGrid, edge: ClassEdge, vertical: boolean): void {
|
|
250
|
+
const { relationship, path } = edge
|
|
251
|
+
let labelWidth = 0
|
|
252
|
+
let labelHeight = 0
|
|
253
|
+
let labelX = 0
|
|
254
|
+
let labelY = 0
|
|
255
|
+
if (relationship.label) {
|
|
256
|
+
const lines = splitDiagramLines(relationship.label)
|
|
257
|
+
labelWidth = Math.max(...lines.map(diagramTextWidth))
|
|
258
|
+
labelHeight = lines.length
|
|
259
|
+
const anchor = classLabelAnchor(path, lines.length, labelWidth, vertical)
|
|
260
|
+
labelX = anchor.x
|
|
261
|
+
labelY = anchor.y
|
|
262
|
+
drawTextTag(grid, anchor.x, anchor.y, lines)
|
|
263
|
+
}
|
|
264
|
+
if (relationship.fromCardinality) {
|
|
265
|
+
const width = diagramTextWidth(relationship.fromCardinality)
|
|
266
|
+
const anchor = cardinalityAnchor(path, width, true, labelX, labelY, labelWidth, labelHeight)
|
|
267
|
+
drawTextTag(grid, anchor.x, anchor.y, [relationship.fromCardinality])
|
|
268
|
+
}
|
|
269
|
+
if (relationship.toCardinality) {
|
|
270
|
+
const width = diagramTextWidth(relationship.toCardinality)
|
|
271
|
+
const anchor = cardinalityAnchor(path, width, false, labelX, labelY, labelWidth, labelHeight)
|
|
272
|
+
drawTextTag(grid, anchor.x, anchor.y, [relationship.toCardinality])
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function cardinalityAnchor(
|
|
277
|
+
path: DiagramPoint[],
|
|
278
|
+
width: number,
|
|
279
|
+
atStart: boolean,
|
|
280
|
+
labelX: number,
|
|
281
|
+
labelY: number,
|
|
282
|
+
labelWidth: number,
|
|
283
|
+
labelHeight: number,
|
|
284
|
+
): DiagramPoint {
|
|
285
|
+
const point = atStart ? path[0]! : path[path.length - 1]!
|
|
286
|
+
const neighbor = atStart ? path[1]! : path[path.length - 2]!
|
|
287
|
+
// Direction points from the endpoint into the line, so one step lands just inside it.
|
|
288
|
+
const direction = directionBetween(point, neighbor) ?? "right"
|
|
289
|
+
const horizontal = direction === "left" || direction === "right"
|
|
290
|
+
const baseX = point.x + (horizontal ? (direction === "right" ? 1 : -1) : 0)
|
|
291
|
+
const baseY = point.y + (horizontal ? 0 : direction === "down" ? 1 : -1)
|
|
292
|
+
const candidates: DiagramPoint[] = horizontal
|
|
293
|
+
? [
|
|
294
|
+
{ x: baseX, y: baseY - 1 },
|
|
295
|
+
{ x: baseX, y: baseY + 1 },
|
|
296
|
+
{ x: baseX, y: baseY - 2 },
|
|
297
|
+
{ x: baseX, y: baseY + 2 },
|
|
298
|
+
]
|
|
299
|
+
: [
|
|
300
|
+
{ x: baseX + 1, y: baseY },
|
|
301
|
+
{ x: baseX - 1, y: baseY },
|
|
302
|
+
{ x: baseX + 1, y: baseY - 1 },
|
|
303
|
+
{ x: baseX + 1, y: baseY + 1 },
|
|
304
|
+
{ x: baseX - 1, y: baseY - 1 },
|
|
305
|
+
{ x: baseX - 1, y: baseY + 1 },
|
|
306
|
+
]
|
|
307
|
+
for (const candidate of candidates) {
|
|
308
|
+
if (!touchesLabel(candidate.x, width, candidate.y, 1, labelX, labelY, labelWidth, labelHeight)) return candidate
|
|
309
|
+
}
|
|
310
|
+
return candidates[0]!
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function touchesLabel(
|
|
314
|
+
x: number,
|
|
315
|
+
width: number,
|
|
316
|
+
y: number,
|
|
317
|
+
height: number,
|
|
318
|
+
labelX: number,
|
|
319
|
+
labelY: number,
|
|
320
|
+
labelWidth: number,
|
|
321
|
+
labelHeight: number,
|
|
322
|
+
): boolean {
|
|
323
|
+
if (labelWidth <= 0 || labelHeight <= 0) return false
|
|
324
|
+
const rowOverlap = y <= labelY + labelHeight - 1 && y + height - 1 >= labelY
|
|
325
|
+
if (!rowOverlap) return false
|
|
326
|
+
// On a shared row keep at least one blank column so the two tags stay legible.
|
|
327
|
+
const clearX = x + width < labelX || x > labelX + labelWidth
|
|
328
|
+
return !clearX
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function drawTextTag(grid: ClassGrid, x: number, y: number, lines: string[]): void {
|
|
332
|
+
lines.forEach((line, index) => {
|
|
333
|
+
const row = y + index
|
|
334
|
+
if (grid.getCell(x - 1, row)?.char === " ") setCell(grid, x - 1, row, " ", "label")
|
|
335
|
+
setText(grid, x, row, line, "label")
|
|
336
|
+
const rightX = x + diagramTextWidth(line)
|
|
337
|
+
if (grid.getCell(rightX, row)?.char === " ") setCell(grid, rightX, row, " ", "label")
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function setText(grid: ClassGrid, x: number, y: number, text: string, style?: ClassCellStyle): void {
|
|
342
|
+
grid.setText(x, y, text, style)
|
|
343
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DiagramPoint } from "../core/geometry.js"
|
|
2
|
+
|
|
3
|
+
export function classLabelAnchor(path: readonly DiagramPoint[], lineCount: number, width: number, vertical: boolean): DiagramPoint {
|
|
4
|
+
let bestFrom = path[0]!
|
|
5
|
+
let bestTo = path[path.length - 1]!
|
|
6
|
+
let bestLength = -1
|
|
7
|
+
const preferredLength = (from: DiagramPoint, to: DiagramPoint): number => {
|
|
8
|
+
const horizontal = from.y === to.y
|
|
9
|
+
return horizontal === vertical ? Math.abs(from.x - to.x) + Math.abs(from.y - to.y) : -1
|
|
10
|
+
}
|
|
11
|
+
for (let index = 1; index < path.length; index++) {
|
|
12
|
+
const from = path[index - 1]!
|
|
13
|
+
const to = path[index]!
|
|
14
|
+
const length = preferredLength(from, to)
|
|
15
|
+
if (length > bestLength) {
|
|
16
|
+
bestLength = length
|
|
17
|
+
bestFrom = from
|
|
18
|
+
bestTo = to
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (bestLength < 0) {
|
|
22
|
+
for (let index = 1; index < path.length; index++) {
|
|
23
|
+
const from = path[index - 1]!
|
|
24
|
+
const to = path[index]!
|
|
25
|
+
const length = Math.abs(from.x - to.x) + Math.abs(from.y - to.y)
|
|
26
|
+
if (length > bestLength) {
|
|
27
|
+
bestLength = length
|
|
28
|
+
bestFrom = from
|
|
29
|
+
bestTo = to
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const midX = Math.floor((bestFrom.x + bestTo.x) / 2)
|
|
34
|
+
const midY = Math.floor((bestFrom.y + bestTo.y) / 2)
|
|
35
|
+
if (bestFrom.y === bestTo.y) return { x: midX - Math.floor(width / 2), y: bestFrom.y - lineCount }
|
|
36
|
+
if (bestFrom.x === bestTo.x) return { x: bestFrom.x + 2, y: midY - Math.floor(lineCount / 2) }
|
|
37
|
+
return { x: midX, y: midY }
|
|
38
|
+
}
|