flowfig 0.11.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/svg.js CHANGED
@@ -6,7 +6,7 @@ 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;
@@ -20,7 +20,6 @@ const color = (t, k) => {
20
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));`;
21
21
  const content = (c) => (isRows(c) ? c : str(c));
22
22
  const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]);
23
- const n2 = (v) => Math.round(v * 10) / 10;
24
23
  // Four decimals: one decimal rounds a 0.7 s hop away and the packet jumps.
25
24
  const n4 = (v) => Math.round(v * 10000) / 10000;
26
25
  const pct = (v) => Math.round(v * 10000) / 100 + '%';
@@ -166,6 +165,7 @@ function place(item, x, y, s, out) {
166
165
  }
167
166
  }
168
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;
169
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;
170
170
  function legendOf(m) {
171
171
  const count = (kind) => Object.values(m.boxes).filter((k) => k === kind).length + Object.values(m.edges).filter((k) => k === kind).length;
@@ -183,7 +183,9 @@ function legendSvg(items, W, H) {
183
183
  let x = (W - legendWidth(items)) / 2;
184
184
  return items
185
185
  .map((i, k) => {
186
- 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]}"/>`;
187
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`;
188
190
  x += widths[k] + 20;
189
191
  return out;
@@ -191,10 +193,10 @@ function legendSvg(items, W, H) {
191
193
  .join('');
192
194
  }
193
195
  const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
194
- /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
195
- export function render(spec, opts = {}) {
196
+ function draw(spec, opts, at) {
196
197
  const fig = autoLayout(spec);
197
- const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
198
+ const sc = opts.scope ?? '';
199
+ const pre = at?.prefix ?? sc;
198
200
  const pad = opts.padding ?? 24;
199
201
  const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
200
202
  const synthetic = tl != null && !fig.steps?.length && !opts.marks;
@@ -323,12 +325,10 @@ export function render(spec, opts = {}) {
323
325
  const segs = [];
324
326
  const hops = [];
325
327
  const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
326
- let t = 0;
328
+ const times = at?.times ?? beatTimes(beats, opts.speed ?? fig.speed ?? 900);
327
329
  beats.forEach((stepBeats, si) => {
328
330
  stepBeats.forEach((b, bi) => {
329
- const last = bi === stepBeats.length - 1;
330
- const dur = beatMs(b, opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
331
- const hold = last ? STEP_HOLD_MS / 1000 / BASE_RATE : 0;
331
+ const { t0, t1, tEnd } = times.beats[si][bi];
332
332
  b.hops.forEach((h, lane) => {
333
333
  if (byId[h.edge])
334
334
  hops.push({
@@ -340,16 +340,15 @@ export function render(spec, opts = {}) {
340
340
  back: h.back,
341
341
  data: h.data,
342
342
  tone: h.tone && TONES[h.tone],
343
- t0: t,
344
- t1: t + speed,
345
- tEnd: t + dur,
343
+ t0,
344
+ t1,
345
+ tEnd,
346
346
  });
347
347
  });
348
- segs.push({ t0: t, t1: t + dur + hold, si, bi });
349
- t += dur + hold;
348
+ segs.push({ t0, t1: bi === stepBeats.length - 1 ? times.ends[si] : tEnd, si, bi });
350
349
  });
351
350
  });
352
- const total = t || 1;
351
+ const total = at?.total ?? (times.ends.at(-1) || 1);
353
352
  const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
354
353
  const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
355
354
  const litNodes = segs.map((seg, i) => {
@@ -387,7 +386,7 @@ export function render(spec, opts = {}) {
387
386
  return '';
388
387
  const key = prefix + on.map((x) => (x ? 1 : 0)).join('');
389
388
  if (!seen.has(key)) {
390
- const name = `a${seen.size}`;
389
+ const name = `${pre}a${seen.size}`;
391
390
  seen.set(key, name);
392
391
  const frames = runs(on.map((x) => (x ? onCss : offCss)));
393
392
  css.push(`@keyframes ${name} { ${frames} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
@@ -404,7 +403,7 @@ export function render(spec, opts = {}) {
404
403
  return '';
405
404
  const key = prefix + values.join('|');
406
405
  if (!seen.has(key)) {
407
- const name = `a${seen.size}`;
406
+ const name = `${pre}a${seen.size}`;
408
407
  seen.set(key, name);
409
408
  const kf = runs(values);
410
409
  css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
@@ -468,7 +467,7 @@ export function render(spec, opts = {}) {
468
467
  const kf = merged.map((f) => `${pct(f.a / total)},${pct(f.b / total - 0.0001)} { ${f.css} }`);
469
468
  const key = (shape ? 'B' : 'R') + kf.join(' ');
470
469
  if (!seen.has(key)) {
471
- const name = `a${seen.size}`;
470
+ const name = `${pre}a${seen.size}`;
472
471
  seen.set(key, name);
473
472
  css.push(`@keyframes ${name} { ${kf.join(' ')} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
474
473
  }
@@ -486,9 +485,11 @@ export function render(spec, opts = {}) {
486
485
  }
487
486
  const diffMark = opts.marks?.boxes[item.id];
488
487
  const href = opts.links?.[item.id];
488
+ const fade = at?.fades?.get(item.id);
489
489
  const tip = (inner) => {
490
- const body = marked(diffMark, item.source ? `<g><title>${esc(item.source)}</title>${inner}</g>` : inner);
491
- 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;
492
493
  };
493
494
  const bt = diffMark ? MARK_COLOR[diffMark] : item.tone && TONES[item.tone];
494
495
  const stroke = cls(boxAnim(item.id, true, bt));
@@ -505,6 +506,7 @@ export function render(spec, opts = {}) {
505
506
  return tip(shape + `<text x="${n2(tx)}" y="${n2(cy + 4.5)}" class="bar">${esc(label)}</text>`);
506
507
  }
507
508
  const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
509
+ const port = at?.ports.has(item.id);
508
510
  const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
509
511
  const stroke0 = bt ?? 'var(--border)';
510
512
  const cx = p.x + p.w / 2;
@@ -517,11 +519,16 @@ export function render(spec, opts = {}) {
517
519
  : item.shape === 'store'
518
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}/>` +
519
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}/>`
520
- : `<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
+ : '';
521
526
  const lines = labelLines(item, p.w);
522
527
  const extra = (lines.length - 1) * LABEL_LINE;
523
528
  const firstY = item.shape === 'store' || contents || dl ? labelY : labelY - extra / 2;
524
- const label = lines.map((l, i) => `<text x="${n2(cx)}" y="${n2(firstY + i * LABEL_LINE)}" class="label">${esc(l)}</text>`).join('');
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('');
525
532
  const sub = dl
526
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('')
527
534
  : item.sub
@@ -536,7 +543,7 @@ export function render(spec, opts = {}) {
536
543
  : item.mark === 'end'
537
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}"/>`
538
545
  : '';
539
- return tip(shape + label + sub + mark + (contents ? card(p, cardTop, contents) : ''));
546
+ return tip(opens + shape + label + sub + mark + (contents ? card(p, cardTop, contents) : ''));
540
547
  });
541
548
  function card(p, top, contents) {
542
549
  const id = p.item.id;
@@ -607,9 +614,9 @@ export function render(spec, opts = {}) {
607
614
  const tip = edgeTip(r.id, e.source, beats.flat());
608
615
  const path0 = r.stub
609
616
  ? r.stub.parts
610
- .map((d) => `<path d="${d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
611
- .join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
612
- : `<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}/>`;
613
620
  const path = tip ? `<g><title>${esc(tip)}</title>${path0}</g>` : path0;
614
621
  const pillColor = em ? MARK_COLOR[em] : undefined;
615
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)'}"` +
@@ -629,11 +636,11 @@ export function render(spec, opts = {}) {
629
636
  fonts.push(11);
630
637
  label = pill(r.mid.x, r.mid.y, lw, str(e.label));
631
638
  }
632
- 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));
633
640
  });
634
641
  const packets = hops.map((h, i) => {
635
- const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
636
- 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}`;
637
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` +
638
645
  `.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
639
646
  const chip = (() => {
@@ -648,8 +655,8 @@ export function render(spec, opts = {}) {
648
655
  lines.map((l, li) => `<text x="0" y="${n2(-boxH - 12 + 15 * li + 15)}" class="chip">${esc(l)}</text>`).join(''));
649
656
  })();
650
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}` +
651
- `<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">` +
652
- `<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>`);
653
660
  });
654
661
  let axisSvg = '', lineSvg = '', todaySvg = '';
655
662
  if (tl) {
@@ -701,7 +708,7 @@ export function render(spec, opts = {}) {
701
708
  at = home;
702
709
  }
703
710
  });
704
- const name = `a${seen.size}`;
711
+ const name = `${pre}a${seen.size}`;
705
712
  seen.set('playhead', name);
706
713
  const kf = pts.map(([t, x]) => `${pct(t / total)} { transform: translateX(${n2(x - home)}px) }`).join(' ');
707
714
  css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
@@ -710,7 +717,7 @@ export function render(spec, opts = {}) {
710
717
  const on = dates.map((x) => x.d === d);
711
718
  const key = `d${d}${on.map((x) => (x ? 1 : 0)).join('')}`;
712
719
  if (!seen.has(key)) {
713
- const nm = `a${seen.size}`;
720
+ const nm = `${pre}a${seen.size}`;
714
721
  seen.set(key, nm);
715
722
  const fr = dates
716
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 }`)
@@ -743,17 +750,20 @@ export function render(spec, opts = {}) {
743
750
  const H = (only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH) + legendH;
744
751
  const shift = (W - (bounds.w + pad * 2 + arcL + arcR)) / 2 + arcL;
745
752
  const railX = rail ? (W - rail.width) / 2 : 0;
753
+ const still = (on) => !!at?.fades && on.every(Boolean);
746
754
  const labels = rail || synthetic
747
755
  ? []
748
756
  : steps.map((_, si) => {
749
757
  const on = segs.map((s) => s.si === si);
750
- 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>`;
751
759
  });
752
760
  const said = [...new Set(captions)].map((text) => {
753
761
  const on = captions.map((c) => c === text);
754
762
  const lines = wrap(text, wrapW, 13.5);
755
- return (`<g opacity="0"${cls(anim(on, 'opacity: 1', 'opacity: 0', 'y'))}>` +
756
- 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('') +
757
767
  `</g>`);
758
768
  });
759
769
  const railSvg = !rail
@@ -822,47 +832,15 @@ export function render(spec, opts = {}) {
822
832
  : '';
823
833
  const tip = edgeTip(row.edge, fig.edges[ids.indexOf(row.edge)].source, beats.flat());
824
834
  parts.push(`<g${cls('railrow', moveRow(i))}>${tip ? `<title>${esc(tip)}</title>` : ''}<g${cls(anim(next, 'opacity: .45', 'opacity: 1', 'r'))}>` +
825
- `<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)}/>` +
826
836
  tag +
827
837
  pill +
828
838
  `</g></g>`);
829
839
  });
830
840
  return `<g transform="translate(${n2(railX)} 0)">${parts.join('')}</g>`;
831
841
  })();
832
- const { font, ...t0 } = { ...LIGHT, ...fig.theme, ...opts.theme };
833
- const alt = altText(fig);
834
842
  const holdAt = beats[0]?.length ? n2(segs[beats[0].length - 1].t1 - 0.1) : 0;
835
- 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">
836
- <title>${esc(alt.title)}</title>
837
- <desc>${esc(alt.desc)}</desc>
838
- <style>
839
- svg { ${vars(t0, '#eef5fd')} }
840
- @media (prefers-color-scheme: dark) { svg { ${vars(DARK, '#1d2733')} } }
841
- .label { fill: var(--fg); font-size: 14px; font-weight: 500; text-anchor: middle; }
842
- .sub { fill: var(--muted); font-size: 12px; text-anchor: middle; }
843
- .frame { fill: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; }
844
- .edgelabel { fill: var(--muted); font-size: 11px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
845
- .row { fill: var(--fg); font-size: 11px; }
846
- .row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
847
- .muted { fill: var(--muted); }
848
- .bar { fill: var(--fg); font-size: 13px; font-weight: 500; }
849
- .tick { fill: var(--muted); font-size: 11px; }
850
- .today { fill: var(--accent); font-size: 11px; font-weight: 600; }
851
- .tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
852
- .mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
853
- .chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
854
- .steplabel { fill: var(--fg); font-size: 13px; font-weight: 600; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; }
855
- .caption { fill: var(--muted); font-size: 13.5px; text-anchor: middle; }
856
- .railcol { fill: var(--fg); font-size: 12px; font-weight: 500; text-anchor: middle; }
857
- .railphase { fill: var(--fg); font-size: 13px; font-weight: 600; font-family: ui-monospace, Menlo, monospace; }
858
- .railphase.muted { fill: var(--muted); }
859
- .end { text-anchor: end; }
860
- ${css.join('\n')}
861
- @media (prefers-reduced-motion: reduce) { * { animation-play-state: paused !important; animation-delay: -${holdAt}s !important; } }
862
- </style>
863
- <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>
864
- <rect width="100%" height="100%" fill="var(--bg)"/>
865
- ${only
843
+ const body = `${only
866
844
  ? ''
867
845
  : `<g transform="translate(${n2(shift)} ${arcT})">
868
846
  ${axisSvg}
@@ -876,8 +854,7 @@ ${todaySvg}
876
854
  `}${railSvg}
877
855
  ${labels.join('\n')}
878
856
  ${said.join('\n')}
879
- ${legendSvg(legend, W, H)}</svg>
880
- `;
857
+ ${legendSvg(legend, W, H)}`;
881
858
  if (rail)
882
859
  fonts.push(12, 13);
883
860
  if (legend.length)
@@ -927,7 +904,189 @@ ${legendSvg(legend, W, H)}</svg>
927
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 } })),
928
905
  }),
929
906
  };
930
- 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
+ };
931
1090
  }
932
1091
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
933
1092
  export function toSvg(fig, opts = {}) {
@@ -935,5 +1094,6 @@ export function toSvg(fig, opts = {}) {
935
1094
  }
936
1095
  /** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. */
937
1096
  export function check(fig, opts = {}) {
938
- return checkRendered(fig, opts, render(fig, opts).scene);
1097
+ const r = render(fig, opts);
1098
+ return checkRendered(fig, opts, r.scene, r.levels);
939
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[];