flowfig 0.10.0 → 0.12.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/source.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // No Node import: the React and SVG entries must stay browser-safe.
2
- import { edgeId, nodes, toBeat } from './model.js';
2
+ import { edgeId, nodes, scenes, toBeat } from './model.js';
3
3
  /** `path` or `path#symbol`. No spaces, one `#` at most, both parts non-empty. `null` for a bad form. */
4
4
  export function parseSource(s) {
5
5
  const m = /^([^\s#]+)(?:#([^\s#]+))?$/.exec(s);
@@ -12,18 +12,24 @@ export const headingSlug = (text) => text
12
12
  .toLowerCase()
13
13
  .replace(/[^\p{L}\p{N} -]/gu, '')
14
14
  .replace(/ /g, '-');
15
- /** Every box, edge and hop with its raw `source`, well-formed or not. The one owner list that `links` and `checkSpec` share. */
16
15
  export function owners(fig) {
17
- return [
18
- ...nodes(fig.layout).map((n) => [`box "${n.id}"`, n.source, undefined]),
19
- ...fig.edges.map((e) => [`edge "${edgeId(e)}"`, e.source, e.via]),
20
- ...(fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => [`hop on "${h.edge}"`, h.source, h.via]))),
21
- ];
16
+ return scenes(fig).flatMap((s) => {
17
+ const ports = new Set(s.ports.map((p) => p.id));
18
+ const at = s.line ? ` [${s.line}]` : '';
19
+ const kind = s.line ? 'part' : 'box';
20
+ return [
21
+ ...nodes(s.props.layout)
22
+ .filter((n) => !ports.has(n.id))
23
+ .map((n) => [`${kind} "${n.id}"${at}`, n.source, undefined, s.path.join('>')]),
24
+ ...s.props.edges.map((e) => [`edge "${edgeId(e)}"${at}`, e.source, e.via, s.path.join('>')]),
25
+ ...(s.props.steps ?? []).flatMap((st) => st.flow.flatMap((b) => toBeat(b).hops.map((h) => [`hop on "${h.edge}"${at}`, h.source, h.via, s.path.join('>')]))),
26
+ ];
27
+ });
22
28
  }
23
29
  /** Every well-formed link in the figure, with the box, edge or hop that carries it. `bad-source` covers the rest. */
24
30
  export function links(fig) {
25
- return owners(fig).flatMap(([owner, source]) => {
31
+ return owners(fig).flatMap(([owner, source, , level]) => {
26
32
  const p = source == null ? null : parseSource(source);
27
- return p ? [{ owner, source: source, ...p }] : [];
33
+ return p ? [{ owner, source: source, ...p, ...(level && { level }) }] : [];
28
34
  });
29
35
  }
package/dist/svg.d.ts CHANGED
@@ -23,11 +23,23 @@ export type SvgOptions = {
23
23
  marks?: DiffMarks;
24
24
  /** Links a box to an href, by box id. `flowfig atlas` sets it. */
25
25
  links?: Record<string, string>;
26
+ /** Draws the level of this box as a plain figure: no tour, no script. */
27
+ scene?: string;
28
+ /** Adds the script that opens a box on a click, for a figure with parts. */
29
+ script?: boolean;
30
+ /** A prefix for animation names and path ids. Each page SVG needs its own. */
31
+ scope?: string;
32
+ };
33
+ export type Level = {
34
+ line: string;
35
+ props: FlowProps;
36
+ scene: Scene;
26
37
  };
27
38
  /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
28
39
  export declare function render(spec: FlowProps, opts?: SvgOptions): {
29
40
  svg: string;
30
41
  scene: Scene;
42
+ levels?: Level[];
31
43
  };
32
44
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
33
45
  export declare function toSvg(fig: FlowProps, opts?: SvgOptions): string;
package/dist/svg.js CHANGED
@@ -6,17 +6,20 @@ import { checkRendered, planFor } from './check.js';
6
6
  import { autoLayout, rowBreaks } from './auto.js';
7
7
  export { parseSource, links } from './source.js';
8
8
  export { diff, formatDiff, mergeFigures } from './diff.js';
9
- 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, LABEL_LINE, itemWidth, labelLines, fitCap, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, altText, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, edgeTip, } from './model.js';
9
+ 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, LABEL_LINE, itemWidth, labelLines, fitCap, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, altText, beatTimes, n2, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, edgeTip, scenes, tourPlan, flowOrder, OPEN_MS, } from './model.js';
10
10
  export { LIGHT, DARK } from './model.js';
11
11
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
12
12
  const SUB_LINE = 15;
13
13
  const MIN_W = 320;
14
14
  const FRAME_TOP = 37, FRAME_BOTTOM = 18;
15
- // The card-on fills approximate the player's 8 % accent tint.
16
- 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));`;
15
+ const color = (t, k) => {
16
+ const v = String(t[k]);
17
+ const depth = [...v].reduce((d, c) => (d < 0 ? d : d + (c === '(' ? 1 : c === ')' ? -1 : 0)), 0);
18
+ return /^[#a-zA-Z0-9 ,.%()/+-]+$/.test(v) && !/url\(/i.test(v) && depth === 0 ? v : LIGHT[k];
19
+ };
20
+ const vars = (t, cardOn) => `--accent:${color(t, 'accent')}; --fg:${color(t, 'fg')}; --muted:${color(t, 'muted')}; --bg:${color(t, 'bg')}; --surface:${color(t, 'surface')}; --border:${color(t, 'border')}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
17
21
  const content = (c) => (isRows(c) ? c : str(c));
18
22
  const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]);
19
- const n2 = (v) => Math.round(v * 10) / 10;
20
23
  // Four decimals: one decimal rounds a 0.7 s hop away and the packet jumps.
21
24
  const n4 = (v) => Math.round(v * 10000) / 10000;
22
25
  const pct = (v) => Math.round(v * 10000) / 100 + '%';
@@ -162,6 +165,7 @@ function place(item, x, y, s, out) {
162
165
  }
163
166
  }
164
167
  const MARK_COLOR = { added: TONES.green, removed: TONES.red, changed: TONES.orange };
168
+ const tagged = (op, id, inner) => op ? `<g data-diff="${op}" data-id="${esc(id)}">${inner}</g>` : inner;
165
169
  const marked = (mark, inner) => mark === 'removed' ? `<g opacity="0.5" stroke-dasharray="5 4">${inner}</g>` : mark ? `<g stroke-width="2">${inner}</g>` : inner;
166
170
  function legendOf(m) {
167
171
  const count = (kind) => Object.values(m.boxes).filter((k) => k === kind).length + Object.values(m.edges).filter((k) => k === kind).length;
@@ -179,7 +183,9 @@ function legendSvg(items, W, H) {
179
183
  let x = (W - legendWidth(items)) / 2;
180
184
  return items
181
185
  .map((i, k) => {
182
- const sq = i.color === 'none' ? '' : `<rect x="${n2(x)}" y="${n2(H - 24)}" width="10" height="10" rx="2" fill="${i.color}"/>`;
186
+ const sq = i.color === 'none'
187
+ ? ''
188
+ : `<rect x="${n2(x)}" y="${n2(H - 24)}" width="10" height="10" rx="2" fill="${i.color}" data-diff="${i.text.split(' ')[0]}"/>`;
183
189
  const out = `${sq}<text x="${n2(x + (i.color === 'none' ? 0 : 16))}" y="${n2(H - 15)}" font-size="12" fill="var(--muted)">${esc(i.text)}</text>\n`;
184
190
  x += widths[k] + 20;
185
191
  return out;
@@ -187,10 +193,10 @@ function legendSvg(items, W, H) {
187
193
  .join('');
188
194
  }
189
195
  const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
190
- /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
191
- export function render(spec, opts = {}) {
196
+ function draw(spec, opts, at) {
192
197
  const fig = autoLayout(spec);
193
- const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
198
+ const sc = opts.scope ?? '';
199
+ const pre = at?.prefix ?? sc;
194
200
  const pad = opts.padding ?? 24;
195
201
  const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
196
202
  const synthetic = tl != null && !fig.steps?.length && !opts.marks;
@@ -293,10 +299,10 @@ export function render(spec, opts = {}) {
293
299
  const dl = dia ? diamondLines(p.item, p.w) : null;
294
300
  const room = (far) => diamondRoom(p.w, p.h, far);
295
301
  const flat = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
296
- const texts = labelLines(p.item, p.w).map((text) => ({
302
+ const texts = labelLines(p.item, p.w).map((text, i) => ({
297
303
  text,
298
304
  fontSize: 14,
299
- room: dl ? room(dl.label.far) : flat,
305
+ room: dl ? room(dl.label[i].far) : flat,
300
306
  }));
301
307
  if (dl)
302
308
  for (const l of dl.subs)
@@ -319,12 +325,10 @@ export function render(spec, opts = {}) {
319
325
  const segs = [];
320
326
  const hops = [];
321
327
  const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
322
- let t = 0;
328
+ const times = at?.times ?? beatTimes(beats, opts.speed ?? fig.speed ?? 900);
323
329
  beats.forEach((stepBeats, si) => {
324
330
  stepBeats.forEach((b, bi) => {
325
- const last = bi === stepBeats.length - 1;
326
- const dur = beatMs(b, opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
327
- const hold = last ? STEP_HOLD_MS / 1000 / BASE_RATE : 0;
331
+ const { t0, t1, tEnd } = times.beats[si][bi];
328
332
  b.hops.forEach((h, lane) => {
329
333
  if (byId[h.edge])
330
334
  hops.push({
@@ -336,16 +340,15 @@ export function render(spec, opts = {}) {
336
340
  back: h.back,
337
341
  data: h.data,
338
342
  tone: h.tone && TONES[h.tone],
339
- t0: t,
340
- t1: t + speed,
341
- tEnd: t + dur,
343
+ t0,
344
+ t1,
345
+ tEnd,
342
346
  });
343
347
  });
344
- segs.push({ t0: t, t1: t + dur + hold, si, bi });
345
- t += dur + hold;
348
+ segs.push({ t0, t1: bi === stepBeats.length - 1 ? times.ends[si] : tEnd, si, bi });
346
349
  });
347
350
  });
348
- const total = t || 1;
351
+ const total = at?.total ?? (times.ends.at(-1) || 1);
349
352
  const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
350
353
  const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
351
354
  const litNodes = segs.map((seg, i) => {
@@ -383,7 +386,7 @@ export function render(spec, opts = {}) {
383
386
  return '';
384
387
  const key = prefix + on.map((x) => (x ? 1 : 0)).join('');
385
388
  if (!seen.has(key)) {
386
- const name = `a${seen.size}`;
389
+ const name = `${pre}a${seen.size}`;
387
390
  seen.set(key, name);
388
391
  const frames = runs(on.map((x) => (x ? onCss : offCss)));
389
392
  css.push(`@keyframes ${name} { ${frames} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
@@ -400,7 +403,7 @@ export function render(spec, opts = {}) {
400
403
  return '';
401
404
  const key = prefix + values.join('|');
402
405
  if (!seen.has(key)) {
403
- const name = `a${seen.size}`;
406
+ const name = `${pre}a${seen.size}`;
404
407
  seen.set(key, name);
405
408
  const kf = runs(values);
406
409
  css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
@@ -464,7 +467,7 @@ export function render(spec, opts = {}) {
464
467
  const kf = merged.map((f) => `${pct(f.a / total)},${pct(f.b / total - 0.0001)} { ${f.css} }`);
465
468
  const key = (shape ? 'B' : 'R') + kf.join(' ');
466
469
  if (!seen.has(key)) {
467
- const name = `a${seen.size}`;
470
+ const name = `${pre}a${seen.size}`;
468
471
  seen.set(key, name);
469
472
  css.push(`@keyframes ${name} { ${kf.join(' ')} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
470
473
  }
@@ -482,9 +485,11 @@ export function render(spec, opts = {}) {
482
485
  }
483
486
  const diffMark = opts.marks?.boxes[item.id];
484
487
  const href = opts.links?.[item.id];
488
+ const fade = at?.fades?.get(item.id);
485
489
  const tip = (inner) => {
486
- const body = marked(diffMark, item.source ? `<g><title>${esc(item.source)}</title>${inner}</g>` : inner);
487
- return href == null ? body : `<a href="${esc(href)}" tabindex="-1">${body}</a>`;
490
+ const body = tagged(diffMark, item.id, marked(diffMark, item.source ? `<g><title>${esc(item.source)}</title>${inner}</g>` : inner));
491
+ const linked = href == null ? body : `<a href="${esc(href)}" tabindex="-1">${body}</a>`;
492
+ return fade ? `<g class="${fade}">${linked}</g>` : linked;
488
493
  };
489
494
  const bt = diffMark ? MARK_COLOR[diffMark] : item.tone && TONES[item.tone];
490
495
  const stroke = cls(boxAnim(item.id, true, bt));
@@ -501,23 +506,29 @@ export function render(spec, opts = {}) {
501
506
  return tip(shape + `<text x="${n2(tx)}" y="${n2(cy + 4.5)}" class="bar">${esc(label)}</text>`);
502
507
  }
503
508
  const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
509
+ const port = at?.ports.has(item.id);
504
510
  const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
505
511
  const stroke0 = bt ?? 'var(--border)';
506
512
  const cx = p.x + p.w / 2;
507
513
  const contents = cards.get(item.id);
508
514
  const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
509
515
  const dl = item.shape === 'decision' && !contents ? diamondLines(item, p.w) : null;
510
- const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (dl ? dl.label.dy : item.sub ? -2 : 5);
516
+ const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (dl ? dl.label[0].dy : item.sub ? -2 : 5);
511
517
  const shape = item.shape === 'decision'
512
518
  ? `<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="${fill0}" stroke="${stroke0}"${stroke}/>`
513
519
  : item.shape === 'store'
514
520
  ? `<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="${fill0}" stroke="${stroke0}"${stroke}/>` +
515
521
  `<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="${stroke0}"${rim}/>`
516
- : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
522
+ : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${port ? ' stroke-dasharray="4 3"' : ''}${stroke}/>`;
523
+ const opens = at?.owners.has(item.id) && item.shape !== 'decision'
524
+ ? `<rect x="${n2(p.x + 3)}" y="${n2(p.y + 3)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="var(--bg)" stroke="var(--border)"/>`
525
+ : '';
517
526
  const lines = labelLines(item, p.w);
518
527
  const extra = (lines.length - 1) * LABEL_LINE;
519
- const firstY = item.shape === 'store' || contents ? labelY : labelY - extra / 2;
520
- const label = lines.map((l, i) => `<text x="${n2(cx)}" y="${n2(firstY + i * LABEL_LINE)}" class="label">${esc(l)}</text>`).join('');
528
+ const firstY = item.shape === 'store' || contents || dl ? labelY : labelY - extra / 2;
529
+ const label = lines
530
+ .map((l, i) => `<text x="${n2(cx)}" y="${n2(firstY + i * LABEL_LINE)}"${cls('label', port && 'muted')}>${esc(l)}</text>`)
531
+ .join('');
521
532
  const sub = dl
522
533
  ? dl.subs.map((l) => `<text x="${n2(cx)}" y="${n2(p.y + p.h / 2 + l.dy)}" class="sub">${esc(l.text)}</text>`).join('')
523
534
  : item.sub
@@ -532,7 +543,7 @@ export function render(spec, opts = {}) {
532
543
  : item.mark === 'end'
533
544
  ? `<circle cx="${mx}" cy="${my}" r="6.25" fill="none" stroke="${dot}" stroke-width="1.5"/><circle cx="${mx}" cy="${my}" r="4" fill="${dot}"/>`
534
545
  : '';
535
- return tip(shape + label + sub + mark + (contents ? card(p, cardTop, contents) : ''));
546
+ return tip(opens + shape + label + sub + mark + (contents ? card(p, cardTop, contents) : ''));
536
547
  });
537
548
  function card(p, top, contents) {
538
549
  const id = p.item.id;
@@ -603,9 +614,9 @@ export function render(spec, opts = {}) {
603
614
  const tip = edgeTip(r.id, e.source, beats.flat());
604
615
  const path0 = r.stub
605
616
  ? r.stub.parts
606
- .map((d) => `<path d="${d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
607
- .join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
608
- : `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
617
+ .map((d) => `<path d="${d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#${sc}arrow)"${lit}/>`)
618
+ .join('') + `<path id="${pre}p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
619
+ : `<path id="${pre}p-${esc(r.id)}" d="${r.d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#${sc}arrow)"${lit}/>`;
609
620
  const path = tip ? `<g><title>${esc(tip)}</title>${path0}</g>` : path0;
610
621
  const pillColor = em ? MARK_COLOR[em] : undefined;
611
622
  const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="${pillColor ? toneTint(pillColor, 'var(--bg)', 14) : 'var(--bg)'}" stroke="${pillColor ?? 'var(--border)'}"` +
@@ -625,11 +636,11 @@ export function render(spec, opts = {}) {
625
636
  fonts.push(11);
626
637
  label = pill(r.mid.x, r.mid.y, lw, str(e.label));
627
638
  }
628
- return marked(em === 'removed' ? em : undefined, hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label);
639
+ return tagged(em, r.id, marked(em === 'removed' ? em : undefined, hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label));
629
640
  });
630
641
  const packets = hops.map((h, i) => {
631
- const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
632
- const name = `p${i}`;
642
+ const t0 = h.t0 / total, t1 = Math.min(h.t1 / total, 1), tEnd = h.tEnd / total, reach = n4((t1 - t0) / (h.t1 / total - t0));
643
+ const name = `${pre}p${i}`;
633
644
  css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(tEnd - 0.0001)} { opacity: 1 } ${pct(tEnd)},100% { opacity: 0 } }\n` +
634
645
  `.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
635
646
  const chip = (() => {
@@ -644,8 +655,8 @@ export function render(spec, opts = {}) {
644
655
  lines.map((l, li) => `<text x="0" y="${n2(-boxH - 12 + 15 * li + 15)}" class="chip">${esc(l)}</text>`).join(''));
645
656
  })();
646
657
  return (`<g${cls(name)} opacity="0"><circle r="10" fill="${h.tone ?? 'var(--accent)'}" opacity="0.2"/><circle r="4.5" fill="${h.tone ?? 'var(--accent)'}"/>${chip}` +
647
- `<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">` +
648
- `<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
658
+ `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? `1;1;${n4(1 - reach)};${n4(1 - reach)}` : `0;0;${reach};${reach}`}" calcMode="linear">` +
659
+ `<mpath href="#${pre}p-${esc(h.edge)}" xlink:href="#${pre}p-${esc(h.edge)}"/></animateMotion></g>`);
649
660
  });
650
661
  let axisSvg = '', lineSvg = '', todaySvg = '';
651
662
  if (tl) {
@@ -697,7 +708,7 @@ export function render(spec, opts = {}) {
697
708
  at = home;
698
709
  }
699
710
  });
700
- const name = `a${seen.size}`;
711
+ const name = `${pre}a${seen.size}`;
701
712
  seen.set('playhead', name);
702
713
  const kf = pts.map(([t, x]) => `${pct(t / total)} { transform: translateX(${n2(x - home)}px) }`).join(' ');
703
714
  css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
@@ -706,7 +717,7 @@ export function render(spec, opts = {}) {
706
717
  const on = dates.map((x) => x.d === d);
707
718
  const key = `d${d}${on.map((x) => (x ? 1 : 0)).join('')}`;
708
719
  if (!seen.has(key)) {
709
- const nm = `a${seen.size}`;
720
+ const nm = `${pre}a${seen.size}`;
710
721
  seen.set(key, nm);
711
722
  const fr = dates
712
723
  .map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
@@ -739,17 +750,20 @@ export function render(spec, opts = {}) {
739
750
  const H = (only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH) + legendH;
740
751
  const shift = (W - (bounds.w + pad * 2 + arcL + arcR)) / 2 + arcL;
741
752
  const railX = rail ? (W - rail.width) / 2 : 0;
753
+ const still = (on) => !!at?.fades && on.every(Boolean);
742
754
  const labels = rail || synthetic
743
755
  ? []
744
756
  : steps.map((_, si) => {
745
757
  const on = segs.map((s) => s.si === si);
746
- 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>`;
758
+ return `<text x="${n2(W / 2)}" y="${n2(H - legendH - capH + 16)}"${still(on) ? '' : ' opacity="0"'}${cls('steplabel', !still(on) && anim(on, 'opacity: 1', 'opacity: 0', 's'))}>${esc(str(steps[si].label))}</text>`;
747
759
  });
748
760
  const said = [...new Set(captions)].map((text) => {
749
761
  const on = captions.map((c) => c === text);
750
762
  const lines = wrap(text, wrapW, 13.5);
751
- return (`<g opacity="0"${cls(anim(on, 'opacity: 1', 'opacity: 0', 'y'))}>` +
752
- lines.map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`).join('') +
763
+ return (`<g${still(on) ? '' : ` opacity="0"${cls(anim(on, 'opacity: 1', 'opacity: 0', 'y'))}`}>` +
764
+ lines
765
+ .map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - legendH - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`)
766
+ .join('') +
753
767
  `</g>`);
754
768
  });
755
769
  const railSvg = !rail
@@ -818,47 +832,15 @@ export function render(spec, opts = {}) {
818
832
  : '';
819
833
  const tip = edgeTip(row.edge, fig.edges[ids.indexOf(row.edge)].source, beats.flat());
820
834
  parts.push(`<g${cls('railrow', moveRow(i))}>${tip ? `<title>${esc(tip)}</title>` : ''}<g${cls(anim(next, 'opacity: .45', 'opacity: 1', 'r'))}>` +
821
- `<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)}/>` +
835
+ `<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(#${sc}arrow)"${cls(lit)}/>` +
822
836
  tag +
823
837
  pill +
824
838
  `</g></g>`);
825
839
  });
826
840
  return `<g transform="translate(${n2(railX)} 0)">${parts.join('')}</g>`;
827
841
  })();
828
- const { font, ...t0 } = { ...LIGHT, ...fig.theme, ...opts.theme };
829
- const alt = altText(fig);
830
842
  const holdAt = beats[0]?.length ? n2(segs[beats[0].length - 1].t1 - 0.1) : 0;
831
- 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)}" role="img">
832
- <title>${esc(alt.title)}</title>
833
- <desc>${esc(alt.desc)}</desc>
834
- <style>
835
- svg { ${vars(t0, '#eef5fd')} }
836
- @media (prefers-color-scheme: dark) { svg { ${vars(DARK, '#1d2733')} } }
837
- .label { fill: var(--fg); font-size: 14px; font-weight: 500; text-anchor: middle; }
838
- .sub { fill: var(--muted); font-size: 12px; text-anchor: middle; }
839
- .frame { fill: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; }
840
- .edgelabel { fill: var(--muted); font-size: 11px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
841
- .row { fill: var(--fg); font-size: 11px; }
842
- .row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
843
- .muted { fill: var(--muted); }
844
- .bar { fill: var(--fg); font-size: 13px; font-weight: 500; }
845
- .tick { fill: var(--muted); font-size: 11px; }
846
- .today { fill: var(--accent); font-size: 11px; font-weight: 600; }
847
- .tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
848
- .mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
849
- .chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
850
- .steplabel { fill: var(--fg); font-size: 13px; font-weight: 600; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
851
- .caption { fill: var(--muted); font-size: 13.5px; text-anchor: middle; }
852
- .railcol { fill: var(--fg); font-size: 12px; font-weight: 500; text-anchor: middle; }
853
- .railphase { fill: var(--fg); font-size: 13px; font-weight: 600; font-family: ui-monospace, Menlo, monospace; }
854
- .railphase.muted { fill: var(--muted); }
855
- .end { text-anchor: end; }
856
- ${css.join('\n')}
857
- @media (prefers-reduced-motion: reduce) { * { animation-play-state: paused !important; animation-delay: -${holdAt}s !important; } }
858
- </style>
859
- <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="context-stroke"/></marker></defs>
860
- <rect width="100%" height="100%" fill="var(--bg)"/>
861
- ${only
843
+ const body = `${only
862
844
  ? ''
863
845
  : `<g transform="translate(${n2(shift)} ${arcT})">
864
846
  ${axisSvg}
@@ -872,8 +854,7 @@ ${todaySvg}
872
854
  `}${railSvg}
873
855
  ${labels.join('\n')}
874
856
  ${said.join('\n')}
875
- ${legendSvg(legend, W, H)}</svg>
876
- `;
857
+ ${legendSvg(legend, W, H)}`;
877
858
  if (rail)
878
859
  fonts.push(12, 13);
879
860
  if (legend.length)
@@ -923,7 +904,189 @@ ${legendSvg(legend, W, H)}</svg>
923
904
  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 } })),
924
905
  }),
925
906
  };
926
- return { svg, scene };
907
+ const frame = { x: shift - arcL, w: bounds.w + pad * 2 + arcL + arcR, h: mapH };
908
+ return { W, H, css: css.join('\n'), body, holdAt, scene, fig, frame };
909
+ }
910
+ const page = (fig, opts, W, H, css, holdAt, body) => {
911
+ const { font, ...t0 } = { ...LIGHT, ...fig.theme, ...opts.theme };
912
+ const alt = altText(fig);
913
+ return `<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)}" role="img">
914
+ <title>${esc(alt.title)}</title>
915
+ <desc>${esc(alt.desc)}</desc>
916
+ <style>
917
+ svg { ${vars(t0, '#eef5fd')} }
918
+ @media (prefers-color-scheme: dark) { svg { ${vars(DARK, '#1d2733')} } }
919
+ .label { fill: var(--fg); font-size: 14px; font-weight: 500; text-anchor: middle; }
920
+ .sub { fill: var(--muted); font-size: 12px; text-anchor: middle; }
921
+ .frame { fill: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; }
922
+ .edgelabel { fill: var(--muted); font-size: 11px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
923
+ .row { fill: var(--fg); font-size: 11px; }
924
+ .row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
925
+ .muted { fill: var(--muted); }
926
+ .bar { fill: var(--fg); font-size: 13px; font-weight: 500; }
927
+ .tick { fill: var(--muted); font-size: 11px; }
928
+ .today { fill: var(--accent); font-size: 11px; font-weight: 600; }
929
+ .tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
930
+ .mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
931
+ .chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
932
+ .steplabel { fill: var(--fg); font-size: 13px; font-weight: 600; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
933
+ .caption { fill: var(--muted); font-size: 13.5px; text-anchor: middle; }
934
+ .railcol { fill: var(--fg); font-size: 12px; font-weight: 500; text-anchor: middle; }
935
+ .railphase { fill: var(--fg); font-size: 13px; font-weight: 600; font-family: ui-monospace, Menlo, monospace; }
936
+ .railphase.muted { fill: var(--muted); }
937
+ .end { text-anchor: end; }
938
+ ${css}
939
+ @media (prefers-reduced-motion: reduce) { * { animation-play-state: paused !important; animation-delay: -${holdAt}s !important; } }
940
+ </style>
941
+ <defs><marker id="${opts.scope ?? ''}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="context-stroke"/></marker></defs>
942
+ <rect width="100%" height="100%" fill="var(--bg)"/>
943
+ ${body}</svg>
944
+ `;
945
+ };
946
+ const SCRIPT = `(()=>{const s=document.currentScript.closest('svg'),G=[...s.querySelectorAll('.scene')],S=[G[0]],R=matchMedia('(prefers-reduced-motion: reduce)').matches,
947
+ W=(g,k)=>g.dataset[k].split(' ').map(p=>p.split(',').map(Number));let q=[],L=W(G[0],'loop'),w=[0,-1],o,t=0,p=performance.now();
948
+ const seek=t=>{s.pauseAnimations();s.setCurrentTime(t);for(const a of s.getAnimations({subtree:true}))a.pause(),a.currentTime=t*1e3+a.effect.getTiming().delay},
949
+ tick=n=>{if(t>=w[1]){o=q.length>0;if(!o)q=L.slice();w=q.shift();t=w[0]}seek(R?w[1]:Math.min(t,w[1]));t+=R?(o?1e9:0):(n-p)/1e3;p=n;requestAnimationFrame(tick)},
950
+ show=()=>{const c=S.at(-1);for(const g of G){g.style.pointerEvents=g==c?'':'none';for(const x of g.querySelectorAll('.hit,.back'))x.setAttribute('role','button'),x.setAttribute('tabindex',g==c?0:-1)}},
951
+ go=(a,b)=>{q=a;L=b;w=[0,-1];t=0;show()},
952
+ back=()=>{if(S.length>1){const g=S.pop();go(W(g,'back'),W(S.at(-1),'loop'))}},
953
+ act=e=>{const x=e.target.closest('.hit,.back');if(!x||x.closest('.scene')!=S.at(-1))return;if(x.classList.contains('back'))back();else{const g=G.find(g=>g.id=='s-'+x.dataset.open);S.push(g);go(W(g,'open'),W(g,'loop'))}return 1};
954
+ s.setAttribute('role','group');s.addEventListener('click',act);
955
+ s.addEventListener('keydown',e=>{const k=e.key;if(k=='Escape'||k=='Backspace')back();else if(!((k=='Enter'||k==' ')&&act(e)))return;e.preventDefault()});
956
+ show();requestAnimationFrame(tick)})()`;
957
+ const BAND = 28;
958
+ /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
959
+ export function render(spec, opts = {}) {
960
+ const { scene: one, script, ...rest } = opts;
961
+ const all = scenes(spec);
962
+ const key = (path) => path.join('>');
963
+ const under = (list, path) => list.filter((x) => x.path.length === path.length + 1 && key(x.path.slice(0, -1)) === key(path));
964
+ const at = (s) => ({
965
+ ports: new Set(s.ports.map((p) => p.id)),
966
+ owners: new Map(under(all, s.path).map((x) => [x.path.at(-1), x.parts])),
967
+ });
968
+ if (one != null) {
969
+ const s = all.find((x) => x.path.at(-1) === one);
970
+ if (!s)
971
+ throw new Error(`box "${one}" has no level to open`);
972
+ const plain = (g) => ({
973
+ ...g,
974
+ children: g.children.map((c) => (isGroup(c) ? plain(c) : { ...c, inside: undefined })),
975
+ });
976
+ const d = draw({ ...s.props, layout: plain(s.props.layout) }, rest, at(s));
977
+ return { svg: page(d.fig, rest, d.W, d.H, d.css, d.holdAt, d.body), scene: d.scene };
978
+ }
979
+ if (all.length < 2) {
980
+ const d = draw(spec, rest);
981
+ return { svg: page(d.fig, rest, d.W, d.H, d.css, d.holdAt, d.body), scene: d.scene };
982
+ }
983
+ const plan = tourPlan(spec, opts.speed ?? spec.speed);
984
+ const total = plan.ms / 1000, eps = plan.ms / 10000;
985
+ const { fade, grow, stagger, hide, shrink } = OPEN_MS;
986
+ const tour = [];
987
+ const uid = `${opts.scope ?? ''}f${([...JSON.stringify(spec)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 7) >>> 0).toString(36)}`;
988
+ const track = (pts) => {
989
+ const name = `${uid}t${tour.length}`;
990
+ const kf = pts.map(([t, v, ease]) => `${pct(t / plan.ms)} { ${v}${ease ? `; animation-timing-function: ${ease}` : ''} }`).join(' ');
991
+ tour.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
992
+ return name;
993
+ };
994
+ const fadeAt = (o) => o.grown - grow - fade;
995
+ const dips = (path) => under(plan.opens, path).flatMap((n) => [
996
+ [fadeAt(n), 1],
997
+ [fadeAt(n) + fade, 0],
998
+ [n.back[1] - fade, 0],
999
+ [n.back[1], 1],
1000
+ ]);
1001
+ const drawn = all.map((s, i) => {
1002
+ const o = plan.opens.find((x) => key(x.path) === key(s.path));
1003
+ const props = o ? { ...s.props, steps: o.step === -1 ? [] : [s.props.steps[o.step]] } : spec;
1004
+ const fades = o &&
1005
+ new Map(flowOrder(s).map((id, k) => {
1006
+ const t = o.grown + stagger * k;
1007
+ return [
1008
+ id,
1009
+ track([
1010
+ [0, 'opacity: 0'],
1011
+ [t, 'opacity: 0'],
1012
+ [t + fade, 'opacity: 1'],
1013
+ [plan.ms, 'opacity: 1'],
1014
+ ]),
1015
+ ];
1016
+ }));
1017
+ const times = o ? o.times : plan.times;
1018
+ return { s, o, props, d: draw(props, rest, { prefix: `${uid}s${i}`, ...at(s), fades, times, total }) };
1019
+ });
1020
+ const W = Math.max(...drawn.map((x) => x.d.W)), H = Math.max(...drawn.map((x) => x.d.H)) + BAND;
1021
+ const off = (d) => [(W - d.W) / 2, (H + BAND - d.H) / 2];
1022
+ const windows = (w, at) => (w.length ? w : [[at, at]]).map(([a, b]) => `${n2(a / 1000)},${n2(b / 1000)}`).join(' ');
1023
+ const groups = drawn.map(({ s, o, d }) => {
1024
+ const shown = o
1025
+ ? [
1026
+ [0, 0],
1027
+ [o.grown, 0],
1028
+ [o.grown + eps, 1],
1029
+ ...dips(s.path),
1030
+ [o.back[1] - fade - shrink - hide, 1],
1031
+ [o.back[1] - fade - shrink, 0],
1032
+ [plan.ms, 0],
1033
+ ]
1034
+ : [[0, 1], ...dips(s.path), [plan.ms, 1]];
1035
+ const band = o
1036
+ ? `<text class="back ${uid}back" x="16" y="19">Back</text><text class="${uid}path" x="${n2(16 + textWidth('Back', 14) + 12)}" y="19">${esc(s.line)}</text>`
1037
+ : '';
1038
+ const area = d.scene.area;
1039
+ const hits = !script
1040
+ ? ''
1041
+ : under(all, s.path)
1042
+ .map((x) => {
1043
+ const r = d.scene.boxes.find((b) => b.id === x.path.at(-1)).rect;
1044
+ return `<rect class="hit" x="${n2(r.x - area.x)}" y="${n2(r.y - area.y)}" width="${n2(r.w)}" height="${n2(r.h)}" fill="transparent" tabindex="0" role="button" aria-label="Open ${esc(x.name)}, ${x.parts} parts" data-open="${esc(x.path.at(-1))}"/>`;
1045
+ })
1046
+ .join('');
1047
+ const data = !script
1048
+ ? ''
1049
+ : o
1050
+ ? ` data-open="${windows([o.open], 0)}" data-loop="${windows(o.loop, o.open[1])}" data-back="${windows([o.back], 0)}"`
1051
+ : ` data-loop="${windows(plan.loop, 0)}"`;
1052
+ return (`<g class="${track(shown.map(([t, v]) => [t, `opacity: ${v}`]))}"><g class="scene"${o ? ` id="s-${esc(s.path.at(-1))}"` : ''}${data}>${band}` +
1053
+ `<g transform="translate(${n2(off(d)[0])} ${n2(off(d)[1])})">\n${d.body}${hits}</g></g></g>`);
1054
+ });
1055
+ const frames = drawn.slice(1).map(({ s, o, d }) => {
1056
+ const parent = drawn.find((x) => key(x.s.path) === key(s.path.slice(0, -1))).d;
1057
+ const r = parent.scene.boxes.find((b) => b.id === s.path.at(-1)).rect;
1058
+ const [px, py] = off(parent), fx = off(d)[0] + d.frame.x, fy = off(d)[1];
1059
+ const dx = px + r.x - parent.scene.area.x - fx, dy = py + r.y - parent.scene.area.y - fy;
1060
+ const small = `transform: translate(${n2(dx)}px, ${n2(dy)}px) scale(${n4(r.w / d.frame.w)}, ${n4(r.h / d.frame.h)})`;
1061
+ const full = 'transform: translate(0px, 0px) scale(1, 1)';
1062
+ const from = fadeAt(o), shut = o.back[1] - fade;
1063
+ const name = track([
1064
+ [0, `opacity: 0; ${small}`],
1065
+ [from, `opacity: 0; ${small}`],
1066
+ [from + eps, `opacity: 1; ${small}`],
1067
+ [from + fade, `opacity: 1; ${small}`, 'cubic-bezier(0.2, 0, 0, 1)'],
1068
+ [o.grown, `opacity: 1; ${full}`],
1069
+ ...dips(s.path).map(([t, v]) => [t, `opacity: ${v}; ${full}`]),
1070
+ [shut - shrink, `opacity: 1; ${full}`, 'cubic-bezier(0.4, 0, 1, 1)'],
1071
+ [shut, `opacity: 1; ${small}`],
1072
+ [shut + eps, `opacity: 0; ${small}`],
1073
+ [plan.ms, `opacity: 0; ${small}`],
1074
+ ]);
1075
+ return (`<rect class="open-frame ${name}" data-for="${esc(s.path.at(-1))}" x="${n2(fx)}" y="${n2(fy)}" width="${n2(d.frame.w)}" height="${n2(d.frame.h)}" rx="10"` +
1076
+ ` fill="none" stroke="var(--border)" vector-effect="non-scaling-stroke" style="transform-box: view-box; transform-origin: ${n2(fx)}px ${n2(fy)}px"/>`);
1077
+ });
1078
+ const root = drawn[0].d;
1079
+ const css = [
1080
+ ...drawn.map((x) => x.d.css),
1081
+ `.${uid}back { fill: var(--accent); font-size: 14px; }\n.${uid}path { fill: var(--fg); font-size: 14px; }`,
1082
+ ...tour,
1083
+ ];
1084
+ const body = `${groups.join('\n')}\n<g pointer-events="none">${frames.join('')}</g>\n${script ? `<script><![CDATA[${SCRIPT}]]></script>\n` : ''}`;
1085
+ return {
1086
+ svg: page(root.fig, rest, W, H, css.join('\n'), root.holdAt, body),
1087
+ scene: root.scene,
1088
+ levels: drawn.map(({ s, props, d }) => ({ line: s.line, props, scene: d.scene })),
1089
+ };
927
1090
  }
928
1091
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
929
1092
  export function toSvg(fig, opts = {}) {
@@ -931,5 +1094,6 @@ export function toSvg(fig, opts = {}) {
931
1094
  }
932
1095
  /** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. */
933
1096
  export function check(fig, opts = {}) {
934
- return checkRendered(fig, opts, render(fig, opts).scene);
1097
+ const r = render(fig, opts);
1098
+ return checkRendered(fig, opts, r.scene, r.levels);
935
1099
  }
package/dist/verify.d.ts CHANGED
@@ -13,8 +13,14 @@ export type Coverage = {
13
13
  id: string;
14
14
  reason: string;
15
15
  }[];
16
+ /** The coverage of each level inside a box, by path line. */
17
+ levels?: {
18
+ path: string;
19
+ coverage: Coverage;
20
+ }[];
16
21
  };
17
22
  export declare const coverageLine: (figure: string, c: Coverage) => string;
23
+ export declare const levelLines: (c: Coverage) => string[];
18
24
  export declare const unsureLines: (c: Coverage) => string[];
19
25
  export declare const SPEC_MARK = "<metadata id=\"figure-spec\">";
20
26
  export declare function detailFindings(fig: FlowProps, root: string, read?: Read): Finding[];