|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * LevelCode — AI · rich diagrams · the ASCII fallback (docs/RICH-DIAGRAMS.md, "UX → Fallback") |
| 3 | + * |
| 4 | + * "If rendering is unavailable, the user sees an ASCII rendering generated from the same spec, so |
| 5 | + * the model never has to draw ASCII itself." |
| 6 | + * |
| 7 | + * This is the SAME layout as the picture, not a second one: layout.layout() is run with every |
| 8 | + * character one cell wide, and its geometry is rasterised onto a character grid — boxes, connectors |
| 9 | + * with their corners and arrowheads, labels beside lines, group frames. So the fallback puts things |
| 10 | + * where the picture would have, and a fix to the layout fixes both. |
| 11 | + * |
| 12 | + * It is drawn by the editor, for a <pre> in a monospaced face — the one place box-and-arrow |
| 13 | + * characters hold their shape. That is the difference from a model typing them into prose. |
| 14 | + *--------------------------------------------------------------------------------------------*/ |
| 15 | +// @ts-check |
| 16 | +(function (root, factory) { |
| 17 | + 'use strict'; |
| 18 | + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./layout')); } |
| 19 | + else { (root.LCDiagram = root.LCDiagram || {}).ascii = factory(root.LCDiagram.layout); } |
| 20 | +}(typeof globalThis !== 'undefined' ? globalThis : this, function (layoutMod) { |
| 21 | + 'use strict'; |
| 22 | + |
| 23 | + /** One character cell, in the layout's pixels. Tall cells are halved so two lines 12px apart stay on different rows. */ |
| 24 | + const CW = 8, CH = 8; |
| 25 | + const chars = (s) => Array.from(String(s)); |
| 26 | + // How wide text is in a monospaced face, in cells. Ranges are [first, last], from Unicode's East Asian |
| 27 | + // Width (Wide and Fullwidth) and its combining blocks — the common cases, not the whole standard. |
| 28 | + // Without this a label in Japanese, or one with an emoji in it, pushes the right-hand side of its own |
| 29 | + // box — and everything after it on the row — out of line. |
| 30 | + const MARKS = [[0x300, 0x36f], [0x1ab0, 0x1aff], [0x1dc0, 0x1dff], [0x200b, 0x200f], [0x20d0, 0x20ff], [0xfe00, 0xfe0f], [0xfe20, 0xfe2f], [0xe0100, 0xe01ef]]; |
| 31 | + const WIDE = [[0x1100, 0x115f], [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], |
| 32 | + [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], |
| 33 | + [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], |
| 34 | + [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f], |
| 35 | + [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a], [0x1f200, 0x1f2ff], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb], |
| 36 | + [0x1f900, 0x1faff], [0x20000, 0x3fffd]]; |
| 37 | + const within = (table, c) => table.some((r) => c >= r[0] && c <= r[1]); |
| 38 | + /** Text as the units a monospaced face sets: a character with any marks that sit on it, and its width in cells. */ |
| 39 | + function units(text) { |
| 40 | + const out = []; |
| 41 | + for (const ch of chars(text)) { |
| 42 | + const c = ch.codePointAt(0); |
| 43 | + if (c >= 0x300 && out.length && within(MARKS, c)) { |
| 44 | + const last = out[out.length - 1]; |
| 45 | + last.text += ch; |
| 46 | + if (c === 0xfe0f) { last.w = 2; } // "show the character before me as an emoji" — which is a wide one |
| 47 | + continue; |
| 48 | + } |
| 49 | + out.push({ text: ch, w: c >= 0x1100 && within(WIDE, c) ? 2 : 1 }); |
| 50 | + } |
| 51 | + return out; |
| 52 | + } |
| 53 | + const width = (text) => units(text).reduce((a, u) => a + u.w, 0); |
| 54 | + /** The layout is told how wide text is in cells, and sizes boxes to match. */ |
| 55 | + const measure = (text) => width(text) * CW; |
| 56 | + /** The mark a shortened label ends with, in characters every terminal has. */ |
| 57 | + const plain = (s) => (typeof s === 'string' ? s.replace(/\u2026/g, '...') : s); |
| 58 | + |
| 59 | + // Box furniture per shape — different corners so "same kind, same shape" survives without curves. |
| 60 | + const FRAME = { |
| 61 | + box: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }, |
| 62 | + decision: { tl: '/', tr: '\\', bl: '\\', br: '/', h: '-', v: '|' }, |
| 63 | + store: { tl: '.', tr: '.', bl: "'", br: "'", h: '=', v: '|' }, |
| 64 | + actor: { tl: '.', tr: '.', bl: "'", br: "'", h: '-', v: '(' } |
| 65 | + }; |
| 66 | + |
| 67 | + /** |
| 68 | + * Render a validated spec as text. |
| 69 | + * @param {any} spec |
| 70 | + * @param {{ maxCols?: number, title?: boolean }} [opts] maxCols: the width to fit (a flow asked for left-to-right |
| 71 | + * turns downward when it will not). title: false leaves the title line out, for a card that already shows it. |
| 72 | + * @returns {string} |
| 73 | + */ |
| 74 | + function render(spec, opts) { |
| 75 | + const o = opts || {}; |
| 76 | + // A character grid has no slanted or curved edges for a connector to stop on, so every shape is |
| 77 | + // laid out as a rectangle here; what kind it is shows in how its frame is drawn. |
| 78 | + const shapeOf = new Map((spec.nodes || []).map((n) => [n.id, n.shape || 'box'])); |
| 79 | + const flat = Object.assign({}, spec, { |
| 80 | + nodes: (spec.nodes || []).map((n) => Object.assign({}, n, { shape: 'box', label: plain(n.label), sub: plain(n.sub) })), |
| 81 | + edges: (spec.edges || []).map((e) => Object.assign({}, e, { label: plain(e.label) })), |
| 82 | + groups: (spec.groups || []).map((g) => Object.assign({}, g, { label: plain(g.label) })) |
| 83 | + }); |
| 84 | + const geo = layoutMod.layout(flat, { |
| 85 | + measure, |
| 86 | + maxWidth: o.maxCols ? o.maxCols * CW : undefined, |
| 87 | + // a little more air than the picture needs: after rounding to cells, neighbours must still be apart |
| 88 | + // Every distance is a whole number of cells: two things a fixed distance apart are then the |
| 89 | + // same number of cells apart after rounding, wherever on the grid they land. |
| 90 | + space: { portGap: 2 * CH, portMin: 2 * CH, labelLane: 2 * CH, track: 2 * CW, labelGap: CH / 2, labelSideGap: CW, labelPad: CW, channelPad: 3 * CW, nodeGap: 3 * CH, rankGap: 6 * CW, lineClear: 2 * CH, groupGap: 2 * CH, groupInset: 2 * CW, groupHeader: 2 * CH, arrow: CW, arrowHalf: 1, margin: CW, selfLoop: 2 * CW }, |
| 91 | + type: { name: { size: 13, weight: 400, line: CH }, sub: { size: 13, weight: 400, line: CH }, edge: { size: 13, weight: 400, line: CH }, group: { size: 13, weight: 400, line: CH }, title: { size: 13, weight: 400, line: CH } }, |
| 92 | + box: { padX: 1.5 * CW, padY: CH / 2, textGap: 0, minWidth: 5 * CW, linkIcon: 0, storeCap: 0 } |
| 93 | + }); |
| 94 | + const col = (x) => Math.round(x / CW), row = (y) => Math.round(y / CH); |
| 95 | + const W = col(geo.width) + 2, H = row(geo.height) + 2; |
| 96 | + const grid = Array.from({ length: H }, () => new Array(W).fill(' ')); |
| 97 | + const put = (r, c, ch) => { if (r >= 0 && r < H && c >= 0 && c < W) { grid[r][c] = ch; } }; |
| 98 | + const get = (r, c) => (r >= 0 && r < H && c >= 0 && c < W ? grid[r][c] : ' '); |
| 99 | + // A wide character fills its own cell and empties the next, so the row still adds up. |
| 100 | + const write = (r, c, text) => { |
| 101 | + let at = c; |
| 102 | + for (const u of units(text)) { put(r, at, u.text); if (u.w === 2) { put(r, at + 1, ''); } at += u.w; } |
| 103 | + }; |
| 104 | + |
| 105 | + // group frames first: everything else is drawn over them |
| 106 | + const byDepth = geo.groups.slice().sort((a, b) => a.depth - b.depth); |
| 107 | + for (const g of byDepth) { |
| 108 | + const c0 = col(g.x), c1 = col(g.x + g.w), r0 = row(g.y), r1 = row(g.y + g.h); |
| 109 | + for (let c = c0; c <= c1; c++) { put(r0, c, (c - c0) % 2 ? ' ' : '.'); put(r1, c, (c - c0) % 2 ? ' ' : '.'); } |
| 110 | + for (let r = r0 + 1; r < r1; r++) { put(r, c0, ':'); put(r, c1, ':'); } |
| 111 | + } |
| 112 | + // A group's name sits on its frame's top row, where the layout put it: slid clear of the lines |
| 113 | + // that come in through that row when there was room. It is written AFTER the connectors, so one |
| 114 | + // that has to share the row with a line is still whole. |
| 115 | + const nameGroups = () => { |
| 116 | + for (const g of byDepth) { |
| 117 | + const c0 = col(g.x), c1 = col(g.x + g.w), r0 = row(g.y); |
| 118 | + const slid = col(g.labelX - (g.x + 2 * CW - 4)); // how far the layout moved it from the corner |
| 119 | + const text = ' ' + g.label + ' '; |
| 120 | + write(r0, Math.max(c0 + 2, Math.min(c0 + 2 + slid, c1 - 1 - width(text))), text); |
| 121 | + } |
| 122 | + }; |
| 123 | + |
| 124 | + // connectors: runs, then corners, then the arrowhead |
| 125 | + const HORIZ = new Set(['-', '.', '=']), VERT = new Set(['|', ':']); |
| 126 | + for (const e of geo.edges) { |
| 127 | + const pts = e.points.map((p) => ({ c: col(p.x), r: row(p.y) })); |
| 128 | + for (let i = 0; i + 1 < pts.length; i++) { |
| 129 | + const a = pts[i], b = pts[i + 1]; |
| 130 | + if (a.r === b.r) { |
| 131 | + for (let c = Math.min(a.c, b.c); c <= Math.max(a.c, b.c); c++) { |
| 132 | + const cur = get(a.r, c); |
| 133 | + put(a.r, c, VERT.has(cur) || cur === '+' ? '+' : (e.dashed && c % 2 ? ' ' : '-')); |
| 134 | + } |
| 135 | + } else { |
| 136 | + for (let r = Math.min(a.r, b.r); r <= Math.max(a.r, b.r); r++) { |
| 137 | + const cur = get(r, a.c); |
| 138 | + put(r, a.c, HORIZ.has(cur) || cur === '+' ? '+' : (e.dashed ? ':' : '|')); |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + for (let i = 1; i + 1 < pts.length; i++) { put(pts[i].r, pts[i].c, '+'); } |
| 143 | + } |
| 144 | + nameGroups(); |
| 145 | + |
| 146 | + // nodes over the connectors that end on them |
| 147 | + for (const n of geo.nodes) { |
| 148 | + const shape = shapeOf.get(n.id) || 'box'; |
| 149 | + const f = FRAME[shape] || FRAME.box; |
| 150 | + const c0 = col(n.x), c1 = col(n.x + n.w), r0 = row(n.y), r1 = row(n.y + n.h); |
| 151 | + for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { put(r, c, ' '); } } |
| 152 | + for (let c = c0 + 1; c < c1; c++) { put(r0, c, n.accent ? '#' : f.h); put(r1, c, n.accent ? '#' : f.h); } |
| 153 | + for (let r = r0 + 1; r < r1; r++) { put(r, c0, f.v); put(r, c1, f.v === '(' ? ')' : f.v); } |
| 154 | + put(r0, c0, f.tl); put(r0, c1, f.tr); put(r1, c0, f.bl); put(r1, c1, f.br); |
| 155 | + if (shape === 'decision') { const mid = Math.round((r0 + r1) / 2); put(mid, c0, '<'); put(mid, c1, '>'); } |
| 156 | + const first = Math.round((r0 + r1) / 2 - (n.lines.length - 1) / 2); |
| 157 | + n.lines.forEach((line, i) => { |
| 158 | + write(first + i, Math.round((c0 + c1) / 2 - (width(line.text) - 1) / 2), line.text); |
| 159 | + }); |
| 160 | + } |
| 161 | + |
| 162 | + // arrowheads sit in the last cell BEFORE the node, so they survive the node being drawn |
| 163 | + for (const e of geo.edges) { |
| 164 | + const a = e.arrow, c = col(a.x) - a.dx, r = row(a.y) - a.dy; |
| 165 | + put(r, c, a.dx > 0 ? '>' : a.dx < 0 ? '<' : a.dy > 0 ? 'v' : '^'); |
| 166 | + } |
| 167 | + |
| 168 | + // labels last, each on the row its line box is centred on |
| 169 | + for (const e of geo.edges) { |
| 170 | + if (!e.label) { continue; } |
| 171 | + write(row(e.label.y + e.label.h / 2), col(e.label.x), e.label.text); |
| 172 | + } |
| 173 | + |
| 174 | + const lines = grid.map((r) => r.join('').replace(/\s+$/, '')); |
| 175 | + while (lines.length && !lines[0]) { lines.shift(); } |
| 176 | + while (lines.length && !lines[lines.length - 1]) { lines.pop(); } |
| 177 | + const indent = Math.min.apply(null, lines.filter(Boolean).map((l) => l.search(/\S/)).concat([Infinity])); |
| 178 | + const body = lines.map((l) => (Number.isFinite(indent) ? l.slice(indent) : l)); |
| 179 | + const title = o.title !== false && spec && spec.title ? [String(spec.tip || spec.title), ''] : []; |
| 180 | + return title.concat(body).join('\n') + '\n'; |
| 181 | + } |
| 182 | + |
| 183 | + return { render, width, CW, CH }; |
| 184 | +})); |
0 commit comments