@yassimba/pi-loom-mermaid 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/README.md +28 -0
- package/index.ts +1 -0
- package/package.json +57 -0
- package/src/index.ts +103 -0
- package/src/loom-mermaid/ansi.ts +65 -0
- package/src/loom-mermaid/canvas.ts +466 -0
- package/src/loom-mermaid/class-style.ts +65 -0
- package/src/loom-mermaid/css-colors.ts +153 -0
- package/src/loom-mermaid/diagrams/class.ts +297 -0
- package/src/loom-mermaid/diagrams/er.ts +189 -0
- package/src/loom-mermaid/diagrams/flowchart.ts +519 -0
- package/src/loom-mermaid/diagrams/gitgraph.ts +230 -0
- package/src/loom-mermaid/diagrams/mindmap.ts +107 -0
- package/src/loom-mermaid/diagrams/pie.ts +121 -0
- package/src/loom-mermaid/diagrams/sequence.ts +305 -0
- package/src/loom-mermaid/diagrams/state.ts +279 -0
- package/src/loom-mermaid/diagrams/timeline.ts +117 -0
- package/src/loom-mermaid/graph-render.ts +195 -0
- package/src/loom-mermaid/graph.ts +205 -0
- package/src/loom-mermaid/index.ts +78 -0
- package/src/loom-mermaid/labels.ts +353 -0
- package/src/loom-mermaid/layout-seq.ts +234 -0
- package/src/loom-mermaid/layout.ts +1749 -0
- package/src/loom-mermaid/paint.ts +325 -0
- package/src/loom-mermaid/placement.ts +255 -0
- package/src/loom-mermaid/registry.ts +88 -0
- package/src/loom-mermaid/source-box.ts +111 -0
- package/src/loom-mermaid/statements.ts +228 -0
- package/src/loom-mermaid/types.ts +64 -0
- package/src/loom-mermaid/width-data.ts +993 -0
- package/src/loom-mermaid/width.ts +69 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Painting a `Layout` onto a `Canvas`: boxes, frames, routes, labels. The
|
|
3
|
+
* only module that both knows the layout geometry and draws glyphs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Canvas,
|
|
8
|
+
CONT,
|
|
9
|
+
D,
|
|
10
|
+
drawText,
|
|
11
|
+
drawTextOverEdges,
|
|
12
|
+
L,
|
|
13
|
+
R,
|
|
14
|
+
STY_DOT,
|
|
15
|
+
STY_SOLID,
|
|
16
|
+
STY_THICK,
|
|
17
|
+
U,
|
|
18
|
+
} from './canvas.ts'
|
|
19
|
+
import type { Edge, Graph, Head, Shape } from './graph.ts'
|
|
20
|
+
import { fitLabel } from './labels.ts'
|
|
21
|
+
import {
|
|
22
|
+
edgeText,
|
|
23
|
+
half,
|
|
24
|
+
type LaneLabel,
|
|
25
|
+
type Layout,
|
|
26
|
+
type NodeExtra,
|
|
27
|
+
PAD,
|
|
28
|
+
type Placed,
|
|
29
|
+
type Route,
|
|
30
|
+
sat,
|
|
31
|
+
} from './layout.ts'
|
|
32
|
+
import { measured, stringWidth } from './width.ts'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Paint a layout onto a fresh canvas: boxes (plain, class compartments or
|
|
36
|
+
* subgraph frames), then every route, then the labels across them.
|
|
37
|
+
*/
|
|
38
|
+
export function paint(graph: Graph, extras: NodeExtra[], lay: Layout): Canvas {
|
|
39
|
+
const { placed, routes } = lay
|
|
40
|
+
const canvas = new Canvas(lay.w, lay.h)
|
|
41
|
+
for (let idx = 0; idx < graph.nodes.length; idx++) {
|
|
42
|
+
const extra = extras[idx]
|
|
43
|
+
canvas.curTag = graph.nodes[idx].classes?.join(' ')
|
|
44
|
+
canvas.curHref = graph.nodes[idx].href
|
|
45
|
+
// `BT` mirrors the finished canvas; multi-row content draws upside down so
|
|
46
|
+
// the flip restores reading order (flipHorizontal's text runs, vertically).
|
|
47
|
+
const mirrored = graph.dir === 'up'
|
|
48
|
+
if (extra.kind === 'frame')
|
|
49
|
+
drawFrame(canvas, placed[idx], graph.nodes[idx].label, extra.sub, mirrored)
|
|
50
|
+
else if (extra.kind === 'compartments')
|
|
51
|
+
drawClassBox(canvas, placed[idx], extra.sections, mirrored)
|
|
52
|
+
else drawBox(canvas, placed[idx], lay.labels[idx], graph.nodes[idx].shape, mirrored)
|
|
53
|
+
}
|
|
54
|
+
canvas.curTag = undefined
|
|
55
|
+
canvas.curHref = undefined
|
|
56
|
+
|
|
57
|
+
graph.edges.forEach((edge, i) => {
|
|
58
|
+
canvas.curStyle =
|
|
59
|
+
edge.line === 'dotted' ? STY_DOT : edge.line === 'thick' ? STY_THICK : STY_SOLID
|
|
60
|
+
const route = routes[i]
|
|
61
|
+
if (route.points.length === 0) drawSelfLoop(canvas, placed[edge.from], edge)
|
|
62
|
+
else drawRoute(canvas, edge, route)
|
|
63
|
+
for (const { text, row, x } of route.labels) placeLabel(canvas, text, row, x)
|
|
64
|
+
})
|
|
65
|
+
flushLabels(canvas)
|
|
66
|
+
placeLaneLabels(canvas, routes.flatMap((r) => (r?.laneLabel === undefined ? [] : [r.laneLabel])))
|
|
67
|
+
|
|
68
|
+
canvas.finalizeMask()
|
|
69
|
+
return canvas
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Apply the direction flip a finished canvas needs for `BT` / `RL`. */
|
|
73
|
+
export function orient(canvas: Canvas, graph: Graph): Canvas {
|
|
74
|
+
if (graph.dir === 'up') canvas.flipVertical()
|
|
75
|
+
else if (graph.dir === 'left') canvas.flipHorizontal()
|
|
76
|
+
return canvas
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
export function drawBox(
|
|
81
|
+
canvas: Canvas,
|
|
82
|
+
p: Placed,
|
|
83
|
+
lines: string[],
|
|
84
|
+
shape: Shape,
|
|
85
|
+
mirrored = false,
|
|
86
|
+
): void {
|
|
87
|
+
const { x, y, w, h } = p
|
|
88
|
+
const right = x + w - 1
|
|
89
|
+
const bottom = y + h - 1
|
|
90
|
+
|
|
91
|
+
// A diamond is a double-line box — the terminal's nod to `A{...}`.
|
|
92
|
+
const [tl, tr, bl, br] =
|
|
93
|
+
shape === 'diamond'
|
|
94
|
+
? ['╔', '╗', '╚', '╝']
|
|
95
|
+
: shape === 'round'
|
|
96
|
+
? ['╭', '╮', '╰', '╯']
|
|
97
|
+
: ['┌', '┐', '└', '┘']
|
|
98
|
+
canvas.set(x, y, tl, 'border')
|
|
99
|
+
canvas.set(right, y, tr, 'border')
|
|
100
|
+
canvas.set(x, bottom, bl, 'border')
|
|
101
|
+
canvas.set(right, bottom, br, 'border')
|
|
102
|
+
|
|
103
|
+
if (shape === 'diamond') {
|
|
104
|
+
// Double lines have no direction bits; edges tee into them through the
|
|
105
|
+
// mixed junctions (`╤` `╧` `╟` `╢`) that `finalizeMask` resolves.
|
|
106
|
+
for (let cx = x + 1; cx < right; cx++) {
|
|
107
|
+
canvas.set(cx, y, '═', 'border')
|
|
108
|
+
canvas.set(cx, bottom, '═', 'border')
|
|
109
|
+
}
|
|
110
|
+
for (let cy = y + 1; cy < bottom; cy++) {
|
|
111
|
+
canvas.set(x, cy, '║', 'border')
|
|
112
|
+
canvas.set(right, cy, '║', 'border')
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// The perimeter is drawn as bits so edges can tee into it, but it is the
|
|
116
|
+
// box outline, so it claims `border` rather than `edge`.
|
|
117
|
+
for (let cx = x + 1; cx < right; cx++) {
|
|
118
|
+
canvas.addBits(cx, y, L | R, 'border')
|
|
119
|
+
canvas.addBits(cx, bottom, L | R, 'border')
|
|
120
|
+
}
|
|
121
|
+
for (let cy = y + 1; cy < bottom; cy++) {
|
|
122
|
+
canvas.addBits(x, cy, U | D, 'border')
|
|
123
|
+
canvas.addBits(right, cy, U | D, 'border')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (let cy = y; cy <= bottom; cy++) {
|
|
128
|
+
for (let cx = x; cx <= right; cx++) {
|
|
129
|
+
const i = canvas.idx(cx, cy)
|
|
130
|
+
canvas.occupied[i] = 1
|
|
131
|
+
if (canvas.curTag !== undefined) canvas.tag[i] = canvas.curTag
|
|
132
|
+
if (canvas.curHref !== undefined) canvas.href[i] = canvas.curHref
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const inner = Math.max(1, sat(w, 2 * PAD + 2))
|
|
137
|
+
const ordered = mirrored ? [...lines].reverse() : lines
|
|
138
|
+
ordered.forEach((line, li) => {
|
|
139
|
+
const text = fitLabel(line, inner)
|
|
140
|
+
const textX = x + 1 + PAD + half(sat(inner, stringWidth(text)))
|
|
141
|
+
drawText(canvas, text, textX, y + 1 + li, 'text')
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A class or ER box: sections separated by horizontal rules, title centred. */
|
|
146
|
+
function drawClassBox(canvas: Canvas, p: Placed, sections: string[][], mirrored = false): void {
|
|
147
|
+
drawBox(canvas, p, [], 'rect')
|
|
148
|
+
const inner = Math.max(1, sat(p.w, 2 * PAD + 2))
|
|
149
|
+
const rows: ({ sep: true } | { sep?: undefined; text: string; center: boolean })[] = []
|
|
150
|
+
sections.forEach((section, si) => {
|
|
151
|
+
if (section.length === 0) return
|
|
152
|
+
if (rows.length > 0) rows.push({ sep: true })
|
|
153
|
+
for (const line of section) rows.push({ text: fitLabel(line, inner), center: si === 0 })
|
|
154
|
+
})
|
|
155
|
+
if (mirrored) rows.reverse()
|
|
156
|
+
rows.forEach((r, ri) => {
|
|
157
|
+
const row = p.y + 1 + ri
|
|
158
|
+
if (r.sep) {
|
|
159
|
+
canvas.set(p.x, row, '├', 'border')
|
|
160
|
+
for (let x = p.x + 1; x < p.x + p.w - 1; x++) canvas.set(x, row, '─', 'border')
|
|
161
|
+
canvas.set(p.x + p.w - 1, row, '┤', 'border')
|
|
162
|
+
} else {
|
|
163
|
+
const tx = r.center ? p.x + 1 + PAD + half(sat(inner, stringWidth(r.text))) : p.x + 1 + PAD
|
|
164
|
+
drawTextOverEdges(canvas, r.text, tx, row, 'text')
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A subgraph frame: a titled box with a finished sub-canvas centred inside. */
|
|
170
|
+
function drawFrame(canvas: Canvas, p: Placed, title: string, sub: Canvas, mirrored = false): void {
|
|
171
|
+
drawBox(canvas, p, [], 'rect')
|
|
172
|
+
// An unlabelled frame (a state `--` region) keeps its border unbroken.
|
|
173
|
+
if (title !== '') {
|
|
174
|
+
const t = fitLabel(title, sat(p.w, 4))
|
|
175
|
+
// Mirrored: the bottom border becomes the top after the flip.
|
|
176
|
+
drawTextOverEdges(canvas, ` ${t} `, p.x + 1, mirrored ? p.y + p.h - 1 : p.y, 'text')
|
|
177
|
+
}
|
|
178
|
+
canvas.blit(sub, p.x + 1 + half(p.w - 2 - sub.w), p.y + 1 + half(p.h - 2 - sub.h))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------------------- routing
|
|
182
|
+
|
|
183
|
+
function headGlyph(head: Head, arrow: string): string {
|
|
184
|
+
switch (head) {
|
|
185
|
+
case 'circle':
|
|
186
|
+
return 'o'
|
|
187
|
+
case 'cross':
|
|
188
|
+
return '×'
|
|
189
|
+
case 'diamondFill':
|
|
190
|
+
return '◆'
|
|
191
|
+
case 'diamondOpen':
|
|
192
|
+
return '◇'
|
|
193
|
+
case 'triangle':
|
|
194
|
+
return { '▼': '▽', '▲': '△', '◄': '◁', '▶': '▷' }[arrow] ?? arrow
|
|
195
|
+
default:
|
|
196
|
+
return arrow
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Direction bit from one cell toward the next (they share a row or column). */
|
|
201
|
+
const toward = ([x0, y0]: [number, number], [x1, y1]: [number, number]): number =>
|
|
202
|
+
x1 > x0 ? R : x1 < x0 ? L : y1 > y0 ? D : U
|
|
203
|
+
|
|
204
|
+
const ARROW: Record<number, string> = { [D]: '▼', [U]: '▲', [R]: '▶', [L]: '◄' }
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Paint a route: junction bits where it leaves the source border, a
|
|
208
|
+
* segment per pair of corners, then the tail and head glyphs facing the
|
|
209
|
+
* way the line leaves and arrives. A head of `none` just meets the box.
|
|
210
|
+
*/
|
|
211
|
+
function drawRoute(canvas: Canvas, edge: Edge, route: Route): void {
|
|
212
|
+
const { points } = route
|
|
213
|
+
const [sx, sy] = points[0]
|
|
214
|
+
const [hx, hy] = points[points.length - 1]
|
|
215
|
+
const leave = toward(points[0], points[1])
|
|
216
|
+
const arrive = toward(points[points.length - 2], points[points.length - 1])
|
|
217
|
+
canvas.junction(sx, sy, leave)
|
|
218
|
+
for (let k = 0; k + 1 < points.length; k++) {
|
|
219
|
+
const [x0, y0] = points[k]
|
|
220
|
+
const [x1, y1] = points[k + 1]
|
|
221
|
+
if (x0 === x1) canvas.segV(x0, y0, y1)
|
|
222
|
+
else canvas.segH(y0, x0, x1)
|
|
223
|
+
}
|
|
224
|
+
if (edge.headTo === 'none') canvas.addBits(hx, hy, toward(points[points.length - 1], points[points.length - 2]))
|
|
225
|
+
else canvas.set(hx, hy, headGlyph(edge.headTo, ARROW[arrive]), 'edge')
|
|
226
|
+
if (edge.headFrom !== 'none') {
|
|
227
|
+
canvas.set(sx, sy, headGlyph(edge.headFrom, ARROW[toward(points[1], points[0])]), 'edge')
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A self-edge: a stub loop hanging below the box. */
|
|
232
|
+
function drawSelfLoop(canvas: Canvas, p: Placed, edge: Edge): void {
|
|
233
|
+
const bottom = p.y + p.h - 1
|
|
234
|
+
const exitX = p.cx + 1
|
|
235
|
+
const retX = p.x + p.w - 2
|
|
236
|
+
if (retX <= exitX || bottom + 2 >= canvas.h) return
|
|
237
|
+
|
|
238
|
+
const [v, h, bl, br] =
|
|
239
|
+
edge.line === 'dotted'
|
|
240
|
+
? ['╎', '╌', '╰', '╯']
|
|
241
|
+
: edge.line === 'thick'
|
|
242
|
+
? ['┃', '━', '┗', '┛']
|
|
243
|
+
: ['│', '─', '╰', '╯']
|
|
244
|
+
|
|
245
|
+
canvas.junction(exitX, bottom, D)
|
|
246
|
+
canvas.set(exitX, bottom + 1, v, 'edge')
|
|
247
|
+
canvas.set(exitX, bottom + 2, bl, 'edge')
|
|
248
|
+
for (let x = exitX + 1; x < retX; x++) canvas.set(x, bottom + 2, h, 'edge')
|
|
249
|
+
canvas.set(retX, bottom + 2, br, 'edge')
|
|
250
|
+
canvas.set(retX, bottom + 1, headGlyph(edge.headTo, '▲'), 'edge')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Write each lane label onto its own row, centred on the run but slid to
|
|
255
|
+
* the nearest stretch free of crossing verticals, arrowheads and earlier
|
|
256
|
+
* labels — clearing a crossing line under a label would sever it.
|
|
257
|
+
*/
|
|
258
|
+
function placeLaneLabels(canvas: Canvas, labels: LaneLabel[]): void {
|
|
259
|
+
for (const { text, y, lo, hi } of labels) {
|
|
260
|
+
const tw = stringWidth(text)
|
|
261
|
+
const lastStart = hi - 1 - tw
|
|
262
|
+
if (lastStart < lo + 1 || y >= canvas.h) continue
|
|
263
|
+
const clear = (start: number): boolean => {
|
|
264
|
+
for (let x = start; x < start + tw; x++) {
|
|
265
|
+
const i = canvas.idx(x, y)
|
|
266
|
+
if (canvas.occupied[i] === 1) return false
|
|
267
|
+
if ((canvas.mask[i] & (U | D)) !== 0) return false
|
|
268
|
+
if (canvas.ch[i] !== ' ') return false
|
|
269
|
+
}
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
const mid = Math.min(Math.max(half(lo + hi) - half(tw), lo + 1), lastStart)
|
|
273
|
+
let at = mid
|
|
274
|
+
for (let d = 0; ; d++) {
|
|
275
|
+
const left = mid - d
|
|
276
|
+
const right = mid + d
|
|
277
|
+
if (left < lo + 1 && right > lastStart) break
|
|
278
|
+
if (left >= lo + 1 && clear(left)) {
|
|
279
|
+
at = left
|
|
280
|
+
break
|
|
281
|
+
}
|
|
282
|
+
if (right <= lastStart && clear(right)) {
|
|
283
|
+
at = right
|
|
284
|
+
break
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
drawTextOverEdges(canvas, text, at, y, 'edgeLabel')
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Edge labels queued while routing and written once every route has
|
|
293
|
+
* landed (`flushLabels`), so a label knows what it sits on: it interrupts
|
|
294
|
+
* a line the way a lane label interrupts its lane (the text sits across the
|
|
295
|
+
* line, which stays readable either side — a cardinality on its own bus
|
|
296
|
+
* row, a label across a return climbing past it) and stops short of a box
|
|
297
|
+
* or other text.
|
|
298
|
+
*/
|
|
299
|
+
/** Queue a label to be written after every line, so it interrupts them. */
|
|
300
|
+
function placeLabel(canvas: Canvas, label: string, row: number, x: number): void {
|
|
301
|
+
canvas.labels.push({ label, row, x })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function flushLabels(canvas: Canvas): void {
|
|
305
|
+
for (const { label, row, x } of canvas.labels.splice(0)) writeLabel(canvas, label, row, x)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function writeLabel(canvas: Canvas, label: string, row: number, startX: number): void {
|
|
309
|
+
if (row >= canvas.h) return
|
|
310
|
+
let x = startX
|
|
311
|
+
for (const [c, cw] of measured(label)) {
|
|
312
|
+
if (cw === 0) continue
|
|
313
|
+
if (x + cw > canvas.w) break
|
|
314
|
+
let blocked = false
|
|
315
|
+
for (let k = 0; k < cw; k++) {
|
|
316
|
+
const i = canvas.idx(x + k, row)
|
|
317
|
+
if (canvas.ch[i] !== ' ' || canvas.occupied[i]) blocked = true
|
|
318
|
+
}
|
|
319
|
+
if (blocked) break
|
|
320
|
+
for (let k = 0; k < cw; k++) canvas.mask[canvas.idx(x + k, row)] = 0
|
|
321
|
+
canvas.set(x, row, c, 'edgeLabel')
|
|
322
|
+
for (let k = 1; k < cw; k++) canvas.set(x + k, row, CONT, 'edgeLabel')
|
|
323
|
+
x += cw
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-axis coordinate assignment for a layered graph — Brandes and Köpf,
|
|
3
|
+
* "Fast and Simple Horizontal Coordinate Assignment" (2002).
|
|
4
|
+
*
|
|
5
|
+
* Every node is aligned with a median neighbour where that crosses no
|
|
6
|
+
* long-edge chain (a type-1 conflict), aligned nodes form blocks that get
|
|
7
|
+
* one coordinate, and blocks are compacted as tightly as the ordering and
|
|
8
|
+
* the separations allow. Of the four runs (top/bottom × left/right) the one
|
|
9
|
+
* with the straightest segments is kept.
|
|
10
|
+
*
|
|
11
|
+
* Input and output are in the layered graph of `orderRanks`: ids are real
|
|
12
|
+
* nodes and virtual chain nodes alike, `size[v]` is each one's cross-axis
|
|
13
|
+
* extent, and a virtual node is any id at or past `realCount`. The returned
|
|
14
|
+
* centres are non-negative integers that keep `sep(left, right)` cells
|
|
15
|
+
* between each pair of neighbours. `offset(v)` moves a node off its aligned
|
|
16
|
+
* coordinate afterwards — a chain that should line up with a port beside
|
|
17
|
+
* its endpoint's centre rather than the centre itself.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface LayeredGraph {
|
|
21
|
+
layers: number[][]
|
|
22
|
+
up: number[][]
|
|
23
|
+
down: number[][]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function brandesKoepf(
|
|
27
|
+
g: LayeredGraph,
|
|
28
|
+
size: number[],
|
|
29
|
+
sep: (left: number, right: number) => number,
|
|
30
|
+
realCount: number,
|
|
31
|
+
offset: (v: number) => number = () => 0,
|
|
32
|
+
): number[] {
|
|
33
|
+
const n = g.up.length
|
|
34
|
+
const layerOf = new Array<number>(n).fill(0)
|
|
35
|
+
const pos = new Array<number>(n).fill(0)
|
|
36
|
+
g.layers.forEach((row, r) => {
|
|
37
|
+
row.forEach((v, i) => {
|
|
38
|
+
layerOf[v] = r
|
|
39
|
+
pos[v] = i
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
const conflicts = markConflicts(g, pos, realCount)
|
|
43
|
+
|
|
44
|
+
const runs: number[][] = []
|
|
45
|
+
const roots: number[][] = []
|
|
46
|
+
for (const fromBottom of [false, true]) {
|
|
47
|
+
for (const fromRight of [false, true]) {
|
|
48
|
+
const layers = fromBottom ? [...g.layers].reverse() : g.layers
|
|
49
|
+
const view: LayeredGraph = {
|
|
50
|
+
layers: fromRight ? layers.map((row) => [...row].reverse()) : layers,
|
|
51
|
+
up: fromBottom ? g.down : g.up,
|
|
52
|
+
down: fromBottom ? g.up : g.down,
|
|
53
|
+
}
|
|
54
|
+
const vpos = new Array<number>(n).fill(0)
|
|
55
|
+
for (const row of view.layers) row.forEach((v, i) => (vpos[v] = i))
|
|
56
|
+
// Mirrored runs see the pair the other way round.
|
|
57
|
+
const gap = fromRight ? (l: number, r: number) => sep(r, l) : sep
|
|
58
|
+
const alignment = alignVertically(view, vpos, conflicts)
|
|
59
|
+
const x = compact(view, vpos, size, gap, alignment)
|
|
60
|
+
runs.push(fromRight ? x.map((c) => -c) : x)
|
|
61
|
+
roots.push(alignment.root)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const chosen = straightest(runs, g, size, realCount)
|
|
65
|
+
const centers = runs[chosen].map((c, v) => c + offset(v))
|
|
66
|
+
const root = roots[chosen]
|
|
67
|
+
|
|
68
|
+
// Rounding, offsets and the class shifts of the compaction can shave a
|
|
69
|
+
// cell off a separation; sweeping each layer left to right restores it,
|
|
70
|
+
// moving a whole aligned block so the run's straight segments stay
|
|
71
|
+
// straight, until every layer holds. Then pin the origin at 0.
|
|
72
|
+
const members = new Map<number, number[]>()
|
|
73
|
+
for (let v = 0; v < n; v++) {
|
|
74
|
+
const list = members.get(root[v])
|
|
75
|
+
if (list) list.push(v)
|
|
76
|
+
else members.set(root[v], [v])
|
|
77
|
+
}
|
|
78
|
+
for (let pass = 0; pass < n; pass++) {
|
|
79
|
+
let moved = false
|
|
80
|
+
for (const row of g.layers) {
|
|
81
|
+
for (let i = 1; i < row.length; i++) {
|
|
82
|
+
const [u, w] = [row[i - 1], row[i]]
|
|
83
|
+
const gap = size[u] / 2 + sep(u, w) + size[w] / 2
|
|
84
|
+
const deficit = centers[u] + gap - centers[w]
|
|
85
|
+
if (deficit <= 0) continue
|
|
86
|
+
for (const m of members.get(root[w]) ?? [w]) centers[m] += deficit
|
|
87
|
+
moved = true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!moved) break
|
|
91
|
+
}
|
|
92
|
+
let min = Number.POSITIVE_INFINITY
|
|
93
|
+
for (const row of g.layers) for (const v of row) min = Math.min(min, centers[v] - size[v] / 2)
|
|
94
|
+
if (!Number.isFinite(min)) min = 0
|
|
95
|
+
return centers.map((c) => Math.max(0, Math.round(c - min)))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Type-1 conflicts: a segment between a real node and anything crossing an
|
|
100
|
+
* inner segment (virtual to virtual). Inner segments win alignment so long
|
|
101
|
+
* edges stay straight; the crossing segment is barred from aligning.
|
|
102
|
+
*/
|
|
103
|
+
function markConflicts(g: LayeredGraph, pos: number[], realCount: number): Set<number> {
|
|
104
|
+
const n = g.up.length
|
|
105
|
+
const conflicts = new Set<number>()
|
|
106
|
+
const isVirtual = (v: number): boolean => v >= realCount
|
|
107
|
+
const innerUpper = (v: number): number | null => {
|
|
108
|
+
if (!isVirtual(v)) return null
|
|
109
|
+
const u = g.up[v].find(isVirtual)
|
|
110
|
+
return u === undefined ? null : u
|
|
111
|
+
}
|
|
112
|
+
for (let i = 1; i < g.layers.length; i++) {
|
|
113
|
+
const row = g.layers[i]
|
|
114
|
+
const upper = g.layers[i - 1]
|
|
115
|
+
let k0 = 0
|
|
116
|
+
let l = 0
|
|
117
|
+
for (let l1 = 0; l1 < row.length; l1++) {
|
|
118
|
+
const v = row[l1]
|
|
119
|
+
const inner = innerUpper(v)
|
|
120
|
+
if (l1 !== row.length - 1 && inner === null) continue
|
|
121
|
+
const k1 = inner === null ? upper.length - 1 : pos[inner]
|
|
122
|
+
for (; l <= l1; l++) {
|
|
123
|
+
const w = row[l]
|
|
124
|
+
for (const u of g.up[w]) {
|
|
125
|
+
if ((pos[u] < k0 || pos[u] > k1) && !(isVirtual(u) && isVirtual(w))) {
|
|
126
|
+
conflicts.add(u * n + w)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
k0 = k1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return conflicts
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface Alignment {
|
|
137
|
+
root: number[]
|
|
138
|
+
align: number[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Align each node with a median upper neighbour, left to right, no crossings. */
|
|
142
|
+
function alignVertically(g: LayeredGraph, pos: number[], conflicts: Set<number>): Alignment {
|
|
143
|
+
const n = g.up.length
|
|
144
|
+
const root = Array.from({ length: n }, (_, v) => v)
|
|
145
|
+
const align = [...root]
|
|
146
|
+
for (let i = 1; i < g.layers.length; i++) {
|
|
147
|
+
let r = -1
|
|
148
|
+
for (const v of g.layers[i]) {
|
|
149
|
+
const ups = [...g.up[v]].sort((a, b) => pos[a] - pos[b])
|
|
150
|
+
const d = ups.length
|
|
151
|
+
if (d === 0) continue
|
|
152
|
+
const medians = d % 2 === 1 ? [ups[(d - 1) / 2]] : [ups[d / 2 - 1], ups[d / 2]]
|
|
153
|
+
for (const u of medians) {
|
|
154
|
+
if (align[v] !== v) break
|
|
155
|
+
if (conflicts.has(u * n + v) || r >= pos[u]) continue
|
|
156
|
+
align[u] = v
|
|
157
|
+
root[v] = root[u]
|
|
158
|
+
align[v] = root[v]
|
|
159
|
+
r = pos[u]
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { root, align }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Place blocks as far left as the ordering allows; returns centre per node. */
|
|
167
|
+
function compact(
|
|
168
|
+
g: LayeredGraph,
|
|
169
|
+
pos: number[],
|
|
170
|
+
size: number[],
|
|
171
|
+
sep: (left: number, right: number) => number,
|
|
172
|
+
{ root, align }: Alignment,
|
|
173
|
+
): number[] {
|
|
174
|
+
const n = g.up.length
|
|
175
|
+
const pred = new Array<number>(n).fill(-1)
|
|
176
|
+
for (const row of g.layers) for (let i = 1; i < row.length; i++) pred[row[i]] = row[i - 1]
|
|
177
|
+
const sink = Array.from({ length: n }, (_, v) => v)
|
|
178
|
+
const shift = new Array<number>(n).fill(Number.POSITIVE_INFINITY)
|
|
179
|
+
const x = new Array<number>(n).fill(Number.NaN)
|
|
180
|
+
const delta = (u: number, w: number): number => size[u] / 2 + sep(u, w) + size[w] / 2
|
|
181
|
+
|
|
182
|
+
const placeBlock = (v: number): void => {
|
|
183
|
+
if (!Number.isNaN(x[v])) return
|
|
184
|
+
x[v] = 0
|
|
185
|
+
let w = v
|
|
186
|
+
do {
|
|
187
|
+
if (pos[w] > 0) {
|
|
188
|
+
const p = pred[w]
|
|
189
|
+
const u = root[p]
|
|
190
|
+
placeBlock(u)
|
|
191
|
+
if (sink[v] === v) sink[v] = sink[u]
|
|
192
|
+
if (sink[v] !== sink[u]) {
|
|
193
|
+
shift[sink[u]] = Math.min(shift[sink[u]], x[v] - x[u] - delta(p, w))
|
|
194
|
+
} else {
|
|
195
|
+
x[v] = Math.max(x[v], x[u] + delta(p, w))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
w = align[w]
|
|
199
|
+
} while (w !== v)
|
|
200
|
+
}
|
|
201
|
+
for (let v = 0; v < n; v++) if (root[v] === v) placeBlock(v)
|
|
202
|
+
|
|
203
|
+
const out = new Array<number>(n)
|
|
204
|
+
for (let v = 0; v < n; v++) {
|
|
205
|
+
out[v] = x[root[v]]
|
|
206
|
+
const s = shift[sink[root[v]]]
|
|
207
|
+
if (Number.isFinite(s)) out[v] += s
|
|
208
|
+
}
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The run whose segments bend least (weighted sum of cross-axis offsets)
|
|
214
|
+
* among those no wider than the narrowest run plus a fifth. Brandes–Köpf
|
|
215
|
+
* balance the four by averaging medians, which splits the difference
|
|
216
|
+
* wherever the runs disagree and leaves a chain kinked by a cell or two;
|
|
217
|
+
* on a cell grid one consistent run reads better. On a large graph a
|
|
218
|
+
* straighter run can be much wider, and width is what a terminal lacks.
|
|
219
|
+
*/
|
|
220
|
+
function straightest(
|
|
221
|
+
runs: number[][],
|
|
222
|
+
g: LayeredGraph,
|
|
223
|
+
size: number[],
|
|
224
|
+
realCount: number,
|
|
225
|
+
): number {
|
|
226
|
+
// A kinked chain segment costs most (a bus track in every band it jogs
|
|
227
|
+
// through), a kinked edge between boxes next; the jog where a long edge
|
|
228
|
+
// leaves or reaches its box is the natural place for it to step aside.
|
|
229
|
+
const weight = (v: number, w: number): number => {
|
|
230
|
+
const virtual = Number(v >= realCount) + Number(w >= realCount)
|
|
231
|
+
return virtual === 2 ? 8 : virtual === 1 ? 1 : 4
|
|
232
|
+
}
|
|
233
|
+
const scored = runs.map((x, index) => {
|
|
234
|
+
let bends = 0
|
|
235
|
+
for (let v = 0; v < g.down.length; v++) {
|
|
236
|
+
for (const w of g.down[v]) bends += weight(v, w) * Math.abs(x[v] - x[w])
|
|
237
|
+
}
|
|
238
|
+
let lo = Number.POSITIVE_INFINITY
|
|
239
|
+
let hi = Number.NEGATIVE_INFINITY
|
|
240
|
+
x.forEach((c, v) => {
|
|
241
|
+
lo = Math.min(lo, c - size[v] / 2)
|
|
242
|
+
hi = Math.max(hi, c + size[v] / 2)
|
|
243
|
+
})
|
|
244
|
+
return { index, bends, width: hi - lo }
|
|
245
|
+
})
|
|
246
|
+
const narrowest = Math.min(...scored.map((s) => s.width))
|
|
247
|
+
let best = scored[0]
|
|
248
|
+
for (const s of scored) {
|
|
249
|
+
if (s.width > narrowest * 1.2) continue
|
|
250
|
+
if (best.width > narrowest * 1.2 || s.bends < best.bends || (s.bends === best.bends && s.width < best.width)) {
|
|
251
|
+
best = s
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return best.index
|
|
255
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagram registry: one entry per supported diagram type.
|
|
3
|
+
*
|
|
4
|
+
* `diagramKind` and `render` both resolve through this table, so the header
|
|
5
|
+
* test each parser gates on and the one `diagramKind` reports are the same
|
|
6
|
+
* function by construction. Adding a diagram type is one module in
|
|
7
|
+
* `diagrams/` plus one entry here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Canvas } from './canvas.ts'
|
|
11
|
+
import { classDiagram } from './diagrams/class.ts'
|
|
12
|
+
import { er } from './diagrams/er.ts'
|
|
13
|
+
import { flowchart } from './diagrams/flowchart.ts'
|
|
14
|
+
import { gitgraph } from './diagrams/gitgraph.ts'
|
|
15
|
+
import { mindmap } from './diagrams/mindmap.ts'
|
|
16
|
+
import { pie } from './diagrams/pie.ts'
|
|
17
|
+
import { sequence } from './diagrams/sequence.ts'
|
|
18
|
+
import { state } from './diagrams/state.ts'
|
|
19
|
+
import { timeline } from './diagrams/timeline.ts'
|
|
20
|
+
import { type Limits, stripControls } from './labels.ts'
|
|
21
|
+
import { headerKind, statementsOf } from './statements.ts'
|
|
22
|
+
|
|
23
|
+
/** A diagram type this renderer draws. */
|
|
24
|
+
export type DiagramKind =
|
|
25
|
+
| 'flowchart'
|
|
26
|
+
| 'state'
|
|
27
|
+
| 'class'
|
|
28
|
+
| 'er'
|
|
29
|
+
| 'sequence'
|
|
30
|
+
| 'pie'
|
|
31
|
+
| 'mindmap'
|
|
32
|
+
| 'timeline'
|
|
33
|
+
| 'gitgraph'
|
|
34
|
+
|
|
35
|
+
export interface Diagram {
|
|
36
|
+
kind: DiagramKind
|
|
37
|
+
/**
|
|
38
|
+
* Header keywords declaring this diagram type, lowercased. Matched exactly:
|
|
39
|
+
* upstream's prefix tests accept junk like `stateDiagramFoo` that mermaid
|
|
40
|
+
* proper rejects at the grammar stage.
|
|
41
|
+
*/
|
|
42
|
+
headers: string[]
|
|
43
|
+
/** Parse and lay out within `limits`; `null` means nothing was drawn. */
|
|
44
|
+
render(
|
|
45
|
+
src: string,
|
|
46
|
+
limits: Limits,
|
|
47
|
+
): {
|
|
48
|
+
canvas: Canvas
|
|
49
|
+
warnings: string[]
|
|
50
|
+
classDefs: Record<string, Record<string, string>>
|
|
51
|
+
} | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DIAGRAMS: Diagram[] = [
|
|
55
|
+
flowchart,
|
|
56
|
+
state,
|
|
57
|
+
classDiagram,
|
|
58
|
+
er,
|
|
59
|
+
sequence,
|
|
60
|
+
pie,
|
|
61
|
+
mindmap,
|
|
62
|
+
timeline,
|
|
63
|
+
gitgraph,
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/** The registry entry `src`'s header declares, or `null`. */
|
|
67
|
+
export function diagramFor(src: string): Diagram | null {
|
|
68
|
+
const header = headerKind(statementsOf(src))
|
|
69
|
+
if (header === null) return null
|
|
70
|
+
return DIAGRAMS.find((d) => d.headers.includes(header)) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The kind of diagram `src` declares, or `null` if its header names no type
|
|
75
|
+
* this renderer draws.
|
|
76
|
+
*
|
|
77
|
+
* Reads the header only — it says nothing about whether the body parses. Pair
|
|
78
|
+
* it with `render` to tell a source this renderer will never draw from one that
|
|
79
|
+
* is merely malformed:
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* render(src) === null && diagramKind(src) !== null // syntax error
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function diagramKind(src: string): DiagramKind | null {
|
|
86
|
+
// The same strip `render` applies, so the two entry points agree on any src.
|
|
87
|
+
return diagramFor(stripControls(src))?.kind ?? null
|
|
88
|
+
}
|