flowfig 0.1.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/dist/svg.js ADDED
@@ -0,0 +1,563 @@
1
+ // The same figure, as one self-contained animated SVG: no scripts, no fonts to fetch, so it plays
2
+ // inside a markdown image on GitHub, GitLab and Notion, where the React player cannot go. It gives
3
+ // up what needs a reader: no hover, no tabs, no pause — every step runs in one loop.
4
+ //
5
+ // It reads the same spec and the same `route()` as the React renderer, so the two cannot drift.
6
+ //
7
+ // Limit: text is measured by character class (src/text.ts) rather than by a browser, so this runs anywhere with plain node.
8
+ // Wrapping is therefore approximate; `flowfig check` uses the same measure, and the player check measures real text.
9
+ import { route } from './geometry.js';
10
+ import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
11
+ import { textWidth } from './text.js';
12
+ import { checkScene, checkSpec, checkTheme } from './check.js';
13
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, } from './model.js';
14
+ export { LIGHT, DARK } from './model.js';
15
+ const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
16
+ const LABEL_LINE = 18, SUB_LINE = 15, NODE_MIN_W = 100, NODE_MAX_W = 190;
17
+ const FRAME_TOP = 37, FRAME_SIDE = 18, FRAME_BOTTOM = 18;
18
+ // The card-on fills are fixed colors that approximate the player's 8% accent tint, so the SVG needs no color-mix.
19
+ const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn};`;
20
+ /** What a card actually holds: rows, or its text. Only text survives outside React. */
21
+ const content = (c) => (isRows(c) ? c : str(c));
22
+ const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]);
23
+ const n2 = (v) => Math.round(v * 10) / 10;
24
+ const pct = (v) => n2(v * 100) + '%';
25
+ /** Break a string into lines that fit `width`, keeping the newlines it already has. */
26
+ function wrap(s, width, fontSize, mono = false) {
27
+ const out = [];
28
+ for (const para of s.split('\n')) {
29
+ let line = '';
30
+ for (const word of para.split(' ')) {
31
+ if (!line)
32
+ line = word;
33
+ else if (textWidth(line + ' ' + word, fontSize, mono) <= width)
34
+ line += ' ' + word;
35
+ else {
36
+ out.push(line);
37
+ line = word;
38
+ }
39
+ }
40
+ out.push(line);
41
+ }
42
+ return out;
43
+ }
44
+ /** A tag pill. The tag is drawn in bold capitals with letter spacing, so the width counts both. */
45
+ const pillW = (tag) => textWidth(tag.toUpperCase(), 9) + tag.length * 0.27 + 8;
46
+ /** A card's rows, wrapped for `width`, with the height they need. */
47
+ function layoutCard(c, width) {
48
+ const inner = width - CARD_SIDE * 2;
49
+ if (c == null)
50
+ return { rows: [], height: LINE + CARD_PAD * 2 };
51
+ const body = content(c);
52
+ if (!Array.isArray(body)) {
53
+ const lines = wrap(body || ' ', inner, 11);
54
+ return { rows: [{ row: { text: body }, heads: false, lines, room: inner }], height: lines.length * LINE + CARD_PAD * 2 };
55
+ }
56
+ const rows = body.map((row) => {
57
+ // A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
58
+ const heads = (row.tag?.length ?? 0) > 2;
59
+ const tagW = row.tag && !heads ? pillW(row.tag) + 5 : 0;
60
+ const markW = row.mark && !heads ? textWidth(str(row.mark), 11) + 6 : 0;
61
+ const body = str(row.text) + (row.meta != null ? ' · ' + str(row.meta) : '');
62
+ const room = inner - tagW - markW;
63
+ return { row, heads, lines: wrap(body, room, row.mono ? 10.5 : 11, row.mono), room };
64
+ });
65
+ const height = rows.reduce((h, r) => h + (r.heads ? LINE : 0) + r.lines.length * LINE, 0) + ROW_GAP * Math.max(0, rows.length - 1) + CARD_PAD * 2;
66
+ return { rows, height };
67
+ }
68
+ function nodeWidth(item, carded) {
69
+ if (item.width != null)
70
+ return item.width;
71
+ if (carded)
72
+ return CARD_WIDTH;
73
+ const label = textWidth(str(item.label), 14) + 32;
74
+ const sub = textWidth(str(item.sub), 12) + 32;
75
+ return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
76
+ }
77
+ function size(item, s) {
78
+ if (!isGroup(item)) {
79
+ const contents = s.cards.get(item.id);
80
+ const w = nodeWidth(item, contents != null);
81
+ const top = item.shape === 'store' ? 24 : 10;
82
+ const card = contents ? 8 + s.cardH.get(item.id) : 0;
83
+ const h = Math.max(top + LABEL_LINE + (item.sub ? SUB_LINE : 0) + card + 10, s.minH(item.id));
84
+ return item.shape === 'decision' ? { w: w + 70, h: h + 24 } : { w, h };
85
+ }
86
+ const kids = item.children.map((c) => size(c, s));
87
+ const gap = s.gap(item);
88
+ const along = kids.reduce((n, k) => n + (item.direction === 'column' ? k.h : k.w), 0) + gap * (kids.length - 1);
89
+ const across = Math.max(...kids.map((k) => (item.direction === 'column' ? k.w : k.h)));
90
+ const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
91
+ return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
92
+ }
93
+ function place(item, x, y, s, out) {
94
+ const { w, h } = size(item, s);
95
+ out.push({ x, y, w, h, item });
96
+ if (!isGroup(item))
97
+ return;
98
+ const framed = item.label != null;
99
+ let ix = x + (framed ? FRAME_SIDE : 0);
100
+ let iy = y + (framed ? FRAME_TOP : 0);
101
+ const innerW = w - (framed ? FRAME_SIDE * 2 : 0), innerH = h - (framed ? FRAME_TOP + FRAME_BOTTOM : 0);
102
+ const gap = s.gap(item);
103
+ for (const child of item.children) {
104
+ const c = size(child, s);
105
+ const align = item.align ?? 'center';
106
+ const off = (room, used) => (align === 'start' ? 0 : align === 'end' ? room - used : (room - used) / 2);
107
+ if (item.direction === 'column') {
108
+ place(child, ix + off(innerW, c.w), iy, s, out);
109
+ iy += c.h + gap;
110
+ }
111
+ else {
112
+ place(child, ix, iy + off(innerH, c.h), s, out);
113
+ ix += c.w + gap;
114
+ }
115
+ }
116
+ }
117
+ const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
118
+ /**
119
+ * The SVG string, and the scene it drew. The scene lets `flowfig check` test the same layout the SVG uses.
120
+ * The scene is for tools, and its shape may change. Use `toSvg` if you only need the SVG.
121
+ */
122
+ export function render(fig, opts = {}) {
123
+ const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
124
+ const pad = opts.padding ?? 24;
125
+ const steps = fig.steps ?? [];
126
+ const beats = steps.map((s) => s.flow.map(toBeat));
127
+ // Every content a box will ever show, so its card can be sized to the biggest one up front.
128
+ const cards = new Map();
129
+ for (const b of beats.flat())
130
+ for (const [id, c] of Object.entries(b.show ?? {}))
131
+ cards.set(id, [...(cards.get(id) ?? []), c]);
132
+ // Boxes with many edges on one side get taller so the edges and their labels have room.
133
+ const out = {}, inn = {};
134
+ for (const e of fig.edges) {
135
+ out[e.from] = (out[e.from] ?? 0) + 1;
136
+ inn[e.to] = (inn[e.to] ?? 0) + 1;
137
+ }
138
+ const minH = (id) => {
139
+ const n = Math.max(out[id] ?? 0, inn[id] ?? 0);
140
+ return n > 2 ? n * 30 : 0;
141
+ };
142
+ const cardH = new Map();
143
+ const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges) };
144
+ for (const [id, contents] of cards) {
145
+ const width = CARD_WIDTH; // refined below once the node's own width is known
146
+ const widths = [width];
147
+ cardH.set(id, Math.max(LINE + CARD_PAD * 2, ...contents.map((c) => layoutCard(c, Math.min(...widths) - 20).height)));
148
+ }
149
+ const placed = [];
150
+ place(fig.layout, pad, pad, sizes, placed);
151
+ const nodes = placed.filter((p) => !isGroup(p.item));
152
+ // A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
153
+ for (const p of nodes) {
154
+ if (!cards.has(p.item.id))
155
+ continue;
156
+ const inner = p.w - 20;
157
+ cardH.set(p.item.id, Math.max(...cards.get(p.item.id).map((c) => layoutCard(c, inner).height), LINE + CARD_PAD * 2));
158
+ }
159
+ placed.length = 0;
160
+ place(fig.layout, pad, pad, sizes, placed);
161
+ const rects = {};
162
+ for (const p of placed)
163
+ if (p.item.id)
164
+ rects[p.item.id] = p;
165
+ const tips = new Set(placed.filter((p) => !isGroup(p.item) && p.item.shape === 'decision').map((p) => p.item.id));
166
+ const ids = fig.edges.map(edgeId);
167
+ const routed = route(fig.edges.map((e, i) => ({ id: ids[i], from: e.from, to: e.to, around: e.around })), rects, tips);
168
+ const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
169
+ // What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
170
+ const fonts = [14, ...(steps.length ? [13.5] : [])];
171
+ const sceneBoxes = nodes.map((p) => {
172
+ const room = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
173
+ const texts = [{ text: str(p.item.label), fontSize: 14, room }];
174
+ if (p.item.sub) {
175
+ texts.push({ text: str(p.item.sub), fontSize: 12, room });
176
+ fonts.push(12);
177
+ }
178
+ for (const c of cards.get(p.item.id) ?? [])
179
+ for (const r of layoutCard(c, p.w - 20).rows) {
180
+ const fontSize = r.row.mono ? 10.5 : 11;
181
+ fonts.push(fontSize);
182
+ for (const line of r.lines)
183
+ texts.push({ text: line, fontSize, room: r.room, mono: r.row.mono });
184
+ }
185
+ return { id: p.item.id, rect: { x: p.x, y: p.y, w: p.w, h: p.h }, texts };
186
+ });
187
+ if (placed.some((p) => isGroup(p.item) && p.item.label != null))
188
+ fonts.push(11);
189
+ // The timeline: every beat of every step, in order, with the player's hold at the end of each step.
190
+ const segs = [];
191
+ const hops = [];
192
+ let t = 0;
193
+ beats.forEach((stepBeats, si) => {
194
+ stepBeats.forEach((b, bi) => {
195
+ const dur = (b.ms ?? fig.speed ?? 900) / 1000 / BASE_RATE;
196
+ const last = bi === stepBeats.length - 1;
197
+ b.hops.forEach((h, lane) => {
198
+ if (byId[h.edge])
199
+ hops.push({ si, bi, lane, edge: h.edge, back: h.back, data: h.data, t0: t, t1: t + dur });
200
+ });
201
+ segs.push({ t0: t, t1: t + dur + (last ? speed * 1.5 : 0), si, bi });
202
+ t += dur + (last ? speed * 1.5 : 0);
203
+ });
204
+ });
205
+ const total = t || 1;
206
+ // What the figure shows during each segment, matching the React player exactly.
207
+ const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
208
+ const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
209
+ const litNodes = segs.map((seg, i) => {
210
+ const on = new Set([...(steps[seg.si].nodes ?? []), ...Object.keys(shownAt[i]), ...(beats[seg.si][seg.bi].light ?? [])]);
211
+ fig.edges.forEach((e, ei) => {
212
+ if (litEdges[i].has(ids[ei]))
213
+ on.add(e.from).add(e.to);
214
+ });
215
+ return on;
216
+ });
217
+ const captions = segs.map(({ si, bi }) => {
218
+ const said = beats[si].slice(0, bi + 1).findLastIndex((b) => b.say != null);
219
+ return str(said === -1 ? steps[si].caption : beats[si][said].say);
220
+ });
221
+ // One class per on/off pattern over the segments, so 30 boxes share a handful of keyframes.
222
+ const css = [];
223
+ const seen = new Map();
224
+ /** The class that switches this element between `onCss` and `offCss` as the loop plays. */
225
+ const anim = (on, onCss, offCss, prefix) => {
226
+ if (!segs.length || on.every((x) => !x))
227
+ return '';
228
+ const key = prefix + on.map((x) => (x ? 1 : 0)).join('');
229
+ if (!seen.has(key)) {
230
+ const name = `a${seen.size}`;
231
+ seen.set(key, name);
232
+ const frames = segs.map((s, i) => `${pct(s.t0 / total)},${pct(s.t1 / total - 0.0001)} { ${on[i] ? onCss : offCss} }`).join(' ');
233
+ css.push(`@keyframes ${name} { ${frames} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
234
+ }
235
+ return seen.get(key);
236
+ };
237
+ /** One `class` attribute from the static classes and the animated one — two would be invalid XML. */
238
+ const cls = (...names) => {
239
+ const list = names.filter(Boolean).join(' ');
240
+ return list ? ` class="${list}"` : '';
241
+ };
242
+ /** Like `anim`, for a property that takes a different value in each segment (a row that moves as phases fold). */
243
+ const frames = (values, prefix) => {
244
+ if (!segs.length || values.every((v) => v === values[0]))
245
+ return '';
246
+ const key = prefix + values.join('|');
247
+ if (!seen.has(key)) {
248
+ const name = `a${seen.size}`;
249
+ seen.set(key, name);
250
+ const kf = segs.map((s, i) => `${pct(s.t0 / total)},${pct(s.t1 / total - 0.0001)} { ${values[i]} }`).join(' ');
251
+ css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
252
+ }
253
+ return seen.get(key);
254
+ };
255
+ const boxes = placed.map((p) => {
256
+ const { item } = p;
257
+ if (isGroup(item)) {
258
+ if (item.label == null)
259
+ return '';
260
+ const on = item.id ? segs.map((_, i) => litNodes[i].has(item.id)) : [];
261
+ return (`<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="14" fill="var(--surface)" stroke="var(--border)"` +
262
+ cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
263
+ `/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
264
+ }
265
+ const on = segs.map((_, i) => litNodes[i].has(item.id));
266
+ const stroke = cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n'));
267
+ const cx = p.x + p.w / 2;
268
+ const contents = cards.get(item.id);
269
+ const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
270
+ const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (item.sub ? -2 : 5);
271
+ const shape = item.shape === 'decision'
272
+ ? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="var(--bg)" stroke="var(--border)"${stroke}/>`
273
+ : item.shape === 'store'
274
+ ? `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(p.w)} 0 v ${n2(p.h - 24)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(-p.w)} 0 z" fill="var(--bg)" stroke="var(--border)"${stroke}/>` +
275
+ `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 0 ${n2(p.w)} 0" fill="none" stroke="var(--border)"${stroke}/>`
276
+ : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="var(--bg)" stroke="var(--border)"${stroke}/>`;
277
+ const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
278
+ const sub = item.sub ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>` : '';
279
+ return shape + label + sub + (contents ? card(p, cardTop, contents) : '');
280
+ });
281
+ /** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
282
+ function card(p, top, contents) {
283
+ const id = p.item.id;
284
+ const x = p.x + 10, w = p.w - 20, h = cardH.get(id);
285
+ const filled = segs.map((_, i) => shownAt[i][id] != null);
286
+ const box = `<rect x="${n2(x)}" y="${n2(top)}" width="${n2(w)}" height="${n2(h)}" rx="6" fill="var(--surface)" stroke="var(--border)" stroke-dasharray="3 3"` +
287
+ cls(anim(filled, 'stroke: var(--accent); fill: var(--card-on)', 'stroke: var(--border); fill: var(--surface)', 'c')) +
288
+ `/>`;
289
+ // Every distinct content gets a layer; the segments decide which one is showing.
290
+ const layers = contents
291
+ .map((c, ci) => {
292
+ const on = segs.map((_, i) => contents.indexOf(shownAt[i][id]) === ci);
293
+ if (on.every((x) => !x))
294
+ return '';
295
+ return `<g opacity="0"${cls(anim(on, 'opacity: 1', 'opacity: 0', 'v'))}>${rows(c, x, top, w)}</g>`;
296
+ })
297
+ .join('');
298
+ const empty = segs.map((_, i) => shownAt[i][id] == null);
299
+ const dash = empty.some(Boolean)
300
+ ? `<text x="${n2(x + CARD_SIDE)}" y="${n2(top + CARD_PAD + 11)}" opacity="0"${cls('row', 'muted', anim(empty, 'opacity: 1', 'opacity: 0', 'v'))}>—</text>`
301
+ : '';
302
+ return box + layers + dash;
303
+ }
304
+ /** One card's rows: a colored tag pill, the text, its muted meta, and the mark on the right. */
305
+ function rows(c, x, top, w) {
306
+ const { rows } = layoutCard(c, w);
307
+ let y = top + CARD_PAD;
308
+ return rows
309
+ .map(({ row, heads, lines }) => {
310
+ const tone = TONES[row.tone ?? 'blue'];
311
+ const left = x + CARD_SIDE;
312
+ const parts = [];
313
+ const tagW = row.tag ? pillW(row.tag) : 0;
314
+ const pill = (px, py) => row.tag
315
+ ? `<rect x="${n2(px)}" y="${n2(py)}" width="${n2(tagW)}" height="14" rx="4" fill="${tone}" fill-opacity="0.15"/>` +
316
+ `<text x="${n2(px + tagW / 2)}" y="${n2(py + 10.5)}" class="tag" fill="${tone}">${esc(row.tag.toUpperCase())}</text>`
317
+ : '';
318
+ const mark = row.mark ? `<text x="${n2(x + w - CARD_SIDE)}" y="${n2(y + 11)}" class="mark">${esc(str(row.mark))}</text>` : '';
319
+ if (heads) {
320
+ parts.push(pill(left, y + 1), mark);
321
+ y += LINE;
322
+ }
323
+ const indent = heads ? 0 : tagW ? tagW + 5 : 0;
324
+ if (!heads)
325
+ parts.push(pill(left, y + 1), mark);
326
+ lines.forEach((line, li) => {
327
+ parts.push(`<text x="${n2(left + (li === 0 ? indent : 0))}" y="${n2(y + 11)}"${cls('row', row.mono && 'mono')}>${esc(line)}</text>`);
328
+ y += LINE;
329
+ });
330
+ y += ROW_GAP;
331
+ return parts.join('');
332
+ })
333
+ .join('');
334
+ }
335
+ const labelRects = {};
336
+ const edgeSvg = routed.map((r) => {
337
+ const e = fig.edges[ids.indexOf(r.id)];
338
+ const on = segs.map((_, i) => litEdges[i].has(r.id));
339
+ const hidden = e.quiet && !on.every(Boolean);
340
+ const shown = hidden ? cls(anim(on, 'opacity: 1', 'opacity: 0', 'q')) : '';
341
+ const lit = cls(anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, 'e'));
342
+ const path = `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
343
+ // A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
344
+ const label = e.label == null
345
+ ? ''
346
+ : (() => {
347
+ const lw = labelPillW(str(e.label));
348
+ labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
349
+ fonts.push(11);
350
+ return (`<rect x="${n2(r.mid.x - lw / 2)}" y="${n2(r.mid.y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
351
+ cls(anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
352
+ `/><text x="${n2(r.mid.x)}" y="${n2(r.mid.y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(str(e.label))}</text>`);
353
+ })();
354
+ return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
355
+ });
356
+ // A packet per hop: it waits offstage, crosses its edge during its beat, then leaves. Any `data`
357
+ // rides above it in a chip, which is how the figure says what is moving.
358
+ const packets = hops.map((h, i) => {
359
+ const t0 = h.t0 / total, t1 = h.t1 / total;
360
+ const name = `p${i}`;
361
+ css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(t1 - 0.0001)} { opacity: 1 } ${pct(t1)},100% { opacity: 0 } }\n` +
362
+ `.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
363
+ const chip = (() => {
364
+ const label = str(h.data);
365
+ if (!label)
366
+ return '';
367
+ fonts.push(11.5);
368
+ const lines = wrap(label, 210, 11.5);
369
+ const w = Math.max(...lines.map((l) => textWidth(l, 11.5))) + 18;
370
+ const boxH = lines.length * 15 + 8;
371
+ return (`<rect x="${n2(-w / 2)}" y="${n2(-boxH - 12)}" width="${n2(w)}" height="${n2(boxH)}" rx="8" fill="var(--accent)"/>` +
372
+ lines.map((l, li) => `<text x="0" y="${n2(-boxH - 12 + 15 * li + 15)}" class="chip">${esc(l)}</text>`).join(''));
373
+ })();
374
+ return (`<g${cls(name)} opacity="0"><circle r="10" fill="var(--accent)" opacity="0.2"/><circle r="4.5" fill="var(--accent)"/>${chip}` +
375
+ `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n2(t0)};${n2(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
376
+ `<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
377
+ });
378
+ const bounds = placed[0];
379
+ const arcs = fig.edges.some((e) => e.around);
380
+ const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
381
+ const mapW = Math.max(bounds.w + pad * 2, 560);
382
+ // `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
383
+ const rail = fig.rail ? layoutRail(fig, fig.rail === 'only' ? 560 : mapW) : null;
384
+ const only = fig.rail === 'only' && rail != null;
385
+ // With the rail, the rail shows the step label, so the caption holds the narration only.
386
+ const capTop = rail ? 20 : 26;
387
+ const capH = steps.length ? capTop + 4 + Math.max(0, ...capLines) * 20 : 0;
388
+ const W = only ? Math.max(560, rail.width) : Math.max(mapW, rail?.width ?? 0);
389
+ const mapH = only ? 0 : bounds.h + pad * 2 + (arcs ? 44 : 0);
390
+ const top = only ? pad : mapH + RAIL.gap; // the rail's top
391
+ const H = only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH;
392
+ const shift = (W - (bounds.w + pad * 2)) / 2;
393
+ const railX = rail ? (W - rail.width) / 2 : 0;
394
+ // The step label and its narration, both switching with the beats.
395
+ const labels = rail
396
+ ? []
397
+ : steps.map((_, si) => {
398
+ const on = segs.map((s) => s.si === si);
399
+ return `<text x="${n2(W / 2)}" y="${n2(H - capH + 16)}" opacity="0"${cls('steplabel', anim(on, 'opacity: 1', 'opacity: 0', 's'))}>${esc(str(steps[si].label))}</text>`;
400
+ });
401
+ const said = [...new Set(captions)].map((text) => {
402
+ const on = captions.map((c) => c === text);
403
+ const lines = wrap(text, Math.max(560, bounds.w), 13.5);
404
+ return (`<g opacity="0"${cls(anim(on, 'opacity: 1', 'opacity: 0', 'y'))}>` +
405
+ lines.map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`).join('') +
406
+ `</g>`);
407
+ });
408
+ // The rail: lifelines under the map. Every part reuses a map part: the frame for a band, the edge-label pill for a payload, the card
409
+ // tag for `async`, the filled card for a parallel group. So the rail adds no color and no font size.
410
+ const railSvg = !rail
411
+ ? ''
412
+ : (() => {
413
+ const parts = [];
414
+ const cols = rail.head - RAIL.cols / 2 + 4; // the column-label baseline
415
+ for (const b of rail.bands)
416
+ parts.push(`<rect x="${n2(b.rect.x)}" y="${n2(top + b.rect.y)}" width="${n2(b.rect.w)}" height="${n2(b.rect.h)}" rx="10" fill="var(--surface)" stroke="var(--border)"/>` +
417
+ `<text x="${n2(b.rect.x + 10)}" y="${n2(top + b.rect.y + 14)}" class="frame">${esc(b.label.toUpperCase())}</text>`);
418
+ for (const c of rail.columns)
419
+ parts.push(`<text x="${n2(c.x)}" y="${n2(top + cols)}" class="railcol">${esc(c.label)}</text>`);
420
+ // Where each row sits in each segment: it moves when another phase opens, and hides while its phase is folded.
421
+ const at = (i) => segs.map((s) => railState(rail, s.si)[i]);
422
+ const base = (i) => railState(rail, 0)[i].y;
423
+ const moveRow = (i) => frames(at(i).map((st) => `transform: translateY(${n2(st.y - base(i))}px); opacity: ${st.shown ? 1 : 0}`), 'm');
424
+ // The lifelines run through the message rows only, so a phase header reads as one clear divider.
425
+ rail.rows.forEach((row, i) => {
426
+ if (row.kind !== 'message')
427
+ return;
428
+ const y = top + base(i);
429
+ const d = rail.columns.map((c) => `M ${n2(c.x)} ${n2(y)} V ${n2(y + RAIL.row)}`).join(' ');
430
+ parts.push(`<g${cls(moveRow(i))}><path d="${d}" stroke="var(--border)" stroke-dasharray="2 3"/></g>`);
431
+ });
432
+ rail.groups.forEach((g) => {
433
+ const box = groupBox(rail, g);
434
+ const y = top + base(g.rows[0]);
435
+ parts.push(`<g${cls(moveRow(g.rows[0]))}><rect x="${n2(box.x)}" y="${n2(y + box.y)}" width="${n2(box.w)}" height="${n2(box.h)}" rx="6" fill="var(--card-on)" stroke="var(--accent)" stroke-dasharray="3 3"/></g>`);
436
+ });
437
+ rail.rows.forEach((row, i) => {
438
+ const y = top + base(i);
439
+ if (row.kind === 'phase') {
440
+ const folded = segs.map((s) => rail.folds && s.si !== row.step);
441
+ const rule = (x1) => `<path d="M ${n2(x1)} ${n2(y + 15)} H ${n2(row.line.end)}" stroke="var(--border)"/>`;
442
+ // One counter per beat of this phase, on this row, so it moves with the row. A parallel beat counts its first message.
443
+ const first = new Map(); // beat -> the n of its first message
444
+ for (const r of rail.rows)
445
+ if (r.kind === 'message' && r.step === row.step && !first.has(r.beat))
446
+ first.set(r.beat, r.n);
447
+ const counters = [...first].map(([beat, n]) => {
448
+ const now = segs.map((s) => s.si === row.step && s.bi === beat);
449
+ return `<text x="${n2(rail.width - RAIL.pad)}" y="${n2(y + 19)}" opacity="0"${cls('frame', 'end', anim(now, 'opacity: 1', 'opacity: 0', 'c'))}>${n} of ${rail.total}</text>`;
450
+ });
451
+ parts.push(`<g${cls(moveRow(i))}>` +
452
+ `<g${cls(anim(folded, 'opacity: 0', 'opacity: 1', 'o'))}><text x="${n2(RAIL.pad)}" y="${n2(y + 19)}" class="railphase">${esc(row.label)}</text>${rule(row.line.open)}</g>` +
453
+ (rail.folds
454
+ ? `<g opacity="0"${cls(anim(folded, 'opacity: 1', 'opacity: 0', 'f'))}><text x="${n2(RAIL.pad)}" y="${n2(y + 19)}" class="railphase muted">${esc(foldedLabel(row))}</text>${rule(row.line.folded)}</g>`
455
+ : '') +
456
+ counters.join('') +
457
+ `</g>`);
458
+ return;
459
+ }
460
+ const now = segs.map((s) => s.si === row.step && s.bi === row.beat);
461
+ const next = segs.map((s) => s.si < row.step || (s.si === row.step && s.bi < row.beat));
462
+ const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
463
+ const ly = y + RAIL.row / 2;
464
+ const lit = anim(now, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, 'e');
465
+ const tagW = ASYNC_TAG_W;
466
+ // The tag sits on the tail side of the pill, so it never hides the arrowhead.
467
+ const tagX = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
468
+ const tag = row.async
469
+ ? `<rect x="${n2(tagX)}" y="${n2(ly - 6)}" width="${n2(tagW)}" height="12" rx="4" fill="var(--bg)"/>` +
470
+ `<rect x="${n2(tagX)}" y="${n2(ly - 6)}" width="${n2(tagW)}" height="12" rx="4" fill="${TONES.gray}" fill-opacity="0.15"/>` +
471
+ `<text x="${n2(tagX + tagW / 2)}" y="${n2(ly + 3)}" class="tag" fill="${TONES.gray}">ASYNC</text>`
472
+ : '';
473
+ const pill = row.pill
474
+ ? `<rect x="${n2(row.pill.x)}" y="${n2(ly - 9)}" width="${n2(row.pill.w)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"${cls(anim(now, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l'))}/>` +
475
+ `<text x="${n2(row.pill.x + row.pill.w / 2)}" y="${n2(ly + 4)}"${cls('edgelabel', anim(now, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(row.text)}</text>`
476
+ : '';
477
+ parts.push(`<g${cls('railrow', moveRow(i))}><g${cls(anim(next, 'opacity: .45', 'opacity: 1', 'r'))}>` +
478
+ `<path d="M ${n2(x1)} ${n2(ly)} H ${n2(x2)}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}"${row.async ? ' stroke-dasharray="4 3"' : ''} marker-end="url(#arrow)"${cls(lit)}/>` +
479
+ tag +
480
+ pill +
481
+ `</g></g>`);
482
+ });
483
+ return `<g transform="translate(${n2(railX)} 0)">${parts.join('')}</g>`;
484
+ })();
485
+ const { font, ...t0 } = { ...LIGHT, ...fig.theme, ...opts.theme };
486
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${n2(W)}" height="${n2(H)}" viewBox="0 0 ${n2(W)} ${n2(H)}" font-family="${esc(font ?? SYSTEM_FONT)}">
487
+ <style>
488
+ svg { ${vars(t0, '#eef5fd')} }
489
+ @media (prefers-color-scheme: dark) { svg { ${vars(DARK, '#1d2733')} } }
490
+ .label { fill: var(--fg); font-size: 14px; font-weight: 500; text-anchor: middle; }
491
+ .sub { fill: var(--muted); font-size: 12px; text-anchor: middle; }
492
+ .frame { fill: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; }
493
+ .edgelabel { fill: var(--muted); font-size: 11px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
494
+ .row { fill: var(--fg); font-size: 11px; }
495
+ .row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
496
+ .muted { fill: var(--muted); }
497
+ .tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
498
+ .mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
499
+ .chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
500
+ .steplabel { fill: var(--fg); font-size: 13px; font-weight: 600; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
501
+ .caption { fill: var(--muted); font-size: 13.5px; text-anchor: middle; }
502
+ .railcol { fill: var(--fg); font-size: 12px; font-weight: 500; text-anchor: middle; }
503
+ .railphase { fill: var(--fg); font-size: 13px; font-weight: 600; font-family: ui-monospace, Menlo, monospace; }
504
+ .railphase.muted { fill: var(--muted); }
505
+ .end { text-anchor: end; }
506
+ ${css.join('\n')}
507
+ </style>
508
+ <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="var(--muted)"/></marker></defs>
509
+ <rect width="100%" height="100%" fill="var(--bg)"/>
510
+ ${only
511
+ ? ''
512
+ : `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
513
+ ${boxes.filter(Boolean).join('\n')}
514
+ ${edgeSvg.join('\n')}
515
+ ${packets.join('\n')}
516
+ </g>
517
+ `}${railSvg}
518
+ ${labels.join('\n')}
519
+ ${said.join('\n')}
520
+ </svg>
521
+ `;
522
+ if (rail)
523
+ fonts.push(12, 13);
524
+ const scene = {
525
+ width: n2(W),
526
+ boxes: only ? [] : sceneBoxes,
527
+ edges: [
528
+ ...(only ? [] : routed).map((r) => {
529
+ const e = fig.edges[ids.indexOf(r.id)];
530
+ return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id] };
531
+ }),
532
+ // Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
533
+ ...(rail?.rows ?? []).flatMap((row, i) => {
534
+ if (row.kind !== 'message')
535
+ return [];
536
+ // Scene coordinates are the map's layout coordinates: the page minus the map's translate(shift, arcs).
537
+ const dx = only ? railX : railX - shift, dy = only ? top : top - (arcs ? 44 : 0);
538
+ const y = dy + railState(rail, row.step)[i].y + RAIL.row / 2;
539
+ const [x1, x2] = [dx + rail.columns[row.from].x, dx + rail.columns[row.to].x];
540
+ const p = { x: x1, y }, q = { x: x2, y };
541
+ return [
542
+ {
543
+ id: `rail:${row.n}`,
544
+ from: rail.columns[row.from].id,
545
+ to: rail.columns[row.to].id,
546
+ curve: [p, p, q, q],
547
+ label: row.pill && { x: dx + row.pill.x, y: y - 9, w: row.pill.w, h: 18 },
548
+ },
549
+ ];
550
+ }),
551
+ ],
552
+ minFont: Math.min(...fonts),
553
+ };
554
+ return { svg, scene };
555
+ }
556
+ /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
557
+ export function toSvg(fig, opts = {}) {
558
+ return render(fig, opts).svg;
559
+ }
560
+ /** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. A `Finding` has a `rule` name, a `severity`, the ids it names and a message. */
561
+ export function check(fig, opts = {}) {
562
+ return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...checkTheme({ ...fig.theme, ...opts.theme })];
563
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** The width of `s` in px at `fontSize`. */
2
+ export declare function textWidth(s: string, fontSize: number, mono?: boolean): number;
package/dist/text.js ADDED
@@ -0,0 +1,13 @@
1
+ // Text width without a browser: the SVG layout and `flowfig check` both run in plain node. Each character counts by its class in
2
+ // the system sans-serif stack. Monospace is exact at 0.6em. The React player measures real text instead.
3
+ const FULL = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|\p{Extended_Pictographic}/u;
4
+ const NARROW = /[ilj.,:;'|!()[\]ftrI ]/;
5
+ const WIDE = /[WMmw@%]/;
6
+ const TALL = /[A-Z0-9#&?]/;
7
+ /** The width of `s` in px at `fontSize`. */
8
+ export function textWidth(s, fontSize, mono = false) {
9
+ let em = 0;
10
+ for (const ch of s)
11
+ em += FULL.test(ch) ? 1 : mono ? 0.6 : WIDE.test(ch) ? 0.85 : NARROW.test(ch) ? 0.3 : TALL.test(ch) ? 0.64 : 0.53;
12
+ return em * fontSize;
13
+ }