Skip to content

Commit f35fbc1

Browse files
committed
feat(diagrams): the painter, and the same diagram as text
diagram/scene.js geometry to a tree of elements, built from two allow-lists: seven tags (svg g rect path text title style) and a set of attributes with no href, no style, no id and no on*. mount() turns it into DOM with createElementNS and textContent; toSvg() into an escaped string for a file. A label is never parsed as markup by either, and both refuse anything off the lists rather than trust whoever built the tree. diagram/text.js the outline a screen reader is given (nodes, then edges, in reading order); the one-line stub that stands in for a spec once a conversation is compacted; Mermaid and source export. diagram/ascii.js for when a picture cannot be shown: the SAME layout on a character grid, so it puts things where the picture would. Plain ASCII; East Asian wide characters and emoji count as two cells, combining marks as none. A linked node carries data-lc-link = its NODE ID, never the path: whoever handles the click looks the link up in its own copy of the spec. A group's name that the layout could not keep clear of a connector is drawn after the connectors, with the halo an edge label gets, so the line reads as passing behind the word. Snapshots of three gallery diagrams in both palettes (fixtures/diagrams/ snapshots): a change to the style, the layout or the painter shows up there as a diff. diagramScene (23 tests) and diagramText (17).
1 parent fb2e67a commit f35fbc1

11 files changed

Lines changed: 1222 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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

Comments
 (0)