flowfig 0.5.0 → 0.7.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 CHANGED
@@ -1,34 +1,24 @@
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.
1
+ // Wrapping is approximate: text is measured by character class, not by a browser.
9
2
  import { route } from './geometry.js';
10
3
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
11
4
  import { textWidth, wrap } from './text.js';
12
- import { checkScene, checkSpec, checkTheme } from './check.js';
5
+ import { checkRendered, planFor } from './check.js';
13
6
  export { parseSource, links } from './source.js';
14
7
  export { diff, formatDiff } from './diff.js';
15
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, lanePlan, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
8
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
16
9
  export { LIGHT, DARK } from './model.js';
17
10
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
18
11
  const LABEL_LINE = 18, SUB_LINE = 15;
19
12
  const FRAME_TOP = 37, FRAME_BOTTOM = 18;
20
- // The card-on fills are fixed colors that approximate the player's 8% accent tint. The active tint is the player's color-mix.
13
+ // The card-on fills approximate the player's 8 % accent tint.
21
14
  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}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
22
- /** What a card actually holds: rows, or its text. Only text survives outside React. */
23
15
  const content = (c) => (isRows(c) ? c : str(c));
24
16
  const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]);
25
17
  const n2 = (v) => Math.round(v * 10) / 10;
26
- // Keyframe times are fractions of a loop that can last a minute: one decimal would round a 0.7 s hop away, and the packet would jump.
18
+ // Four decimals: one decimal rounds a 0.7 s hop away and the packet jumps.
27
19
  const n4 = (v) => Math.round(v * 10000) / 10000;
28
20
  const pct = (v) => Math.round(v * 10000) / 100 + '%';
29
- /** A tag pill. The tag is drawn in bold capitals with letter spacing, so the width counts both. */
30
21
  const pillW = (tag) => textWidth(tag.toUpperCase(), 9) + tag.length * 0.27 + 8;
31
- /** A card's rows, wrapped for `width`, with the height they need. */
32
22
  function layoutCard(c, width) {
33
23
  const inner = width - CARD_SIDE * 2;
34
24
  if (c == null)
@@ -39,7 +29,6 @@ function layoutCard(c, width) {
39
29
  return { rows: [{ row: { text: body }, heads: false, lines, room: inner }], height: lines.length * LINE + CARD_PAD * 2 };
40
30
  }
41
31
  const rows = body.map((row) => {
42
- // A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
43
32
  const heads = (row.tag?.length ?? 0) > 2;
44
33
  const tagW = row.tag && !heads ? pillW(row.tag) + 5 : 0;
45
34
  const markW = row.mark && !heads ? textWidth(str(row.mark), 11) + 6 : 0;
@@ -68,8 +57,6 @@ function size(item, s) {
68
57
  const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
69
58
  return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
70
59
  }
71
- /** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column. Columns past
72
- * the plan wrap into blocks under the first. A block holds only the lanes with a box in it; all bands share one width. */
73
60
  function placeLanes(fig, x, y, s, out) {
74
61
  const lanes = fig.layout.children;
75
62
  const { cols, starts, gaps, lead, blocks } = s.plan;
@@ -87,7 +74,6 @@ function placeLanes(fig, x, y, s, out) {
87
74
  .slice(0, c - starts[k])
88
75
  .reduce((a, w, i) => a + w + gaps[starts[k] + i], 0));
89
76
  });
90
- // A figure with no box has no column: its band keeps the frame side on the right.
91
77
  const width = Math.max(...blocks.map((k) => {
92
78
  const ws = colsOf(k);
93
79
  return (gutter +
@@ -104,7 +90,6 @@ function placeLanes(fig, x, y, s, out) {
104
90
  ly += LANE_BLOCK_GAP - LANE_ROW_GAP;
105
91
  for (const lane of lanes) {
106
92
  const kids = lane.children.filter((b) => laneBlock(starts, cols.get(b.id)) === bk).map((b) => ({ b, ...size(b, s) }));
107
- // A wrapped block draws only its own lanes; one block draws every lane, an empty one too.
108
93
  if (blocks.length > 1 && !kids.length)
109
94
  continue;
110
95
  const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
@@ -117,7 +102,6 @@ function placeLanes(fig, x, y, s, out) {
117
102
  });
118
103
  out[topAt].h = ly - LANE_ROW_GAP - y;
119
104
  }
120
- /** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
121
105
  function placeTimeline(fig, x, y, out) {
122
106
  const lanes = fig.layout.children;
123
107
  const gutter = gutterOf(lanes);
@@ -178,24 +162,18 @@ function place(item, x, y, s, out) {
178
162
  }
179
163
  }
180
164
  const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
181
- /**
182
- * The SVG string, and the scene it drew. The scene lets `flowfig check` test the same layout the SVG uses.
183
- * The scene is for tools, and its shape may change. Use `toSvg` if you only need the SVG.
184
- */
165
+ /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
185
166
  export function render(fig, opts = {}) {
186
167
  const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
187
168
  const pad = opts.padding ?? 24;
188
169
  const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
189
- // A timeline with no steps of its own walks its dated items in date order.
190
170
  const synthetic = tl != null && !fig.steps?.length;
191
171
  const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
192
172
  const beats = steps.map((s) => s.flow.map(toBeat));
193
- // Every content a box will ever show, so its card can be sized to the biggest one up front.
194
173
  const cards = new Map();
195
174
  for (const b of beats.flat())
196
175
  for (const [id, c] of Object.entries(b.show ?? {}))
197
176
  cards.set(id, [...(cards.get(id) ?? []), c]);
198
- // Boxes with many edges on one side get taller so the edges and their labels have room.
199
177
  const out = {}, inn = {};
200
178
  for (const e of fig.edges) {
201
179
  out[e.from] = (out[e.from] ?? 0) + 1;
@@ -210,14 +188,13 @@ export function render(fig, opts = {}) {
210
188
  const plan = lanes ? planFor(fig, opts) : null;
211
189
  const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig, plan, tall: new Set(plan?.tall) };
212
190
  for (const [id, contents] of cards) {
213
- const width = CARD_WIDTH; // refined below once the node's own width is known
191
+ const width = CARD_WIDTH;
214
192
  const widths = [width];
215
193
  cardH.set(id, Math.max(LINE + CARD_PAD * 2, ...contents.map((c) => layoutCard(c, Math.min(...widths) - 20).height)));
216
194
  }
217
195
  const placed = [];
218
196
  place(fig.layout, pad, pad, sizes, placed);
219
197
  let nodes = placed.filter((p) => !isGroup(p.item));
220
- // A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
221
198
  for (const p of nodes) {
222
199
  if (!cards.has(p.item.id))
223
200
  continue;
@@ -227,11 +204,9 @@ export function render(fig, opts = {}) {
227
204
  const rects = {};
228
205
  const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
229
206
  const ids = fig.edges.map(edgeId);
230
- // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane gutters.
231
207
  const stubs = plan?.stubs ?? new Map();
232
208
  const gutter = plan ? gutterOf(fig.layout.children) : 0;
233
209
  const band = (id, k) => placed.find((p) => p.lane && p.item.id === id && p.block === k);
234
- // The band of an edge end, in the block of that end: the band of the box's lane, or of the lane itself.
235
210
  const laneGroups = fig.layout.children;
236
211
  const bandOf = (eid, id, k) => {
237
212
  const block = plan?.ends.get(eid)?.[k];
@@ -239,7 +214,6 @@ export function render(fig, opts = {}) {
239
214
  return placed.find((p) => p.lane && p.item === lane && p.block === block);
240
215
  };
241
216
  const end = (eid, id, start) => (plan ? laneEnd(plan, eid, id, start, band, rects) : id);
242
- // Place and route. A stub end with no place clear of every edge path makes its lane copy taller, then both run again.
243
217
  const go = () => {
244
218
  placed.length = 0;
245
219
  place(fig.layout, pad, pad, sizes, placed);
@@ -253,12 +227,13 @@ export function render(fig, opts = {}) {
253
227
  around: e.around ?? (plan?.around.has(ids[i]) ? 'below' : undefined),
254
228
  ...(tl && { sides: ['r', 'l'], elbow: true }),
255
229
  ...(stubs.has(ids[i]) && { stub: stubs.get(ids[i]).map(labelPillW), bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)] }),
256
- ...(stubs.size && e.label != null && { labelW: labelPillW(str(e.label)) }),
230
+ ...((stubs.size || lanes) && e.label != null && { labelW: labelPillW(str(e.label)) }),
257
231
  })), rects, tips, stubs.size
258
232
  ? placed.flatMap((p) => (!isGroup(p.item) ? [p] : p.lane ? [{ x: p.x, y: p.y, w: gutter, h: p.h }] : []))
259
- : placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
260
- // A stub pill stays in the lanes, right of the gutter.
261
- stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined);
233
+ : [
234
+ ...placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
235
+ ...placed.filter((p) => p.tl).map((p) => ({ x: p.x, y: p.y, w: p.w, h: p.h, box: true })),
236
+ ], stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined, lanes ? { bands: placed.filter((p) => p.lane), boxes: placed.filter((p) => !isGroup(p.item)) } : undefined);
262
237
  };
263
238
  let routed = go();
264
239
  for (let pass = 0; plan && pass < 3; pass++) {
@@ -270,11 +245,9 @@ export function render(fig, opts = {}) {
270
245
  }
271
246
  nodes = placed.filter((p) => !isGroup(p.item));
272
247
  const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
273
- // What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
274
248
  const fonts = [14, ...(steps.length ? [13.5] : [])];
275
249
  const sceneBoxes = nodes.map((p) => {
276
250
  if (p.tl) {
277
- // A label inside the bar has the bar as room; a label beside the bar has the space to the next item of its row, or to the band edge.
278
251
  const label = str(p.item.label);
279
252
  const mine = tl.items.find((i) => i.id === p.item.id);
280
253
  const next = tl.items.filter((i) => i.track === mine.track && i.row === mine.row && i.x > mine.x).map((i) => p.x - mine.x + i.x - 8);
@@ -306,7 +279,6 @@ export function render(fig, opts = {}) {
306
279
  });
307
280
  if (placed.some((p) => isGroup(p.item) && p.item.label != null))
308
281
  fonts.push(11);
309
- // The timeline: every beat of every step, in order, with the player's hold at the end of each step.
310
282
  const segs = [];
311
283
  const hops = [];
312
284
  const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
@@ -337,7 +309,6 @@ export function render(fig, opts = {}) {
337
309
  });
338
310
  });
339
311
  const total = t || 1;
340
- // What the figure shows during each segment, matching the React player exactly.
341
312
  const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
342
313
  const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
343
314
  const litNodes = segs.map((seg, i) => {
@@ -357,10 +328,19 @@ export function render(fig, opts = {}) {
357
328
  const said = beats[si].slice(0, bi + 1).findLastIndex((b) => b.say != null);
358
329
  return str(said === -1 ? steps[si].caption : beats[si][said].say);
359
330
  });
360
- // One class per on/off pattern over the segments, so 30 boxes share a handful of keyframes.
361
331
  const css = [];
362
332
  const seen = new Map();
363
- /** The class that switches this element between `onCss` and `offCss` as the loop plays. */
333
+ const runs = (values) => {
334
+ const out = [];
335
+ for (let i = 0; i < segs.length;) {
336
+ let j = i;
337
+ while (j + 1 < segs.length && values[j + 1] === values[i])
338
+ j++;
339
+ out.push(`${pct(segs[i].t0 / total)},${pct(segs[j].t1 / total - 0.0001)} { ${values[i]} }`);
340
+ i = j + 1;
341
+ }
342
+ return out.join(' ');
343
+ };
364
344
  const anim = (on, onCss, offCss, prefix) => {
365
345
  if (!segs.length || on.every((x) => !x))
366
346
  return '';
@@ -368,17 +348,16 @@ export function render(fig, opts = {}) {
368
348
  if (!seen.has(key)) {
369
349
  const name = `a${seen.size}`;
370
350
  seen.set(key, name);
371
- const frames = segs.map((s, i) => `${pct(s.t0 / total)},${pct(s.t1 / total - 0.0001)} { ${on[i] ? onCss : offCss} }`).join(' ');
351
+ const frames = runs(on.map((x) => (x ? onCss : offCss)));
372
352
  css.push(`@keyframes ${name} { ${frames} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
373
353
  }
374
354
  return seen.get(key);
375
355
  };
376
- /** One `class` attribute from the static classes and the animated one — two would be invalid XML. */
356
+ // One class attribute only: two would be invalid XML.
377
357
  const cls = (...names) => {
378
358
  const list = names.filter(Boolean).join(' ');
379
359
  return list ? ` class="${list}"` : '';
380
360
  };
381
- /** Like `anim`, for a property that takes a different value in each segment (a row that moves as phases fold). */
382
361
  const frames = (values, prefix, base = values[0]) => {
383
362
  if (!segs.length || values.every((v) => v === base))
384
363
  return '';
@@ -386,20 +365,14 @@ export function render(fig, opts = {}) {
386
365
  if (!seen.has(key)) {
387
366
  const name = `a${seen.size}`;
388
367
  seen.set(key, name);
389
- const kf = segs.map((s, i) => `${pct(s.t0 / total)},${pct(s.t1 / total - 0.0001)} { ${values[i]} }`).join(' ');
368
+ const kf = runs(values);
390
369
  css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
391
370
  }
392
371
  return seen.get(key);
393
372
  };
394
- /**
395
- * The look of one box over the loop: off, trail (visited) or active (the packet arrived, until the segment ends).
396
- * A segment ends at the beat end, or after the step hold for the last beat.
397
- * Each frame holds one look. The gap before the frame that follows an active frame gives the FADE.
398
- * FADE is in timeline seconds, which are wall seconds.
399
- */
373
+ // FADE and RAMP are wall seconds; the player matches them in ms.
400
374
  const FADE = 0.4;
401
- const RAMP = 0.4; // the today line moves in 400 ms, in wall seconds like FADE
402
- // A box tone is a permanent state: a 1 px border and a light tint in the off and trail looks. The active look uses the hop tone, else the box tone.
375
+ const RAMP = 0.4;
403
376
  const looks = (look, boxTone, hopTone) => {
404
377
  const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
405
378
  if (look === 'off')
@@ -411,13 +384,21 @@ export function render(fig, opts = {}) {
411
384
  ? [toneTint(c, 'var(--surface)', 10), c, 2, `drop-shadow(0 0 4px ${c})`]
412
385
  : ['var(--tint)', 'var(--accent)', 2, 'drop-shadow(0 0 4px var(--accent))'];
413
386
  };
387
+ const hopsAt = new Map();
388
+ for (const h of hops) {
389
+ const k = `${h.si}:${h.bi}:${h.dest}`;
390
+ const list = hopsAt.get(k);
391
+ if (list)
392
+ list.push(h);
393
+ else
394
+ hopsAt.set(k, [h]);
395
+ }
414
396
  const boxAnim = (id, shape, boxTone) => {
415
397
  const pieces = [];
416
398
  segs.forEach((s, i) => {
417
399
  if (!litNodes[i].has(id))
418
400
  return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
419
- const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
420
- // A focused box turns active at the beat start; in a timeline, when the today line arrives.
401
+ const here = hopsAt.get(`${s.si}:${s.bi}:${id}`) ?? [];
421
402
  const focused = beats[s.si][s.bi].focus?.includes(id);
422
403
  const arrive = Math.min(...here.map((h) => h.t1), focused ? Math.min(s.t0 + (tl ? RAMP : 0), s.t1) : s.t1, s.t1);
423
404
  if (arrive > s.t0)
@@ -427,18 +408,23 @@ export function render(fig, opts = {}) {
427
408
  });
428
409
  if (!pieces.length || pieces.every((q) => q.look === 'off'))
429
410
  return '';
430
- // The last frame is active: the fade ends at 100 %, so the loop wraps with no jump.
431
411
  const end = pieces.at(-1);
432
412
  if (end.look === 'active') {
433
413
  end.b -= FADE;
434
414
  pieces.push({ a: end.b, b: end.b + FADE, look: pieces[0].look, hop: pieces[0].hop, ramp: FADE - 0.0002 * total });
435
415
  }
436
- const kf = pieces.map((q, k) => {
416
+ const merged = [];
417
+ pieces.forEach((q, k) => {
437
418
  const ramp = q.ramp ?? (q.look !== 'active' && pieces[k - 1]?.look === 'active' ? Math.min(FADE, (q.b - q.a) / 2) : 0);
438
419
  const [fill, stroke, width, filter] = looks(q.look, boxTone, q.hop);
439
420
  const css = `${shape ? `fill: ${fill}; ` : ''}stroke: ${stroke}; stroke-width: ${width}${shape ? `; filter: ${filter}` : ''}`;
440
- return `${pct((q.a + ramp) / total)},${pct(q.b / total - 0.0001)} { ${css} }`;
421
+ const last = merged.at(-1);
422
+ if (last?.css === css)
423
+ last.b = q.b;
424
+ else
425
+ merged.push({ a: q.a + ramp, b: q.b, css });
441
426
  });
427
+ const kf = merged.map((f) => `${pct(f.a / total)},${pct(f.b / total - 0.0001)} { ${f.css} }`);
442
428
  const key = (shape ? 'B' : 'R') + kf.join(' ');
443
429
  if (!seen.has(key)) {
444
430
  const name = `a${seen.size}`;
@@ -491,7 +477,6 @@ export function render(fig, opts = {}) {
491
477
  : item.sub
492
478
  ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
493
479
  : '';
494
- // The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
495
480
  const dot = bt ?? 'var(--accent)';
496
481
  const my = n2(p.y + p.h / 2);
497
482
  const mark = item.mark === 'start'
@@ -501,7 +486,6 @@ export function render(fig, opts = {}) {
501
486
  : '';
502
487
  return shape + label + sub + mark + (contents ? card(p, cardTop, contents) : '');
503
488
  });
504
- /** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
505
489
  function card(p, top, contents) {
506
490
  const id = p.item.id;
507
491
  const x = p.x + 10, w = p.w - 20, h = cardH.get(id);
@@ -509,7 +493,6 @@ export function render(fig, opts = {}) {
509
493
  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"` +
510
494
  cls(anim(filled, 'stroke: var(--accent); fill: var(--card-on)', 'stroke: var(--border); fill: var(--surface)', 'c')) +
511
495
  `/>`;
512
- // Every distinct content gets a layer; the segments decide which one is showing.
513
496
  const layers = contents
514
497
  .map((c, ci) => {
515
498
  const on = segs.map((_, i) => contents.indexOf(shownAt[i][id]) === ci);
@@ -524,7 +507,6 @@ export function render(fig, opts = {}) {
524
507
  : '';
525
508
  return box + layers + dash;
526
509
  }
527
- /** One card's rows: a colored tag pill, the text, its muted meta, and the mark on the right. */
528
510
  function rows(c, x, top, w) {
529
511
  const { rows } = layoutCard(c, w);
530
512
  let y = top + CARD_PAD;
@@ -561,22 +543,18 @@ export function render(fig, opts = {}) {
561
543
  const on = segs.map((_, i) => litEdges[i].has(r.id));
562
544
  const hidden = e.quiet && !on.every(Boolean);
563
545
  const shown = hidden ? cls(anim(on, 'opacity: 1', 'opacity: 0', 'q')) : '';
564
- // A toned hop colors its edge for its own beat only; the trail after it is the accent.
565
546
  const tone = segs.map(({ si, bi }) => (beats[si][bi].hops.find((h) => h.edge === r.id && h.tone)?.tone ?? undefined));
566
547
  const col = tone.map((x) => (x ? TONES[x] : 'var(--accent)'));
567
548
  const off = `stroke: var(--muted); stroke-width: ${EDGE_OFF}`;
568
- // With no tone the map and the rail share one keyframe (same key); a toned edge needs its own values.
569
549
  const toned = tone.some(Boolean);
570
550
  const lit = cls(toned
571
551
  ? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
572
552
  : anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
573
- // A cross-block edge draws its two stubs; its full path only guides the packet, so the packet jumps between the blocks.
574
553
  const path = r.stub
575
554
  ? r.stub.parts
576
555
  .map((d) => `<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
577
556
  .join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
578
557
  : `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
579
- // A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
580
558
  const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
581
559
  cls(toned
582
560
  ? frames(on.map((x, i) => x ? `fill: ${tone[i] ? toneFill(col[i]) : col[i]}; stroke: ${col[i]}` : 'fill: var(--bg); stroke: var(--border)'), 'l', 'fill: var(--bg); stroke: var(--border)')
@@ -596,8 +574,6 @@ export function render(fig, opts = {}) {
596
574
  }
597
575
  return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
598
576
  });
599
- // A packet per hop: it waits offstage, crosses its edge in `speed`, rests at the end of the edge to the end of its beat, then leaves. Any `data`
600
- // rides above it in a chip, which is how the figure says what is moving.
601
577
  const packets = hops.map((h, i) => {
602
578
  const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
603
579
  const name = `p${i}`;
@@ -618,8 +594,7 @@ export function render(fig, opts = {}) {
618
594
  `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
619
595
  `<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
620
596
  });
621
- // The timeline axis under the boxes, the fixed today marker and the moving playhead over them.
622
- let axisSvg = '', todaySvg = '';
597
+ let axisSvg = '', lineSvg = '', todaySvg = '';
623
598
  if (tl) {
624
599
  const top = placed[0];
625
600
  const x0 = top.x + gutterOf(fig.layout.children);
@@ -632,38 +607,40 @@ export function render(fig, opts = {}) {
632
607
  `<text x="${n2(x0 + k.x + 3)}" y="${n2(top.y + 10)}" class="tick">${esc(k.label)}</text>`)
633
608
  .join('');
634
609
  const bottom = n2(top.y + top.h);
635
- // The today marker is fixed at the figure's `today`.
636
610
  if (tl.today != null) {
637
611
  const tx = x0 + tl.today;
638
- todaySvg =
639
- `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>` +
640
- `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
612
+ lineSvg = `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>`;
613
+ todaySvg = `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
641
614
  }
642
- // The playhead moves to the start of each beat's item, then to the last date in the step hold.
643
615
  if (tl.last != null && segs.length) {
644
616
  const home = tl.last;
645
617
  const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
646
618
  const dateOf = new Map(tl.items.map((i) => [i.id, i.date]));
647
619
  const hold = STEP_HOLD_MS / 1000 / BASE_RATE;
648
- const pts = [[0, home]];
649
- const dates = []; // the label date in each time span
650
- let at = home;
620
+ const first = loopStartItem(tl.items, steps);
621
+ const start = first ? startOf.get(first.id) : home;
622
+ const pts = [[0, start]];
623
+ const dates = [];
624
+ let at = start;
651
625
  segs.forEach((s, i) => {
652
626
  const item = playheadItem(tl.items, beats[s.si], s.bi);
653
627
  const to = item ? startOf.get(item.id) : home;
654
628
  const date = item ? dateOf.get(item.id) : tl.lastDate;
655
629
  const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
656
- const end = last ? s.t1 - hold : s.t1; // the step hold starts here
630
+ const end = last ? s.t1 - hold : s.t1;
657
631
  pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
658
- const [la, lb] = labelSpan(s.t0, end, RAMP);
632
+ const [la, lb] = i === 0 && to === start ? [s.t0, end] : labelSpan(s.t0, end, RAMP);
659
633
  if (la < lb)
660
634
  dates.push({ a: la, b: lb, d: date, x: to });
661
635
  at = to;
662
636
  if (last) {
663
- pts.push([Math.min(end + RAMP, s.t1), home], [s.t1, home]);
664
- const [ha, hb] = labelSpan(end, s.t1, RAMP);
637
+ const back = i === segs.length - 1 ? Math.max(end + RAMP, s.t1 - RAMP) : s.t1;
638
+ pts.push([Math.min(end + RAMP, s.t1), home], [back, home]);
639
+ const [ha, hb] = labelSpan(end, back, RAMP);
665
640
  if (ha < hb)
666
641
  dates.push({ a: ha, b: hb, d: tl.lastDate, x: home });
642
+ if (back < s.t1)
643
+ pts.push([s.t1, start]);
667
644
  at = home;
668
645
  }
669
646
  });
@@ -678,36 +655,32 @@ export function render(fig, opts = {}) {
678
655
  if (!seen.has(key)) {
679
656
  const nm = `a${seen.size}`;
680
657
  seen.set(key, nm);
681
- // Each span ends with a hidden frame: the label stays off while the line moves.
682
658
  const fr = dates
683
659
  .map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
684
660
  .join(' ');
685
661
  css.push(`@keyframes ${nm} { ${fr} }\n.${nm} { animation: ${nm} ${n2(total)}s infinite step-end; }`);
686
662
  }
687
- // A label that would meet "today" moves to the tick row.
688
663
  const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
689
664
  return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
690
665
  });
691
- todaySvg += `<g${cls(name)}><path d="M ${n2(lx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-width="1.5"/>${labels.join('')}</g>`;
666
+ lineSvg += `<g${cls(name)}><path d="M ${n2(lx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-width="1.5"/></g>`;
667
+ todaySvg += `<g${cls(name)}>${labels.join('')}</g>`;
692
668
  }
693
669
  }
694
670
  const bounds = placed[0];
695
- const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i])); // a stub does not arc
671
+ const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i]));
696
672
  const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
697
673
  const mapW = Math.max(bounds.w + pad * 2, 560);
698
- // `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
699
674
  const rail = fig.rail ? layoutRail(fig, fig.rail === 'only' ? 560 : mapW) : null;
700
675
  const only = fig.rail === 'only' && rail != null;
701
- // With the rail, the rail shows the step label, so the caption holds the narration only.
702
676
  const capTop = rail ? 20 : 26;
703
677
  const capH = steps.length ? capTop + 4 + Math.max(0, ...capLines) * 20 : 0;
704
678
  const W = only ? Math.max(560, rail.width) : Math.max(mapW, rail?.width ?? 0);
705
679
  const mapH = only ? 0 : bounds.h + pad * 2 + (arcs ? 44 : 0);
706
- const top = only ? pad : mapH + RAIL.gap; // the rail's top
680
+ const top = only ? pad : mapH + RAIL.gap;
707
681
  const H = only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH;
708
682
  const shift = (W - (bounds.w + pad * 2)) / 2;
709
683
  const railX = rail ? (W - rail.width) / 2 : 0;
710
- // The step label and its narration, both switching with the beats.
711
684
  const labels = rail || synthetic
712
685
  ? []
713
686
  : steps.map((_, si) => {
@@ -721,23 +694,19 @@ export function render(fig, opts = {}) {
721
694
  lines.map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`).join('') +
722
695
  `</g>`);
723
696
  });
724
- // 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
725
- // tag for `async`, the filled card for a parallel group. So the rail adds no color and no font size.
726
697
  const railSvg = !rail
727
698
  ? ''
728
699
  : (() => {
729
700
  const parts = [];
730
- const cols = rail.head - RAIL.cols / 2 + 4; // the column-label baseline
701
+ const cols = rail.head - RAIL.cols / 2 + 4;
731
702
  for (const b of rail.bands)
732
703
  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)"/>` +
733
704
  `<text x="${n2(b.rect.x + 10)}" y="${n2(top + b.rect.y + 14)}" class="frame">${esc(b.label.toUpperCase())}</text>`);
734
705
  for (const c of rail.columns)
735
706
  parts.push(`<text x="${n2(c.x)}" y="${n2(top + cols)}" class="railcol">${esc(c.label)}</text>`);
736
- // Where each row sits in each segment: it moves when another phase opens, and hides while its phase is folded.
737
707
  const at = (i) => segs.map((s) => railState(rail, s.si)[i]);
738
708
  const base = (i) => railState(rail, 0)[i].y;
739
709
  const moveRow = (i) => frames(at(i).map((st) => `transform: translateY(${n2(st.y - base(i))}px); opacity: ${st.shown ? 1 : 0}`), 'm');
740
- // The lifelines run through the message rows only, so a phase header reads as one clear divider.
741
710
  rail.rows.forEach((row, i) => {
742
711
  if (row.kind !== 'message')
743
712
  return;
@@ -755,8 +724,7 @@ export function render(fig, opts = {}) {
755
724
  if (row.kind === 'phase') {
756
725
  const folded = segs.map((s) => rail.folds && s.si !== row.step);
757
726
  const rule = (x1) => `<path d="M ${n2(x1)} ${n2(y + 15)} H ${n2(row.line.end)}" stroke="var(--border)"/>`;
758
- // One counter per beat of this phase, on this row, so it moves with the row. A parallel beat counts its first message.
759
- const first = new Map(); // beat -> the n of its first message
727
+ const first = new Map();
760
728
  for (const r of rail.rows)
761
729
  if (r.kind === 'message' && r.step === row.step && !first.has(r.beat))
762
730
  first.set(r.beat, r.n);
@@ -780,7 +748,6 @@ export function render(fig, opts = {}) {
780
748
  const col = row.tone ? TONES[row.tone] : 'var(--accent)';
781
749
  const lit = anim(now, `stroke: ${col}; stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, row.tone ? 'e' + col : 'e');
782
750
  const tagW = ASYNC_TAG_W;
783
- // The tag sits on the tail side of the pill, so it never hides the arrowhead.
784
751
  const tagX = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
785
752
  const tag = row.async
786
753
  ? `<rect x="${n2(tagX)}" y="${n2(ly - 6)}" width="${n2(tagW)}" height="12" rx="4" fill="var(--bg)"/>` +
@@ -831,7 +798,7 @@ ${only
831
798
  ? ''
832
799
  : `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
833
800
  ${axisSvg}
834
- ${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}
801
+ ${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}${lineSvg && `\n${lineSvg}`}
835
802
  ${tl ? edgeSvg.join('\n') : ''}
836
803
  ${boxes.filter((b, i) => b && !(tl && isGroup(placed[i].item))).join('\n')}
837
804
  ${tl ? '' : edgeSvg.join('\n')}
@@ -851,16 +818,22 @@ ${said.join('\n')}
851
818
  edges: [
852
819
  ...(only ? [] : routed).flatMap((r) => {
853
820
  const e = fig.edges[ids.indexOf(r.id)];
854
- // A cross-block edge is two scene edges: each stub with its pill.
855
821
  if (r.stub)
856
822
  return r.stub.pts.map((pts, k) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: r.stub.pills[k] }));
857
- return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id], ...(tl && { behind: true }) }];
823
+ return [
824
+ {
825
+ id: r.id,
826
+ from: e.from,
827
+ to: e.to,
828
+ curve: r.curve,
829
+ label: labelRects[r.id],
830
+ ...(tl && { behind: true, elbow: r.elbow }),
831
+ },
832
+ ];
858
833
  }),
859
- // Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
860
834
  ...(rail?.rows ?? []).flatMap((row, i) => {
861
835
  if (row.kind !== 'message')
862
836
  return [];
863
- // Scene coordinates are the map's layout coordinates: the page minus the map's translate(shift, arcs).
864
837
  const dx = only ? railX : railX - shift, dy = only ? top : top - (arcs ? 44 : 0);
865
838
  const y = dy + railState(rail, row.step)[i].y + RAIL.row / 2;
866
839
  const [x1, x2] = [dx + rail.columns[row.from].x, dx + rail.columns[row.to].x];
@@ -878,30 +851,17 @@ ${said.join('\n')}
878
851
  }),
879
852
  ],
880
853
  minFont: Math.min(...fonts),
881
- ...(stubs.size && {
854
+ ...(lanes && {
882
855
  lanes: placed.filter((p) => p.lane).map((p) => ({ id: str(p.item.label), rect: { x: p.x, y: p.y, w: p.w, h: p.h } })),
883
856
  }),
884
857
  };
885
858
  return { svg, scene };
886
859
  }
887
- /** The lanes wrap plan at the width and text size of `opts`, or null if the figure does not draw as lanes. A rail sets a least width. */
888
- function planFor(fig, opts) {
889
- if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
890
- return null;
891
- const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
892
- return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
893
- }
894
860
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
895
861
  export function toSvg(fig, opts = {}) {
896
862
  return render(fig, opts).svg;
897
863
  }
898
- /** 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. */
864
+ /** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. */
899
865
  export function check(fig, opts = {}) {
900
- const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
901
- rule: 'lane-end-block',
902
- severity: 'warning',
903
- ids: [id],
904
- message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
905
- }));
906
- return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
866
+ return checkRendered(fig, opts, render(fig, opts).scene);
907
867
  }
package/dist/text.d.ts CHANGED
@@ -1,4 +1,2 @@
1
- /** The width of `s` in px at `fontSize`. */
2
1
  export declare function textWidth(s: string, fontSize: number, mono?: boolean): number;
3
- /** Break a string into lines that fit `width`, keeping the newlines it already has. */
4
2
  export declare function wrap(s: string, width: number, fontSize: number, mono?: boolean): string[];
package/dist/text.js CHANGED
@@ -1,17 +1,14 @@
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.
1
+ // Estimated widths: no browser here. The React player measures real text.
3
2
  const FULL = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|\p{Extended_Pictographic}/u;
4
3
  const NARROW = /[ilj.,:;'|!()[\]ftrI ]/;
5
4
  const WIDE = /[WMmw@%]/;
6
5
  const TALL = /[A-Z0-9#&?]/;
7
- /** The width of `s` in px at `fontSize`. */
8
6
  export function textWidth(s, fontSize, mono = false) {
9
7
  let em = 0;
10
8
  for (const ch of s)
11
9
  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
10
  return em * fontSize;
13
11
  }
14
- /** Break a string into lines that fit `width`, keeping the newlines it already has. */
15
12
  export function wrap(s, width, fontSize, mono = false) {
16
13
  const out = [];
17
14
  for (const para of s.split('\n')) {
package/dist/verify.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { FlowProps } from './model.ts';
2
2
  import type { Finding } from './scene.ts';
3
3
  export { parseSource, headingSlug, owners, links, type Link } from './source.ts';
4
- /** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
4
+ /** The links against the files under `root`. A path outside `root` counts as a missing file. */
5
5
  export declare function verify(fig: FlowProps, { root }?: {
6
6
  root?: string;
7
7
  }): Finding[];