@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.
@@ -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
+ }