Docs/Skills and code/skill video-visual-production

.claude/skills/video-visual-production/render/scenes-data.js

Code301 lines27.8 KBSkill: video-visual-production

What the file says about itself

Generic data and diagram scenes for the middle beats: stat, bars, line, photo2, chips, race, stalls, treadmill, unitgrid, stamps, paths, rungs. Same contract as scenes.js.

// Generic data and diagram scenes for the middle beats: stat, bars, line, photo2, chips, race, stalls, treadmill, unitgrid, stamps, paths, rungs.
// Same contract as scenes.js. Every string comes from props, every build from a cue, so a beat sheet can use them without code changes.
// `EXAMPLES` holds a working props + cues sample for each type; render/selftest-scenes.js draws them (that is the test).
const D = require('./draw');
const K = require('./contacts');
const { C, FONT, ease, prog, lerp, clamp, txt, panel, background, roundPath } = D;

const fade = (t, t0, d = 0.35) => ease.out3(prog(t, t0, d));
const fmt = (n, d = 0) => n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
const toneColor = (tone) => ({ accent: C.accent, good: C.good, loss: C.lossText || C.loss, muted: C.muted, dim: C.muted, ink: C.ink }[tone] || C.accent); // dim text would fail the contrast check, so dim maps to muted
function fitSize(ctx, s, font, size, maxW, min = 40) { let z = size; ctx.font = `${z}px ${font}`; while (ctx.measureText(s).width > maxW && z > min) { z -= 4; ctx.font = `${z}px ${font}`; } return z; }
function hatch(ctx, x, y, w, h, color, alpha = 0.5, gap = 24) { // diagonal hatch: the look of an allegation or an estimate
  ctx.save(); roundPath(ctx, x, y, w, h, 10); ctx.clip(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = 5;
  for (let k = -h; k < w; k += gap) { ctx.beginPath(); ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); ctx.stroke(); }
  ctx.restore(); ctx.globalAlpha = 1;
}
function pill(env, text, x, y, a, tone) { const { ctx } = env; ctx.font = `44px ${FONT.med}`; const w = ctx.measureText(text).width + 70; panel(ctx, x - w / 2, y - 40, w, 80, { r: 40, fill: C.bg0, stroke: tone, lw: 3, dash: [10, 8], alpha: a }); txt(env, text, x, y + 2, { font: FONT.med, size: 44, color: tone === C.loss ? C.lossText : tone, align: 'center', base: 'middle', alpha: a, role: 'label' }); }
// status grammar: solid = verified; dotted = single source or derived; dashed ghost = searched for and not found
function statusStroke(status) { /* alleged and estimate are drawn by hatch in the scene itself */ return status === 'dotted' ? { dash: [4, 10], color: C.accent } : status === 'ghost' ? { dash: [14, 10], color: C.loss } : null; }

// ------------------------------------------------------------ stat: one big figure with its label and source
function stat(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const a = fade(t, q.stat, 0.5), st = statusStroke(P.status);
  let val = P.value;
  if (P.count) { const k = ease.out3(prog(t, q.stat, 1.3)); val = (P.count.prefix || '') + fmt(lerp(P.count.from, P.count.to, k), P.count.decimals || 0) + (P.count.suffix || ''); }
  if (st) panel(ctx, 200, 210, 1520, 520, { r: 30, stroke: st.color, lw: 4, dash: st.dash, alpha: a });
  if (P.status === 'alleged' || P.status === 'estimate') { hatch(ctx, 200, 210, 1520, 520, C.accent, 0.16 * a); panel(ctx, 200, 210, 1520, 520, { r: 30, stroke: C.accent, lw: 4, dash: [18, 12], alpha: a }); pill(env, P.status === 'alleged' ? 'alleged, not proved' : 'estimate', 960, 275, a, C.loss); }
  const size = fitSize(ctx, val, FONT.bold, 240, 1380, 90);
  txt(env, val, 960, 470 + 24 * (1 - a), { font: FONT.bold, size, color: st && P.status === 'ghost' ? C.lossText : C.accent, align: 'center', base: 'middle', alpha: a, role: 'number' });
  if (P.label) txt(env, P.label, 960, 640, { font: FONT.semi, size: 56, align: 'center', alpha: fade(t, q.label != null ? q.label : q.stat + 0.3, 0.4), role: 'label' });
  if (P.sub) txt(env, P.sub, 960, 700, { font: FONT.reg, size: 42, color: C.muted, align: 'center', alpha: fade(t, q.label != null ? q.label : q.stat + 0.4, 0.4), role: 'label' });
  if (P.source) txt(env, P.source, 200, 800, { font: FONT.med, size: 34, color: C.muted, alpha: fade(t, q.stat + 0.3, 0.4), role: 'source' });
}

// ------------------------------------------------------------ bars: up to five columns, optional negative bar, ghost bar, gap marker
function bars(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const n = P.bars.length, vals = P.bars.map((b) => b.value || 0), maxAbs = Math.max(...vals.map(Math.abs), 1e-9), hasNeg = vals.some((v) => v < 0);
  const baseY = hasNeg ? 600 : 800, Hh = hasNeg ? 250 : 430, bw = 210, gap = 120, x0 = (1920 - (n * bw + (n - 1) * gap)) / 2;
  ctx.strokeStyle = C.dim; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x0 - 60, baseY); ctx.lineTo(x0 + n * bw + (n - 1) * gap + 60, baseY); ctx.stroke();
  const tops = [];
  P.bars.forEach((b, i) => {
    const x = x0 + i * (bw + gap), a = fade(t, q[b.cue], 0.3), g = ease.out3(prog(t, q[b.cue], 0.8));
    const h = b.ghost ? Hh * 0.45 : (Math.abs(b.value) / maxAbs) * Hh * g;
    const neg = b.value < 0, y = neg ? baseY : baseY - h;
    tops.push({ x: x + bw / 2, y: neg ? baseY : baseY - h });
    if (a > 0) {
      if (b.ghost) { panel(ctx, x, y, bw, h, { r: 10, stroke: C.loss, lw: 4, dash: [14, 10], alpha: a }); }
      else if (b.alleged || b.estimate || b.status === 'alleged' || b.status === 'estimate') { hatch(ctx, x, y, bw, h, C.accent, 0.8 * a); panel(ctx, x, y, bw, h, { r: 10, stroke: C.accent, lw: 4, dash: [14, 10], alpha: a }); }
      else { ctx.globalAlpha = a; ctx.fillStyle = neg ? C.loss : b.tone === 'muted' ? C.dim : b.tone === 'good' ? C.good : C.accent; roundPath(ctx, x, y, bw, h, 10); ctx.fill(); ctx.globalAlpha = 1; }
      const ty = neg ? baseY + h + 80 : baseY - h - 62;
      txt(env, b.text, x + bw / 2, b.ghost ? baseY - h - 62 : ty, { font: FONT.bold, size: 76, color: b.ghost ? C.lossText : C.ink, align: 'center', alpha: a * (b.ghost ? 1 : g), role: b.text.match(/\d/) ? 'number' : 'label' });
      if (b.unit) txt(env, b.unit, x + bw / 2, (b.ghost ? baseY - h - 62 : ty) + 52, { font: FONT.reg, size: 40, color: C.muted, align: 'center', alpha: a * g, role: 'label' });
    }
    txt(env, b.label, x + bw / 2, hasNeg ? 170 : baseY + 70, { font: FONT.semi, size: 44, color: C.ink, align: 'center', alpha: fade(t, q[b.cue], 0.4), role: 'label' });
  });
  if (P.chip) pill(env, P.chip.text, 960, 150, fade(t, q[P.chip.cue], 0.4), C.loss);
  if (P.gap) { // a bracket between two bars: the size of a difference
    const ga = fade(t, q[P.gap.cue], 0.4), A = tops[P.gap.from], B = tops[P.gap.to], y = Math.min(A.y, B.y) - 150;
    if (ga > 0) {
      ctx.globalAlpha = ga; ctx.strokeStyle = C.accent; ctx.lineWidth = 5; ctx.setLineDash([12, 10]);
      ctx.beginPath(); ctx.moveTo(A.x, y); ctx.lineTo(B.x, y); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1;
      txt(env, P.gap.text, (A.x + B.x) / 2, y - 24, { font: FONT.bold, size: 80, color: C.accent, align: 'center', alpha: ga, role: 'number' });
    }
  }
  if (P.source) txt(env, P.source, 200, 985, { font: FONT.med, size: fitSize(ctx, P.source, FONT.med, 34, 1500, 32), color: C.muted, alpha: fade(t, q[P.bars[0].cue], 0.5), role: 'source' });
}

// ------------------------------------------------------------ line: one or two series of labelled points, drawn point to point
function line(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const series = P.series, nPts = Math.max(...series.map((s) => s.points.length)), allY = series.flatMap((s) => s.points.map((p) => p.y));
  const yMax = (P.yMax || Math.max(...allY)) * 1.05, X0 = 340, X1 = 1580, Y0 = 760, Y1 = 270;
  const px = (i) => (nPts === 1 ? (X0 + X1) / 2 : X0 + (i / (nPts - 1)) * (X1 - X0)), py = (y) => Y0 - (y / yMax) * (Y0 - Y1);
  ctx.strokeStyle = C.dim; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(X0 - 60, Y0); ctx.lineTo(X1 + 60, Y0); ctx.stroke();
  (series[0].points).forEach((p, i) => txt(env, p.x || '', px(i), Y0 + 62, { font: FONT.med, size: 40, color: C.muted, align: 'center', alpha: fade(t, q[p.cue], 0.4), role: 'label' }));
  series.forEach((s, si) => {
    const col = toneColor(s.tone || (si ? 'good' : 'accent'));
    ctx.strokeStyle = col; ctx.lineWidth = 10; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
    for (let i = 1; i < s.points.length; i++) {
      const c0 = q[s.points[i - 1].cue], c1 = q[s.points[i].cue], k = ease.inOut(prog(t, c0, Math.max(0.6, c1 - c0)));
      if (k <= 0) continue;
      const ax = px(i - 1), ay = py(s.points[i - 1].y), bx = px(i), by = py(s.points[i].y);
      ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(lerp(ax, bx, k), lerp(ay, by, k)); ctx.stroke();
    }
    ctx.lineCap = 'butt';
    s.points.forEach((p, i) => {
      const a = fade(t, q[p.cue], 0.3); if (a <= 0) return;
      ctx.globalAlpha = a; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(px(i), py(p.y), 15, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
      if (p.text) {
        const other = series.length > 1 ? series.find((o) => o !== s && o.points[i]) : null;
        // two series: the higher one's labels go above, the lower one's below, so they never pile up; one series: a line falling into a point would cross a label above it, so the label goes below
        const above = other ? p.y >= other.points[i].y && !(p.y === other.points[i].y && si > 0) : !(i > 0 && p.y < s.points[i - 1].y);
        txt(env, p.text, px(i), py(p.y) + (above ? -96 : 118), { font: FONT.bold, size: 76, color: col === C.dim ? C.ink : C.ink, align: 'center', alpha: a, role: /\d/.test(p.text) ? 'number' : 'label' });
        if (p.sub) txt(env, p.sub, px(i), py(p.y) + (above ? -48 : 164), { font: FONT.reg, size: 40, color: C.muted, align: 'center', alpha: a, role: 'label' });
      }
    });
    if (s.label) { const lx = 960 + (si - (series.length - 1) / 2) * 420, la = fade(t, q[s.points[0].cue], 0.4); ctx.globalAlpha = la; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(lx - 150, 905, 14, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; txt(env, s.label, lx - 120, 918, { font: FONT.med, size: 40, color: col === C.dim ? C.muted : col, alpha: la, role: 'label' }); }
  });
  if (P.source) txt(env, P.source, 200, 985, { font: FONT.med, size: 34, color: C.muted, alpha: fade(t, q[series[0].points[0].cue], 0.5), role: 'source' });
}

// ------------------------------------------------------------ photo2: a licensed photo with captions and side notes
function photo2(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const a = S.assets[P.asset];
  const pp = ease.out3(prog(t, q.photo, 0.5));
  if (pp > 0) {
    const px = 140, py = 150 - 26 * (1 - pp), pw = 1000, ph = 660;
    ctx.save(); ctx.globalAlpha = pp; ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 36; ctx.shadowOffsetY = 10; roundPath(ctx, px, py, pw, ph, 16); ctx.fillStyle = C.bg0; ctx.fill(); ctx.shadowColor = 'transparent';
    roundPath(ctx, px, py, pw, ph, 16); ctx.clip();
    const k = 1 + 0.06 * clamp((t - q.photo) / Math.max(1, S.dur - q.photo)), iw = a.img.width, ih = a.img.height, base = Math.max(pw / iw, ph / ih), dw = iw * base * k, dh = ih * base * k;
    ctx.drawImage(a.img, px + (pw - dw) / 2, py + (ph - dh) / 2, dw, dh); ctx.restore();
    txt(env, P.caption, px, py + ph + 62, { font: FONT.semi, size: 42, alpha: fade(t, q.photo + 0.25, 0.4), role: 'label' });
    if (P.caption2) txt(env, P.caption2, px, py + ph + 112, { font: FONT.reg, size: 38, color: C.muted, alpha: fade(t, q.photo + 0.45, 0.4), role: 'label' });
    const credit = D.wrap(ctx, [P.credit1, P.credit2].filter(Boolean).join(' '), 560, FONT.reg, 34);
    credit.slice(0, 4).forEach((ln, k) => txt(env, ln, 1530, 760 + k * 42, { font: FONT.reg, size: 34, color: C.muted, align: 'center', alpha: fade(t, q.photo + 0.4, 0.4), role: 'source' }));
  }
  (P.notes || []).forEach((n, i) => txt(env, n.text, 1530, 300 + i * 150, { font: FONT.semi, size: 54, color: n.tone ? toneColor(n.tone) : C.ink, align: 'center', alpha: fade(t, q[n.cue], 0.45), role: n.number ? 'number' : 'label' }));
}

// ------------------------------------------------------------ chips: a set of reasons or items; one may be a ghost (searched for, not found)
function chips(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  if (P.title) txt(env, P.title, 960, 200, { font: FONT.semi, size: 56, color: C.muted, align: 'center', alpha: fade(t, q.title != null ? q.title : 0, 0.5), role: 'label' });
  const n = P.items.length, cols = n > 3 ? 2 : 1, rows = Math.ceil(n / cols), w = cols === 2 ? 800 : 1100, h = 110, gx = 60, gy = 40;
  const x0 = (1920 - (cols * w + (cols - 1) * gx)) / 2, y0 = (1080 - (rows * h + (rows - 1) * gy)) / 2 + 30;
  P.items.forEach((it, i) => {
    const c = i % cols, r = Math.floor(i / cols), x = x0 + c * (w + gx), y = y0 + r * (h + gy), a = fade(t, q[it.cue], 0.4);
    if (a <= 0) return;
    const ghost = it.ghost, tone = ghost ? C.lossText : toneColor(it.tone || 'ink');
    const gone = it.gone ? ease.out3(prog(t, q[it.gone], 0.5)) : 0;
    if (gone > 0) ctx.globalAlpha = 1 - 0.65 * gone;
    panel(ctx, x + 30 * (1 - a), y, w, h, { r: 55, fill: ghost ? null : C.panel, stroke: ghost ? C.loss : (it.tone === 'accent' ? C.accent : C.panelEdge), lw: ghost ? 4 : 3, dash: ghost ? [14, 10] : [], alpha: a });
    const size = fitSize(ctx, it.text, FONT.med, 50, w - 90, 38);
    txt(env, it.text, x + 45 + 30 * (1 - a), y + h / 2, { font: FONT.med, size, color: tone, base: 'middle', alpha: a * (1 - 0.65 * gone), role: 'label' });
    if (gone > 0) { ctx.globalAlpha = 1; ctx.strokeStyle = C.lossText; ctx.lineWidth = 7; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x + 40, y + h / 2); ctx.lineTo(x + 40 + (w - 80) * gone, y + h / 2); ctx.stroke(); ctx.lineCap = 'butt'; }
  });
}

// ------------------------------------------------------------ race: lanes with markers; one already ahead, one at the start line
function race(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const X0 = 640, X1 = 1720, n = P.lanes.length, y0 = 540 - ((n - 1) * 230) / 2;
  const la = fade(t, 0, 0.5);
  ctx.globalAlpha = la; ctx.strokeStyle = C.dim; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(X0, 250); ctx.lineTo(X0, 830); ctx.stroke();
  ctx.setLineDash([12, 12]); ctx.beginPath(); ctx.moveTo(X1, 250); ctx.lineTo(X1, 830); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1;
  if (P.track) txt(env, P.track, 960, 200, { font: FONT.semi, size: 56, color: C.muted, align: 'center', alpha: la, role: 'label' });
  P.lanes.forEach((ln, i) => {
    const y = y0 + i * 230, a = fade(t, q[ln.cue], 0.5), pos = ease.inOut(prog(t, q[ln.cue], ln.move || 0.8)) * ln.pos;
    ctx.globalAlpha = a * 0.6; ctx.strokeStyle = C.panelEdge; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(X0, y + 70); ctx.lineTo(X1, y + 70); ctx.stroke(); ctx.globalAlpha = 1;
    D.person(ctx, X0 + pos * (X1 - X0 - 120) + 60, y, 130, toneColor(ln.tone || 'muted'), a);
    txt(env, ln.label, X0 - 40, y + 30, { font: FONT.semi, size: 48, color: toneColor(ln.tone || 'ink'), align: 'right', alpha: a, role: 'label' });
  });
}

// ------------------------------------------------------------ stalls: two price boards that change in turn, a lane that fills
function stalls(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const a0 = fade(t, 0, 0.5);
  ctx.globalAlpha = a0; ctx.fillStyle = C.panel; ctx.fillRect(160, 640, 1600, 150); ctx.globalAlpha = 1;
  P.stalls.forEach((st, i) => {
    const cx = i === 0 ? 560 : 1360;
    ctx.globalAlpha = a0; ctx.fillStyle = C.panelEdge; ctx.fillRect(cx - 190, 420, 380, 220);
    ctx.fillStyle = i === 0 ? C.accent : C.good; ctx.beginPath(); ctx.moveTo(cx - 220, 420); ctx.lineTo(cx + 220, 420); ctx.lineTo(cx + 180, 350); ctx.lineTo(cx - 180, 350); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1;
    let cur = st.boards[0];
    for (const b of st.boards) if (q[b.cue] != null && t >= q[b.cue]) cur = b;
    const ba = fade(t, q[st.boards[0].cue], 0.4), flash = cur !== st.boards[0] ? Math.max(0, 1 - (t - q[cur.cue]) / 0.5) : 0;
    panel(ctx, cx - 150, 450, 300, 130, { r: 14, fill: C.bg0, stroke: flash > 0 ? C.accent : C.dim, lw: 4, alpha: ba });
    txt(env, cur.text, cx, 515, { font: FONT.bold, size: 76, color: C.ink, align: 'center', base: 'middle', alpha: ba, role: 'number' });
    txt(env, st.label, cx, 330, { font: FONT.semi, size: 52, color: C.muted, align: 'center', alpha: ba, role: 'label' });
  });
  if (P.fill) { // the lane fills with faceless figures: many sellers, nobody richer
    const n = P.fill.count || 10, t0 = q[P.fill.cue];
    for (let i = 0; i < n; i++) { const a = ease.out3(prog(t, t0 + i * 0.12, 0.3)); if (a > 0) D.person(ctx, 280 + (i * 1360) / (n - 1), 715, 90, C.dim, a); }
    if (P.fill.text) txt(env, P.fill.text, 960, 905, { font: FONT.semi, size: 54, align: 'center', alpha: fade(t, t0, 0.5), role: 'keyword' });
  }
}

// ------------------------------------------------------------ treadmill: a runner on a belt whose speed follows the cues
function treadmill(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  let v = P.speeds[0].v, dist = 0, last = 0;
  const sp = P.speeds.map((s) => ({ t: q[s.cue], v: s.v })).sort((a, b) => a.t - b.t);
  // integrate speed over time (piecewise, with a short ease between speeds)
  const steps = Math.max(1, Math.floor(t * 60));
  for (let i = 0; i < steps; i++) { const tt = (i / steps) * t; let cur = sp[0].v; for (const s of sp) if (tt >= s.t) cur = s.v; dist += cur / 60 * (t / steps) * 60; v = cur; last = tt; }
  const a = fade(t, 0, 0.5);
  ctx.globalAlpha = a; ctx.fillStyle = C.panelEdge; roundPath(ctx, 380, 700, 1160, 70, 35); ctx.fill();
  ctx.fillStyle = C.dim; for (let i = -1; i < 16; i++) { const x = 380 + ((i * 80 - dist * 60) % 1280 + 1280) % 1280 - 40; if (x > 400 && x < 1500) ctx.fillRect(x, 722, 40, 12); }
  ctx.globalAlpha = 1;
  const bob = Math.sin(dist * 30) * 8 * Math.min(1.6, v);
  D.person(ctx, 960, 560 + bob, 250, C.accent, a);
  const gp = fade(t, q[P.outline.cue], 0.5);
  if (gp > 0) { ctx.globalAlpha = gp; ctx.strokeStyle = C.good; ctx.lineWidth = 6; ctx.setLineDash(v > 1.6 ? [18, 14] : []); roundPath(ctx, 700, 330, 520, 470, 40); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; }
  if (P.label) txt(env, P.label, 960, 900, { font: FONT.semi, size: 56, align: 'center', alpha: fade(t, q[P.labelCue || P.speeds[0].cue], 0.5), role: 'keyword' });
}

// ------------------------------------------------------------ unitgrid: icons that stand for a big count (1 icon = N)
function unitgrid(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const cols = P.cols || 5, rows = Math.ceil(P.count / cols), gap = 130, x0 = 960 - ((cols - 1) / 2) * gap, y0 = 570 - ((rows - 1) / 2) * gap * 0.9;
  if (P.icon === 'cell') { // 100 cells, the first P.lit of them lit: a share, not a count of things
    const s = 54, g = 64, cx0 = 960 - (cols - 1) * g / 2, cy0 = 545 - (Math.ceil(P.count / cols) - 1) * g / 2;
    for (let i = 0; i < P.count; i++) {
      const a = fade(t, q.grid + (i / P.count) * (P.span || 1.5), 0.2), lit = i < P.lit ? ease.out3(prog(t, q.lit + i * 0.12, 0.25)) : 0;
      ctx.globalAlpha = a; ctx.fillStyle = lit > 0 ? C.accent : C.panelEdge; roundPath(ctx, cx0 + (i % cols) * g - s / 2, cy0 + Math.floor(i / cols) * g - s / 2, s, s, 8); ctx.fill(); ctx.globalAlpha = 1;
    }
    if (P.legend) txt(env, P.legend, 960, 880, { font: FONT.med, size: 44, color: C.muted, align: 'center', alpha: fade(t, q.legend != null ? q.legend : q.grid, 0.5), role: 'label' });
    if (P.value) txt(env, P.value, 960, 150, { font: FONT.bold, size: 90, color: C.accent, align: 'center', alpha: fade(t, q.value != null ? q.value : q.lit, 0.5), role: /\d/.test(P.value) ? 'number' : 'keyword' });
    return;
  }
  for (let i = 0; i < P.count; i++) {
    const a = ease.back(prog(t, q.grid + (i / P.count) * (P.span || 2.4), 0.3)); if (a <= 0) continue;
    const x = x0 + (i % cols) * gap, y = y0 + Math.floor(i / cols) * gap * 0.9, s = Math.max(0, a);
    if (P.icon === 'factory') D.factory(ctx, x, y, 128 * s, P.tone === 'muted' ? C.muted : C.ink, { alpha: clamp(a * 2), window: C.bg0 });
    else if (P.icon === 'person') D.person(ctx, x, y, 110 * s, C.muted, clamp(a * 2));
    else { ctx.globalAlpha = clamp(a * 2); ctx.fillStyle = '#b4babf'; ctx.beginPath(); ctx.arc(x, y, 46 * s, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1d1712'; ctx.beginPath(); ctx.arc(x, y, 8 * s, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }
  }
  if (P.legend) txt(env, P.legend, 960, 925, { font: FONT.med, size: 44, color: C.muted, align: 'center', alpha: fade(t, q.legend != null ? q.legend : q.grid, 0.5), role: 'label' });
  if (P.value) txt(env, P.value, 960, 190, { font: FONT.bold, size: 90, color: C.accent, align: 'center', alpha: fade(t, q.value != null ? q.value : q.grid + 1.2, 0.5), role: /\d/.test(P.value) ? 'number' : 'keyword' });
}

// ------------------------------------------------------------ stamps: a row of the same stamp landing one after another
function stamps(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const n = P.count, gap = 300, x0 = 960 - ((n - 1) / 2) * gap;
  for (let i = 0; i < n; i++) {
    const t0 = q.stamps + i * (P.every || 0.45), p = prog(t, t0, 0.18); if (p <= 0) continue;
    const sc = 1.5 - 0.5 * ease.out3(p), x = x0 + i * gap;
    ctx.save(); ctx.translate(x, 520); ctx.rotate(-0.1 + (i % 2) * 0.06); ctx.scale(sc, sc);
    ctx.globalAlpha = ease.out3(p * 2); ctx.strokeStyle = C.good; ctx.lineWidth = 8; roundPath(ctx, -120, -80, 240, 160, 18); ctx.stroke();
    ctx.restore(); ctx.globalAlpha = 1;
    txt(env, P.text, x, 535, { font: FONT.bold, size: 62 * sc, color: C.good, align: 'center', base: 'middle', alpha: ease.out3(p * 2), role: 'label' });
    if (P.labels && P.labels[i]) txt(env, P.labels[i], x, 680, { font: FONT.reg, size: 40, color: C.muted, align: 'center', alpha: ease.out3(p * 2), role: 'label' });
  }
  if (P.title) txt(env, P.title, 960, 250, { font: FONT.semi, size: 58, align: 'center', alpha: fade(t, q.title != null ? q.title : 0, 0.5), role: 'label' });
}

// ------------------------------------------------------------ paths: a fork with two options
function paths(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const a = fade(t, q.from, 0.5);
  panel(ctx, 160, 440, 460, 160, { r: 30, fill: C.panel, stroke: C.panelEdge, lw: 3, alpha: a });
  const fl = D.wrap(ctx, P.from, 400, FONT.semi, 46).slice(0, 2);
  fl.forEach((ln, k) => txt(env, ln, 390, 520 + (k - (fl.length - 1) / 2) * 54, { font: FONT.semi, size: 46, align: 'center', base: 'middle', alpha: a, role: 'label' }));
  P.options.forEach((o, i) => {
    const nOpt = P.options.length, y = nOpt > 2 ? 200 + i * (nOpt === 3 ? 280 : 210) : (i === 0 ? 290 : 750), oa = fade(t, q[o.cue], 0.5), k = ease.inOut(prog(t, q[o.cue], 0.8));
    const col = toneColor(o.tone || (i ? 'accent' : 'muted'));
    if (k > 0) { ctx.strokeStyle = col; ctx.lineWidth = 8; ctx.lineCap = 'round'; ctx.beginPath(); const p0 = [620, 520], p1 = [760, y + 70], p2 = [1000, y + 70]; ctx.moveTo(p0[0], p0[1]); let l = null; for (let s = 1; s <= 30; s++) { l = D.qpt(p0, p1, p2, (s / 30) * k); ctx.lineTo(l.x, l.y); } ctx.stroke(); ctx.lineCap = 'butt'; if (l) D.arrowHead(ctx, l.x, l.y, l.ang, 28, col); }
    panel(ctx, 1020, y, 740, 140, { r: 30, fill: C.panel, stroke: col, lw: o.glow ? 6 : 3, dash: o.dashed ? [14, 10] : [], alpha: oa });
    const ls = D.wrap(ctx, o.text, 640, FONT.semi, 46).slice(0, 2);
    ls.forEach((ln, k) => txt(env, ln, 1390, y + 70 + (k - (ls.length - 1) / 2) * 54, { font: FONT.semi, size: 46, color: col, align: 'center', base: 'middle', alpha: oa, role: 'label' }));
  });
}

// ------------------------------------------------------------ rungs: the pivot ladder with N rungs; ticks, a glowing next rung
function rungs(env, t, P, S) {
  background(env);
  const { ctx } = env, q = S.cues;
  const lx = P.x || 1250, rail = 130, top = 230, bot = 860, n = P.count, ys = Array.from({ length: n }, (_, i) => bot - 70 - (i * (bot - top - 100)) / (n - 1));
  ctx.fillStyle = C.dim; ctx.fillRect(lx - rail - 7, top, 14, bot - top); ctx.fillRect(lx + rail - 7, top, 14, bot - top);
  P.rungs.forEach((r, i) => {
    const y = ys[i], done = r.cue != null ? prog(t, q[r.cue], 0.35) : 0;
    if (r.state === 'filled' && done > 0) { ctx.globalAlpha = ease.out3(done); ctx.fillStyle = C.accent; roundPath(ctx, lx - rail, y - 11, rail * 2, 22, 8); ctx.fill(); ctx.globalAlpha = 1; D.check(ctx, lx + rail + 70, y, 56, C.accent, ease.out3(done), 10); }
    else if (r.state === 'glow') { const g = ease.out3(prog(t, q[r.cue], 0.6)); ctx.globalAlpha = g; ctx.shadowColor = C.accent; ctx.shadowBlur = 30 + 14 * Math.sin(t * 4); ctx.strokeStyle = C.accent; ctx.lineWidth = 5; ctx.strokeRect(lx - rail, y - 11, rail * 2, 22); ctx.shadowBlur = 0; ctx.shadowColor = 'transparent'; ctx.globalAlpha = 1; }
    else if (r.state === 'hollow') { ctx.strokeStyle = C.dim; ctx.lineWidth = 4; ctx.setLineDash([12, 12]); ctx.strokeRect(lx - rail, y - 11, rail * 2, 22); ctx.setLineDash([]); }
    if (r.label && (r.cue == null || done > 0 || r.state === 'glow')) txt(env, r.label, lx - rail - 40, y + 14, { font: FONT.med, size: 42, align: 'right', alpha: r.cue != null ? fade(t, q[r.cue], 0.4) : 1, role: 'label' });
  });
  if (P.title) { const p = prog(t, q[P.titleCue], K.label); if (p > 0) txt(env, P.title, 520, 560, { font: FONT.bold, size: Math.min(fitSize(ctx, P.title, FONT.bold, 150, 640, 70), 150) * (1.14 - 0.14 * ease.out3(p)), color: C.accent, align: 'center', base: 'middle', alpha: ease.out3(p * 3), role: 'number' }); }
}

const EXAMPLES = {
  stat: { props: { value: '1,100', label: 'crore, planned in 2000', sub: 'when the country chased services', source: 'Source: Business Today, 22 Jun 2003', status: 'solid' }, cues: { stat: 0.3, label: 1.0 } },
  bars: { props: { bars: [{ label: 'FY07', text: '110', unit: 'crore', value: 110, cue: 'a' }, { label: 'FY08', text: '79', unit: 'crore loss', value: -79, cue: 'b' }, { label: 'next', text: 'no record', value: 0, ghost: true, cue: 'c' }], source: 'Source: Storage Newsletter' }, cues: { a: 0.3, b: 1.1, c: 1.9 } },
  line: { props: { series: [{ label: 'price', points: [{ x: 'Oct 2002', y: 28, text: '28', sub: 'cents', cue: 'p1' }, { x: 'later', y: 14, text: '14', sub: 'cents', cue: 'p2' }] }], source: 'Source: Taipei Times, 12 Oct 2002' }, cues: { p1: 0.3, p2: 1.5 } },
  chips: { props: { title: 'four reasons given', items: [{ text: 'cheaper imports', cue: 'a' }, { text: 'weak demand', cue: 'b' }, { text: 'pen drives', cue: 'c', tone: 'muted' }, { text: 'price', cue: 'd', tone: 'accent' }, { text: 'a missing customer: not mentioned', cue: 'e', ghost: true }] }, cues: { a: 0.3, b: 0.7, c: 1.1, d: 1.5, e: 2.0 } },
  race: { props: { track: 'CD-R', lanes: [{ label: 'Taiwan', pos: 0.7, cue: 'a', tone: 'muted' }, { label: 'India', pos: 0.0, cue: 'b', tone: 'accent' }] }, cues: { a: 0.2, b: 0.8 } },
  stalls: { props: { stalls: [{ label: 'stall A', boards: [{ text: '10', cue: 'a1' }, { text: '8', cue: 'a2' }, { text: '6', cue: 'a3' }] }, { label: 'stall B', boards: [{ text: '10', cue: 'b1' }, { text: '9', cue: 'b2' }, { text: '7', cue: 'b3' }] }], fill: { count: 10, cue: 'fill', text: 'nobody is richer' } }, cues: { a1: 0.2, b1: 0.2, a2: 1.0, b2: 1.5, a3: 2.0, b3: 2.5, fill: 3.0 } },
  treadmill: { props: { speeds: [{ cue: 's1', v: 1 }, { cue: 's2', v: 2.2 }], outline: { cue: 'o' }, label: 'running faster to stay in place', labelCue: 's1' }, cues: { s1: 0.2, s2: 2.0, o: 2.6 } },
  unitgrid: { props: { icon: 'disc', count: 25, cols: 5, legend: '1 icon = 100 million discs', value: '2.5 billion', span: 2.0 }, cues: { grid: 0.3, legend: 0.5, value: 2.2 } },
  stamps: { props: { count: 5, text: 'WON', title: 'five cases in Europe', labels: ['1', '2', '3', '4', '5'], every: 0.4 }, cues: { stamps: 0.6, title: 0.1 } },
  paths: { props: { from: 'now', options: [{ text: 'stay small', cue: 'a', tone: 'muted' }, { text: 'pivot again', cue: 'b', tone: 'accent', glow: true }] }, cues: { from: 0.1, a: 0.7, b: 1.4 } },
  rungs: { props: { count: 4, rungs: [{ state: 'filled', cue: 'r1', label: 'floppy' }, { state: 'filled', cue: 'r2', label: 'CD-R' }, { state: 'glow', cue: 'r3', label: 'next' }, { state: 'hollow' }], title: 'PIVOT 3', titleCue: 't' }, cues: { r1: 0.2, r2: 0.8, r3: 1.4, t: 1.4 } },
};
// photo2 needs an image asset, so it has no self-test example here

module.exports = { stat, bars, line, photo2, chips, race, stalls, treadmill, unitgrid, stamps, paths, rungs, EXAMPLES };

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.