@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,353 @@
1
+ import { measured, stringWidth } from './width.ts'
2
+
3
+ /** How much text a diagram shows before wrapping or truncating. */
4
+ export interface Limits {
5
+ /** Node labels wrap to at most this many display columns per line ... */
6
+ wrap: number
7
+ /** ... and at most this many lines; overflow is truncated with an ellipsis. */
8
+ lines: number
9
+ /** Edge labels are truncated to this many columns. */
10
+ label: number
11
+ }
12
+
13
+ /** The default limits, and the tighter ones `render` falls back through
14
+ * when a diagram is wider than the space it was given. */
15
+ export const LIMITS: Limits[] = [
16
+ { wrap: 24, lines: 4, label: 28 },
17
+ { wrap: 16, lines: 3, label: 16 },
18
+ { wrap: 12, lines: 2, label: 10 },
19
+ ]
20
+ export const DEFAULT_LIMITS: Limits = LIMITS[0]
21
+
22
+ /**
23
+ * Identifier-boundary characters preferred as break points when a single word
24
+ * is too wide to fit, so it is not sliced mid-segment.
25
+ *
26
+ * Mirrors `TOKEN_BREAK_CHARS` in grok-build's
27
+ * `third_party/mermaid-to-svg/src/text_wrap.rs`; the two renderers are
28
+ * deliberately independent, so keep these in sync.
29
+ */
30
+ const LABEL_BREAK_CHARS = ['_', '-', '.', '/']
31
+
32
+ /**
33
+ * ASCII-only case folding, matching Rust's `to_ascii_lowercase`.
34
+ *
35
+ * `String.prototype.toLowerCase` can change a string's length (`İ` becomes two
36
+ * code points), which would desync the byte offsets some parsers slice with.
37
+ */
38
+ export const asciiLower = (s: string): string => s.replace(/[A-Z]/g, (c) => c.toLowerCase())
39
+ export const asciiUpper = (s: string): string => s.replace(/[a-z]/g, (c) => c.toUpperCase())
40
+
41
+ /**
42
+ * C0 and C1 controls, less the `\t\n\r` the parsers and `srcLines` read.
43
+ *
44
+ * They measure one column and paint none, so a box sized around one is drawn a
45
+ * column short of its own border; NUL also collides with the `CONT` sentinel
46
+ * and is dropped after layout has already paid for its cell; ESC would inject
47
+ * ANSI into the caller's scrollback. `decodeEntityBody` refuses to decode an
48
+ * entity into one — this closes the same hole for literals.
49
+ */
50
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the point is to match them
51
+ const CONTROLS = /[\0-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g
52
+
53
+ /** Applied by every public entry point that takes untrusted source. */
54
+ export const stripControls = (src: string): string => src.replace(CONTROLS, '')
55
+
56
+ /**
57
+ * Split source into lines the way Rust's `str::lines()` does: on `\n`, with a
58
+ * trailing `\r` stripped, and *without* a final empty line when the input ends
59
+ * in a newline. `String.split` yields that extra element, which would show up
60
+ * as a spurious blank row inside a source box.
61
+ */
62
+ export function srcLines(src: string): string[] {
63
+ const out = src.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l))
64
+ if (out.length > 0 && out[out.length - 1] === '') out.pop()
65
+ return out
66
+ }
67
+
68
+ const ALNUM = /[\p{Alphabetic}\p{N}]/u
69
+
70
+ /** Matches Rust's `char::is_alphanumeric`. */
71
+ const isAlphanumeric = (c: string): boolean => ALNUM.test(c)
72
+
73
+ /** Characters allowed in a bare node/state/class identifier. */
74
+ export const isIdChar = (c: string): boolean => isAlphanumeric(c) || c === '_'
75
+
76
+ const ENTITY_LOOKAHEAD = 10
77
+
78
+ const NAMED_ENTITIES: Record<string, string> = {
79
+ lt: '<',
80
+ gt: '>',
81
+ amp: '&',
82
+ quot: '"',
83
+ apos: "'",
84
+ }
85
+
86
+ function decodeEntityBody(body: string): string | null {
87
+ const named = NAMED_ENTITIES[body]
88
+ if (named !== undefined) return named
89
+ if (!body.startsWith('#')) return null
90
+ const num = body.slice(1)
91
+ const hex = /^[xX]/.test(num)
92
+ const digits = hex ? num.slice(1) : num
93
+ if (!(hex ? /^[0-9a-fA-F]+$/ : /^[0-9]+$/).test(digits)) return null
94
+ const code = Number.parseInt(digits, hex ? 16 : 10)
95
+ // Surrogates and out-of-range values are not characters at all.
96
+ if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return null
97
+ // Reject control chars: NUL collides with the CONT sentinel and ESC would
98
+ // inject ANSI into scrollback.
99
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return null
100
+ return String.fromCodePoint(code)
101
+ }
102
+
103
+ /**
104
+ * Decode HTML entities in label text. Called once per label: via `cleanLabel`
105
+ * for bracketed labels, or explicitly at each direct-push sink.
106
+ */
107
+ export function decodeHtmlEntities(s: string): string {
108
+ if (!s.includes('&')) return s
109
+ const chars = [...s]
110
+ let out = ''
111
+ let i = 0
112
+ while (i < chars.length) {
113
+ if (chars[i] !== '&') {
114
+ out += chars[i]
115
+ i++
116
+ continue
117
+ }
118
+ // Scan a bounded window including the terminating `;`, so a stray `&` or an
119
+ // over-long run stays literal.
120
+ const hi = Math.min(i + 1 + ENTITY_LOOKAHEAD, chars.length)
121
+ let semi = -1
122
+ for (let j = i + 1; j < hi; j++) {
123
+ if (chars[j] === ';') {
124
+ semi = j
125
+ break
126
+ }
127
+ }
128
+ const decoded = semi === -1 ? null : decodeEntityBody(chars.slice(i + 1, semi).join(''))
129
+ if (decoded === null) {
130
+ out += '&'
131
+ i++
132
+ } else {
133
+ // Resume past the `;`. The single pass never re-scans emitted text, so
134
+ // `&amp;lt;` decodes to the literal `&lt;` rather than to `<`.
135
+ out += decoded
136
+ i = semi + 1
137
+ }
138
+ }
139
+ return out
140
+ }
141
+
142
+ /** Strip markdown emphasis from a `` `backtick` `` label string. */
143
+ function stripMarkdown(s: string): string {
144
+ const noCode = [...s].filter((c) => c !== '`').join('')
145
+ const noStrong = noCode.replaceAll('**', '').replaceAll('__', '')
146
+ const chars = [...noStrong]
147
+ let out = ''
148
+ for (let i = 0; i < chars.length; i++) {
149
+ const c = chars[i]
150
+ // Keep `*`/`_` only when they sit inside a word, so snake_case survives.
151
+ const inWord =
152
+ i > 0 &&
153
+ isAlphanumeric(chars[i - 1]) &&
154
+ chars[i + 1] !== undefined &&
155
+ isAlphanumeric(chars[i + 1])
156
+ if ((c === '*' || c === '_') && !inWord) continue
157
+ out += c
158
+ }
159
+ return out.trim()
160
+ }
161
+
162
+ /**
163
+ * Inline formatting tags that carry no meaning in a terminal. Anything else
164
+ * that looks like a tag — `Vec<String>`, `<id>` — is left alone.
165
+ */
166
+ const HTML_FORMAT_TAGS = new Set([
167
+ 'b',
168
+ 'strong',
169
+ 'i',
170
+ 'em',
171
+ 'u',
172
+ 's',
173
+ 'strike',
174
+ 'del',
175
+ 'ins',
176
+ 'mark',
177
+ 'small',
178
+ 'big',
179
+ 'sub',
180
+ 'sup',
181
+ 'code',
182
+ 'kbd',
183
+ 'samp',
184
+ 'var',
185
+ 'tt',
186
+ 'span',
187
+ 'font',
188
+ 'q',
189
+ 'abbr',
190
+ 'cite',
191
+ 'pre',
192
+ ])
193
+
194
+ /** Read a tag starting at `start`, returning its name and the index after `>`. */
195
+ function htmlTagAt(chars: string[], start: number): { name: string; end: number } | null {
196
+ let i = start + 1
197
+ if (chars[i] === '/') i++
198
+ const nameStart = i
199
+ while (i < chars.length && /^[0-9A-Za-z]$/.test(chars[i])) i++
200
+ if (i === nameStart) return null
201
+ const name = chars.slice(nameStart, i).join('')
202
+ while (i < chars.length && chars[i] !== '>') {
203
+ if (chars[i] === '<') return null
204
+ i++
205
+ }
206
+ return chars[i] === '>' ? { name, end: i + 1 } : null
207
+ }
208
+
209
+ function stripHtmlTags(s: string): string {
210
+ const chars = [...s]
211
+ let out = ''
212
+ let i = 0
213
+ while (i < chars.length) {
214
+ if (chars[i] === '<') {
215
+ const tag = htmlTagAt(chars, i)
216
+ if (tag) {
217
+ const lower = tag.name.toLowerCase()
218
+ if (lower === 'br') {
219
+ out += ' '
220
+ i = tag.end
221
+ continue
222
+ }
223
+ if (HTML_FORMAT_TAGS.has(lower)) {
224
+ i = tag.end
225
+ continue
226
+ }
227
+ }
228
+ }
229
+ out += chars[i]
230
+ i++
231
+ }
232
+ return out
233
+ }
234
+
235
+ /** Strip one matching pair of wrapping delimiters, if present. */
236
+ function unwrap(s: string, open: string, close: string): string | null {
237
+ return s.length >= open.length + close.length && s.startsWith(open) && s.endsWith(close)
238
+ ? s.slice(open.length, s.length - close.length)
239
+ : null
240
+ }
241
+
242
+ /**
243
+ * Normalise raw label text: strip markup, unquote, and decode entities.
244
+ *
245
+ * Decoding happens after tag-stripping so `<b>` is removed as markup while
246
+ * `&lt;b&gt;` survives as the literal text `<b>`.
247
+ */
248
+ export function cleanLabel(raw: string): string {
249
+ const trimmed = stripHtmlTags(raw.trim()).trim()
250
+ const unquoted = (unwrap(trimmed, '"', '"') ?? unwrap(trimmed, "'", "'") ?? trimmed).trim()
251
+ const md = unwrap(unquoted, '`', '`')
252
+ return decodeHtmlEntities(md === null ? unquoted : stripMarkdown(md.trim()))
253
+ }
254
+
255
+ /** Mermaid writes generics as `List~T~`; show them as `List<T>`. */
256
+ export function displayGenerics(s: string): string {
257
+ let out = ''
258
+ let open = false
259
+ for (const c of s) {
260
+ if (c === '~') {
261
+ out += open ? '>' : '<'
262
+ open = !open
263
+ } else {
264
+ out += c
265
+ }
266
+ }
267
+ return out
268
+ }
269
+
270
+ /** Index of the last identifier-boundary character, or -1. */
271
+ function lastBreak(s: string): number {
272
+ let best = -1
273
+ for (const c of LABEL_BREAK_CHARS) best = Math.max(best, s.lastIndexOf(c))
274
+ return best
275
+ }
276
+
277
+ /**
278
+ * Wrap a label to `width` columns over at most `maxLines` lines, truncating the
279
+ * last line with an ellipsis if it overflows.
280
+ *
281
+ * A word too wide to fit is broken after the last identifier boundary
282
+ * (`_-./`) that fits, falling back to a per-character break when it has none.
283
+ */
284
+ export function wrapLabel(label: string, width: number, maxLines: number): string[] {
285
+ width = Math.max(1, width)
286
+ const lines: string[] = []
287
+ let cur = ''
288
+ let curW = 0
289
+
290
+ for (const word of label.split(/\s+/).filter((w) => w !== '')) {
291
+ const ww = stringWidth(word)
292
+ if (ww > width) {
293
+ if (cur !== '') {
294
+ lines.push(cur)
295
+ cur = ''
296
+ }
297
+ let chunk = ''
298
+ let chunkW = 0
299
+ for (const [ch, cw] of measured(word)) {
300
+ if (chunkW + cw > width && chunk !== '') {
301
+ const p = lastBreak(chunk)
302
+ const carry = p === -1 ? '' : chunk.slice(p + 1)
303
+ lines.push(p === -1 ? chunk : chunk.slice(0, p + 1))
304
+ chunk = carry
305
+ chunkW = stringWidth(carry)
306
+ }
307
+ chunk += ch
308
+ chunkW += cw
309
+ }
310
+ cur = chunk
311
+ curW = chunkW
312
+ } else if (cur === '') {
313
+ cur = word
314
+ curW = ww
315
+ } else if (curW + 1 + ww <= width) {
316
+ cur += ` ${word}`
317
+ curW += 1 + ww
318
+ } else {
319
+ lines.push(cur)
320
+ cur = word
321
+ curW = ww
322
+ }
323
+ }
324
+ if (cur !== '') lines.push(cur)
325
+ if (lines.length === 0) lines.push('')
326
+
327
+ if (lines.length > maxLines) {
328
+ lines.length = maxLines
329
+ const target = Math.max(1, width - 1)
330
+ let s = ''
331
+ let sw = 0
332
+ for (const [ch, cw] of measured(lines[lines.length - 1])) {
333
+ if (sw + cw > target) break
334
+ s += ch
335
+ sw += cw
336
+ }
337
+ lines[lines.length - 1] = `${s}…`
338
+ }
339
+ return lines
340
+ }
341
+
342
+ /** Truncate to `inner` columns, leaving room for the ellipsis. */
343
+ export function fitLabel(label: string, inner: number): string {
344
+ if (stringWidth(label) <= inner) return label
345
+ let out = ''
346
+ let used = 0
347
+ for (const [c, cw] of measured(label)) {
348
+ if (used + cw + 1 > inner) break
349
+ out += c
350
+ used += cw
351
+ }
352
+ return `${out}…`
353
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Sequence diagram layout.
3
+ *
4
+ * Participants get one column each, with lifelines running the full height and
5
+ * a box repeated at top and bottom. Column gaps are solved from the widest
6
+ * thing that has to fit between any two columns — a message label, a note, a
7
+ * self-message stub — then items stack down the canvas in source order.
8
+ */
9
+
10
+ import { Canvas, D, drawTextOverEdges, L, R, U } from './canvas.ts'
11
+ import type { NoteAnchor, SeqItem, Sequence } from './diagrams/sequence.ts'
12
+ import { fitLabel, type Limits } from './labels.ts'
13
+ import type { CanvasResult } from './graph-render.ts'
14
+ import { half, MAX_CANVAS_CELLS, PAD, type Placed, sat } from './layout.ts'
15
+ import { drawBox } from './paint.ts'
16
+ import { stringWidth } from './width.ts'
17
+
18
+ /** Minimum columns between adjacent lifelines. */
19
+ const SEQ_GAP = 5
20
+
21
+ /** Where a note box sits, given the lifeline positions. */
22
+ function noteGeometry(xs: number[], anchor: NoteAnchor, textW: number): { x: number; w: number } {
23
+ if (anchor.kind === 'over') {
24
+ const center = half(xs[anchor.from] + xs[anchor.to])
25
+ const w = Math.max(xs[anchor.to] - xs[anchor.from] + 5, textW + 2 * PAD + 2)
26
+ return { x: sat(center, half(w)), w }
27
+ }
28
+ const w = textW + 2 * PAD + 2
29
+ if (anchor.kind === 'left') return { x: sat(xs[anchor.at], 2 + w - 1), w }
30
+ return { x: xs[anchor.at] + 2, w }
31
+ }
32
+
33
+ const itemTextW = (text: string | null): number => (text === null ? 0 : stringWidth(text))
34
+
35
+ export function layoutSequence(seq: Sequence, limits: Limits): CanvasResult {
36
+ const n = seq.labels.length
37
+ const labels = seq.labels.map((l) => fitLabel(l, limits.wrap))
38
+ const boxW = labels.map((l) => Math.max(1, stringWidth(l)) + 2 * PAD + 2)
39
+ const boxH = 3
40
+
41
+ const gaps = Array.from({ length: sat(n, 1) }, (_, i) =>
42
+ Math.max(SEQ_GAP, Math.ceil(boxW[i] / 2) + Math.ceil(boxW[i + 1] / 2) + 1),
43
+ )
44
+
45
+ // Each requirement is "columns l..r together need at least `need` cells".
46
+ const reqs: [number, number, number][] = []
47
+ for (const item of seq.items) {
48
+ if (item.kind === 'message') {
49
+ const tw = itemTextW(item.text)
50
+ if (item.from !== item.to) {
51
+ reqs.push([Math.min(item.from, item.to), Math.max(item.from, item.to), Math.max(tw + 2, 4)])
52
+ } else if (item.from + 1 < n) {
53
+ reqs.push([item.from, item.from + 1, 5 + tw + 2])
54
+ }
55
+ } else if (item.kind === 'note') {
56
+ const tw = stringWidth(item.text)
57
+ const a = item.anchor
58
+ if (a.kind === 'over' && a.from < a.to) {
59
+ reqs.push([a.from, a.to, sat(tw, 1)])
60
+ } else if (a.kind === 'over') {
61
+ const need = Math.ceil((tw + 4) / 2) + 2
62
+ if (a.from > 0) reqs.push([a.from - 1, a.from, need])
63
+ if (a.from + 1 < n) reqs.push([a.from, a.from + 1, need])
64
+ } else if (a.kind === 'left' && a.at > 0) {
65
+ reqs.push([a.at - 1, a.at, tw + 7])
66
+ } else if (a.kind === 'right' && a.at + 1 < n) {
67
+ reqs.push([a.at, a.at + 1, tw + 7])
68
+ }
69
+ }
70
+ }
71
+ // Narrowest spans first, so a wide requirement absorbs what they already gave.
72
+ reqs.sort((a, b) => a[1] - a[0] - (b[1] - b[0]))
73
+ for (const [l, r, need] of reqs) {
74
+ let cur = 0
75
+ for (let i = l; i < r; i++) cur += gaps[i]
76
+ if (cur < need) gaps[r - 1] += need - cur
77
+ }
78
+
79
+ const xs = new Array<number>(n)
80
+ xs[0] = half(boxW[0])
81
+ for (let i = 1; i < n; i++) xs[i] = xs[i - 1] + gaps[i - 1]
82
+
83
+ // A note left of the first participant has no gap to grow; shift the whole
84
+ // diagram right instead so the note box lands beside the lifeline, not on it.
85
+ let leftPad = 0
86
+ for (const item of seq.items) {
87
+ if (item.kind === 'note' && item.anchor.kind === 'left' && item.anchor.at === 0) {
88
+ const w = stringWidth(item.text) + 2 * PAD + 2
89
+ leftPad = Math.max(leftPad, sat(w + 1, xs[0]))
90
+ }
91
+ }
92
+ if (leftPad > 0) for (let i = 0; i < n; i++) xs[i] += leftPad
93
+
94
+ let canvasW = xs[n - 1] + Math.ceil(boxW[n - 1] / 2) + 1
95
+ for (const item of seq.items) {
96
+ if (item.kind === 'message' && item.from === item.to) {
97
+ canvasW = Math.max(canvasW, xs[item.from] + 5 + itemTextW(item.text) + 1)
98
+ } else if (item.kind === 'note') {
99
+ const g = noteGeometry(xs, item.anchor, stringWidth(item.text))
100
+ canvasW = Math.max(canvasW, g.x + g.w + 1)
101
+ } else if (item.kind === 'divider') {
102
+ canvasW = Math.max(canvasW, stringWidth(item.text) + 4)
103
+ }
104
+ }
105
+
106
+ const rows: number[] = []
107
+ let y = boxH + 1
108
+ for (const item of seq.items) {
109
+ rows.push(y)
110
+ y += rowHeight(item)
111
+ }
112
+ const bottomTop = y
113
+ const canvasH = bottomTop + boxH
114
+
115
+ if (canvasW * canvasH > MAX_CANVAS_CELLS) return null
116
+
117
+ const canvas = new Canvas(canvasW, canvasH)
118
+
119
+ for (let i = 0; i < n; i++) {
120
+ for (const by of [0, bottomTop]) {
121
+ drawBox(canvas, box(sat(xs[i], half(boxW[i])), by, boxW[i], boxH), [labels[i]], 'rect')
122
+ }
123
+ }
124
+ seq.items.forEach((item, k) => {
125
+ if (item.kind !== 'note') return
126
+ const g = noteGeometry(xs, item.anchor, stringWidth(item.text))
127
+ drawBox(canvas, box(g.x, rows[k], g.w, 3), [item.text], 'rect')
128
+ })
129
+
130
+ for (const x of xs) {
131
+ canvas.junction(x, boxH - 1, D)
132
+ canvas.segV(x, boxH, bottomTop - 1)
133
+ canvas.junction(x, bottomTop, U)
134
+ }
135
+
136
+ seq.items.forEach((item, k) => {
137
+ const r = rows[k]
138
+ if (item.kind === 'message') drawMessage(canvas, item, xs, r)
139
+ else if (item.kind === 'divider') drawDivider(canvas, item.text, r, canvasW)
140
+ })
141
+
142
+ // Activations turn the lifeline into a double line from the activating
143
+ // message's arrow row to the deactivating one's (to the bottom while still
144
+ // open) — two rails, echoing mermaid's slim activation rectangle. Setting
145
+ // the glyph directly leaves the mask bits for `finalizeMask`, whose
146
+ // double-tee pass resolves message junctions on the run (║ → ╟ ╢ ╫).
147
+ const startRow = (k: number): number => {
148
+ const item = seq.items[k]
149
+ const labeled = item.kind === 'message' && item.from !== item.to && item.text !== null
150
+ return rows[k] + (labeled ? 1 : 0)
151
+ }
152
+ const endRow = (k: number): number => {
153
+ const item = seq.items[k]
154
+ // A self-message stub returns two rows below where it left.
155
+ return item.kind === 'message' && item.from === item.to ? rows[k] + 2 : startRow(k)
156
+ }
157
+ for (const a of seq.activations) {
158
+ const y1 = a.to === null ? bottomTop - 1 : endRow(a.to)
159
+ for (let y = startRow(a.from); y <= y1; y++) {
160
+ const i = canvas.idx(xs[a.at], y)
161
+ if (canvas.mask[i] !== 0) canvas.ch[i] = '║'
162
+ }
163
+ }
164
+
165
+ canvas.finalizeMask()
166
+ return canvas
167
+ }
168
+
169
+ function rowHeight(item: SeqItem): number {
170
+ if (item.kind === 'note') return 4
171
+ if (item.kind === 'divider') return 2
172
+ if (item.from === item.to) return 4
173
+ return item.text !== null ? 3 : 2
174
+ }
175
+
176
+ /** Geometry for a box drawn by position and size; ranks are irrelevant here. */
177
+ const box = (x: number, y: number, w: number, h: number): Placed => ({
178
+ x,
179
+ y,
180
+ w,
181
+ h,
182
+ cx: x + half(w),
183
+ cy: y + 1,
184
+ rank: 0,
185
+ })
186
+
187
+ function drawMessage(
188
+ canvas: Canvas,
189
+ item: Extract<SeqItem, { kind: 'message' }>,
190
+ xs: number[],
191
+ r: number,
192
+ ): void {
193
+ const lineCh = item.dashed ? '╌' : '─'
194
+
195
+ if (item.from === item.to) {
196
+ // A stub that leaves the lifeline and returns two rows down.
197
+ const x = xs[item.from]
198
+ canvas.junction(x, r, R)
199
+ canvas.set(x + 1, r, lineCh, 'edge')
200
+ canvas.set(x + 2, r, lineCh, 'edge')
201
+ canvas.set(x + 3, r, '╮', 'edge')
202
+ canvas.set(x + 3, r + 1, '│', 'edge')
203
+ canvas.set(x + 1, r + 2, item.head === 'cross' ? '×' : '◄', 'edge')
204
+ canvas.set(x + 2, r + 2, lineCh, 'edge')
205
+ canvas.set(x + 3, r + 2, '╯', 'edge')
206
+ if (item.text !== null) drawTextOverEdges(canvas, item.text, x + 5, r + 1, 'text')
207
+ return
208
+ }
209
+
210
+ const x0 = xs[item.from]
211
+ const x1 = xs[item.to]
212
+ const rightward = x1 > x0
213
+ // A labelled message writes its text on `r` and draws the arrow below it.
214
+ const arrowRow = item.text !== null ? r + 1 : r
215
+ const lo = Math.min(x0, x1)
216
+ const hi = Math.max(x0, x1)
217
+
218
+ canvas.junction(x0, arrowRow, rightward ? R : L)
219
+ for (let x = lo + 1; x < hi; x++) canvas.set(x, arrowRow, lineCh, 'edge')
220
+ const headCh = item.head === 'cross' ? '×' : rightward ? '▶' : '◄'
221
+ canvas.set(rightward ? x1 - 1 : x1 + 1, arrowRow, headCh, 'edge')
222
+
223
+ if (item.text !== null) {
224
+ const span = hi - lo - 1
225
+ const t = fitLabel(item.text, Math.max(1, span))
226
+ drawTextOverEdges(canvas, t, lo + 1 + half(sat(span, stringWidth(t))), r, 'text')
227
+ }
228
+ }
229
+
230
+ /** A full-width rule labelling a `loop` / `alt` / `opt` block boundary. */
231
+ function drawDivider(canvas: Canvas, text: string, r: number, canvasW: number): void {
232
+ for (let x = 0; x < canvasW; x++) canvas.set(x, r, '─', 'edge')
233
+ drawTextOverEdges(canvas, ` ${fitLabel(text, sat(canvasW, 4))} `, 2, r, 'edgeLabel')
234
+ }