// The bottom deck's composition: one HyperFrames page for a whole report cut. // // PURE, like deck.mjs: a schedule and a render block in, an HTML string out. // compose-chrome.mjs copies the assets in beside it, writes it and renders it; // nothing here touches a file, so a test can read the page it would draw. // // --------------------------------------------------------------------------- // Why the timeline is a list of cues computed here, not tweens written there // --------------------------------------------------------------------------- // A HyperFrames render is a SEEK per frame, from parallel workers, in any // order. A GSAP `set` or `to` remembers the value it found the first time it // rendered -- which, on a seek, is whatever the last seek left behind. Every // cue below is therefore a fromTo whose FROM is stated, and the from of each // is the to of the cue before it on the same element: `deckCues` walks the // cues in time order and carries every property's value forward. The page's // script is a dumb interpreter of that list. So the browser holds no logic a // test cannot see, and the times in it are deckChoreography's, untouched -- // there is no second copy of the timing to drift. // // --------------------------------------------------------------------------- // Why the wipe is two translates and not a clip-path // --------------------------------------------------------------------------- // A wipe is a window sliding across text that stays put. The clip box (an // inline-block, so it is exactly as wide as its fitted title) moves by // xPercent and its child moves by the opposite xPercent: the two cancel for // the glyphs and not for the window. xPercent is relative to the element's OWN // width, so it follows the load-time text fit with nothing measured at tween // time, and it is a transform -- on the render's animatable allowlist, which // clip-path is not. // // --------------------------------------------------------------------------- // The progress fill is a fuse // --------------------------------------------------------------------------- // While clip k plays the accent fill burns from pip k toward pip k+1, reaching // it as the marker sets off. The marker then travels over track that is // already lit, and the fill states "how far through this clip" without a // second clock or a label. import { fileURLToPath } from "node:url"; import { deckChoreography, deckLayout, pageDuration, pipSegments, pipXs, resolveDeck } from "./deck.mjs"; import { resolveFactcheck, tallyCues, tallyKey, tallyOf } from "./factcheck.mjs"; /** * GSAP, vendored, for every chrome region. * * It used to come off a CDN at render time. A deck render is ten thousand * frames; one DNS failure in the middle of it is blank frames and no error * anybody would recognise. The file is in the repo at the version the chart * band was written against, and compose-chrome copies it in beside each page. * * Declared here, not in compose-chrome, and as `new URL(…, import.meta.url)`: * a bundler resolves that form to the one file, and a name derived from * import.meta in the module that does the fs work would mark every path that * module joins (scripts/next-build-trace.test.mjs). */ export const GSAP_FILE = fileURLToPath(new URL("./assets/gsap.min.js", import.meta.url)); /** Instant cues still take a millisecond: a zero-duration tween has its own seek rules. */ const INSTANT = 0.001; const esc = (s) => String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); const r4 = (v) => Math.round(v * 10000) / 10000; /** The tracking of the QR's host label, in em: part of the length `fitHost` fits to the code. */ export const HOST_TRACKING = 0.1; /** The host a QR resolves to -- the one thing on the tile a viewer cannot read off the code. */ export function hostOf(url) { try { return new URL(url).host.replace(/^www\./, ""); } catch { return ""; } } // --------------------------------------------------------------------------- // Colour. The palette is five hexes; the panel's lift is arithmetic on them. // --------------------------------------------------------------------------- function rgbOf(hex) { const h = String(hex).replace("#", ""); const full = h.length === 3 ? [...h].map((c) => c + c).join("") : h.slice(0, 6); const n = Number.parseInt(full, 16); if (!Number.isFinite(n)) throw new Error(`deck: palette colour ${hex} is not a hex colour`); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** `a` moved toward `b` by `t` (0..1), as a hex. */ export function mix(a, b, t) { const x = rgbOf(a), y = rgbOf(b); return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("")}`; } /** A palette colour at an alpha, as rgba(). */ export function rgba(hex, a) { const [r, g, b] = rgbOf(hex); return `rgba(${r}, ${g}, ${b}, ${a})`; } // --------------------------------------------------------------------------- // The cue list. // --------------------------------------------------------------------------- /** * Segment i's two text states. "before" is wiped to the LEFT of its window and * not yet visible; "shown" is at rest; "after" is wiped off to the right. * The QR is edge-on at ±90° on either side, so the flip turns one way. */ const SEG_STATE = { before: (lift) => ({ seg: { autoAlpha: 0 }, tc: { xPercent: -100 }, ti: { xPercent: 100, y: lift }, blade: { opacity: 0 }, sc: { xPercent: -100 }, si: { xPercent: 100 }, rule: { scaleX: 0, transformOrigin: "0% 50%" }, qr: { rotationY: -90 }, }), shown: () => ({ seg: { autoAlpha: 1 }, tc: { xPercent: 0 }, ti: { xPercent: 0, y: 0 }, blade: { opacity: 0 }, sc: { xPercent: 0 }, si: { xPercent: 0 }, rule: { scaleX: 1, transformOrigin: "0% 50%" }, qr: { rotationY: 0 }, }), }; const PARTS = ["tc", "ti", "blade", "sc", "si", "rule", "qr"]; const keyOf = (i, part) => (part === "seg" ? `s${i}` : `s${i}.${part}`); /** * Everything the deck's timeline does, as data. * * @returns {{ init: Record, cues: Array<{ k: string, at: number, * dur: number, from: object, to: object, ease: string, why: string }>, * pips: Array<{ id: string, x: number }>, choreography: object }} * * `init` is each element's state at t = 0 (applied once at load); `cues` are in * time order and each states its own from. `why` names the choreography entry * a cue came from -- the tests match on it, and a reader of the page can too. */ export function deckCues(schedule, render) { const deck = resolveDeck(render); const tally = tallyOf(schedule, render); const lay = deckLayout(render, { tally }); const segs = schedule.segments; const ch = deckChoreography(schedule, render); const pipped = pipSegments(schedule); const xs = pipXs( pipped.length, lay.pipTrack.x0, lay.pipTrack.x1, deck.pip.spacing, deck.pip.spacing === "time" ? { total: schedule.total, segments: pipped } : null, ); const pips = pipped.map((s, k) => ({ id: s.id, x: r4(xs[k]) })); const pipOf = new Map(pipped.map((s, k) => [s.id, k])); const fuseX0 = xs[0] ?? 0; const fuseW = xs.length > 1 ? xs[xs.length - 1] - xs[0] : 0; const frac = (k) => (fuseW > 0 ? r4((xs[k] - fuseX0) / fuseW) : 0); const lift = Math.round(lay.text.titleSize * 0.16); const slideY = lay.height + 12; // ---- t = 0 ------------------------------------------------------------- const init = {}; const put = (k, v) => { init[k] = { ...(init[k] ?? {}), ...v }; }; const startsHidden = !!segs[0]?.hideDeck; put("panel", { y: startsHidden ? slideY : 0 }); put("marker", { x: r4(xs[0] ?? lay.pipTrack.x0) }); if (fuseW > 0) put("fuse", { scaleX: 0, transformOrigin: "0% 50%" }); pips.forEach((_, k) => put(`pip${k}`, { opacity: 0 })); segs.forEach((s, i) => { const st = i === 0 && !s.hideDeck ? SEG_STATE.shown() : SEG_STATE.before(lift); put(keyOf(i, "seg"), st.seg); for (const p of PARTS) put(keyOf(i, p), st[p]); }); // ---- events, unordered; froms are filled in below ---------------------- const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: r4(at), dur: r4(Math.max(INSTANT, dur)), to, ease, why }); const setSeg = (i, state, at, why) => { add(keyOf(i, "seg"), at, INSTANT, state.seg, "none", why); for (const p of PARTS) add(keyOf(i, p), at, INSTANT, state[p], "none", why); }; const idx = new Map(segs.map((s, i) => [s.id, i])); const filled = new Set(); const fillPipsBefore = (k, at, dur, why) => { for (let j = 0; j < k; j += 1) { if (filled.has(j)) continue; filled.add(j); add(`pip${j}`, at, dur, { opacity: 1 }, "power1.out", why); } }; // When the marker ARRIVES at each pip, and when it LEAVES: the fuse burns between. const arrive = new Map(); const depart = new Map(); if (pips.length && !startsHidden) arrive.set(0, 0); for (const h of ch.handovers) { const a = idx.get(h.from), b = idx.get(h.to); const why = `handover ${h.from}->${h.to}`; // Out. A segment shorter than in + out would start leaving before it had // arrived; the clamp below (per element) keeps the two from overlapping. add(keyOf(a, "tc"), h.out[0], h.out[1] - h.out[0], { xPercent: 100 }, "power2.in", `${why} out`); add(keyOf(a, "ti"), h.out[0], h.out[1] - h.out[0], { xPercent: -100 }, "power2.in", `${why} out`); add(keyOf(a, "sc"), h.out[0], h.out[1] - h.out[0], { xPercent: 100 }, "power2.in", `${why} out`); add(keyOf(a, "si"), h.out[0], h.out[1] - h.out[0], { xPercent: -100 }, "power2.in", `${why} out`); add(keyOf(a, "rule"), h.out[0], h.out[1] - h.out[0], { scaleX: 0, transformOrigin: "100% 50%" }, "power2.in", `${why} out`); add(keyOf(a, "qr"), h.qr[0], h.m - h.qr[0], { rotationY: 90 }, "power1.in", `${why} qr`); add(keyOf(a, "seg"), h.m, INSTANT, { autoAlpha: 0 }, "none", `${why} out`); // In. add(keyOf(b, "seg"), h.m, INSTANT, { autoAlpha: 1 }, "none", `${why} in`); add(keyOf(b, "tc"), h.in[0], h.in[1] - h.in[0], { xPercent: 0 }, "power3.out", `${why} in`); add(keyOf(b, "ti"), h.in[0], h.in[1] - h.in[0], { xPercent: 0, y: 0 }, "power3.out", `${why} in`); // The blade rides the reveal's leading edge and is gone as the title settles. add(keyOf(b, "blade"), h.in[0], INSTANT, { opacity: 1 }, "none", `${why} in`); add(keyOf(b, "blade"), h.in[0] + (h.in[1] - h.in[0]) * 0.35, (h.in[1] - h.in[0]) * 0.65, { opacity: 0 }, "power1.in", `${why} in`); add(keyOf(b, "sc"), h.sub[0], h.sub[1] - h.sub[0], { xPercent: 0 }, "power3.out", `${why} sub`); add(keyOf(b, "si"), h.sub[0], h.sub[1] - h.sub[0], { xPercent: 0 }, "power3.out", `${why} sub`); add(keyOf(b, "rule"), h.in[0], h.in[1] - h.in[0], { scaleX: 1, transformOrigin: "0% 50%" }, "power3.out", `${why} in`); add(keyOf(b, "qr"), h.m, h.qr[1] - h.m, { rotationY: 0 }, "power1.out", `${why} qr`); // The pip. const pa = pipOf.get(h.from), pb = pipOf.get(h.to); add("marker", h.pip[0], h.pip[1] - h.pip[0], { x: pips[pb].x }, "power3.inOut", `${why} pip`); fillPipsBefore(pb, h.pip[0], Math.min(0.25, h.pip[1] - h.pip[0]), `${why} pip`); depart.set(pa, h.pip[0]); arrive.set(pb, h.pip[1]); } for (const v of ch.visibility) { if (v.at[1] - v.at[0] <= 0 && v.i === 0) continue; // hidden from the first frame: that is init const why = `${v.hide ? "hide" : "show"} @${segs[v.i].id}`; add("panel", v.at[0], v.at[1] - v.at[0], { y: v.hide ? slideY : 0 }, v.hide ? "power2.in" : "power3.out", why); if (v.hide) { // The text on the way down stays until it is off screen, then is gone // for good -- nothing is handed over behind a card. const prev = segs.slice(0, v.i).map((s, j) => j).filter((j) => !segs[j].hideDeck).pop(); if (prev !== undefined) { add(keyOf(prev, "seg"), v.at[1], INSTANT, { autoAlpha: 0 }, "none", why); const pk = pipOf.get(segs[prev].id); if (!depart.has(pk)) depart.set(pk, v.at[0]); } } else { // Whatever came before is already set when the deck rises: the new text // is in place, the marker is on its pip and every pip before it is lit. setSeg(v.i, SEG_STATE.shown(), v.at[0], why); const k = pipOf.get(segs[v.i].id); add("marker", v.at[0], INSTANT, { x: pips[k].x }, "none", why); fillPipsBefore(k, v.at[0], INSTANT, why); arrive.set(k, v.at[0]); } } // The fact-check tally (factcheck.mjs): its cells' counts step as each // stamp lands. Part of the panel, so it slides away and back with it. if (tally) { const t = tallyCues(schedule.factcheck.stamps, tally); for (const [k, v] of Object.entries(t.init)) put(k, v); for (const e of t.events) add(e.k, e.at, e.dur, e.to, e.ease, e.why); } // The fuse: from pip k to pip k+1 over the time the marker rests on k. if (fuseW > 0) { for (let k = 0; k < pips.length - 1; k += 1) { const a = arrive.get(k), b = depart.get(k); if (a === undefined || b === undefined) continue; add("fuse", a, b - a, { scaleX: frac(k + 1), transformOrigin: "0% 50%" }, "none", `fuse ${pips[k].id}`); } } // ---- order, clamp, and state the froms --------------------------------- ev.forEach((e, n) => { e.n = n; }); ev.sort((x, y) => x.at - y.at || x.n - y.n); const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); const freeAt = new Map(); const cues = []; for (const e of ev) { const free = freeAt.get(e.k) ?? 0; let { at, dur } = e; if (at < free) { const end = at + dur; at = r4(free); dur = r4(Math.max(INSTANT, end - at)); } const cur = state[e.k] ?? (state[e.k] = {}); const from = {}; for (const p of Object.keys(e.to)) from[p] = cur[p]; Object.assign(cur, e.to); freeAt.set(e.k, r4(at + dur)); cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why }); } return { init, cues, pips, slideY, choreography: ch }; } // --------------------------------------------------------------------------- // The page. // --------------------------------------------------------------------------- /** The subtitle's markup: its " · " separators become accent dots. */ export function subtitleMarkup(text) { return String(text ?? "") .split(" · ") .map((p) => `${esc(p)}`) .join('·'); } /** * The deck composition's HTML. * * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold), * `qrSrcs` = `{ [segmentId]: "assets/qrNN.png" }`, `gsap` = the vendored * script's asset path. `from`/`duration` render a window of the cut: the root * declares `duration` seconds and the timeline plays the cut's [from, * from + duration] -- the page is otherwise identical. */ export function deckHtml(schedule, render, opts = {}) { const deck = resolveDeck(render); const tally = tallyOf(schedule, render); const lay = deckLayout(render, { tally }); const pal = render.palette; const W = lay.width, H = lay.height; const fonts = opts.fonts ?? {}; const qrSrcs = opts.qrSrcs ?? {}; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const from = Number(opts.from ?? 0); const total = schedule.total; const dur = opts.duration != null ? Number(opts.duration) : r4(total - from); if (!(dur > 0)) throw new Error(`deck: nothing to render from ${from}s of a ${total}s cut`); const windowed = from > 0 || Math.abs(dur - total) > 1e-6; const { init, cues, pips } = deckCues(schedule, render); const t = lay.text; const tr = lay.pipTrack; const qr = lay.qr; const pip = deck.pip; const panel = deck.background === "panel"; // The lift: a few percent toward the foreground, so the deck reads as a // surface standing on the frame's ground rather than a hole cut in it. const top = panel ? mix(pal.bg, pal.fg, 0.095) : pal.bg; const bottom = panel ? mix(pal.bg, pal.fg, 0.04) : pal.bg; // The untitled arrangement, centred on the same block. const uSubSize = Math.round(t.subtitleSize * 1.35); const uSubBox = Math.round(uSubSize * 1.3); const uSubY = Math.round(t.titleY + (t.subtitleY + t.subtitleBox - t.titleY - uSubBox - 14) / 2); const uRuleY = uSubY + uSubBox + 6; const plateX = qr ? qr.x - 46 : W; const ink = panel ? mix(pal.bg, pal.fg, 0.085) : pal.bg; // what the marker's ring cuts back to const segHtml = schedule.segments .map((s, i) => { const src = qrSrcs[s.id]; return ( `
` + `
` + `
${esc(s.title)}
` + `
` + `
` + `
` + `
${subtitleMarkup(s.subtitle)}
` + (qr ? `
` + (src ? `` : "") + `
` + (src && hostOf(s.qrUrl) ? `
${esc(hostOf(s.qrUrl))}
` : "") : `
`) + `
` ); }) .join("\n "); const pipHtml = pips .map( (p, k) => `
` + `
`, ) .join(""); // The tally: one cell per verdict the cut stamps, each with every count it // will show stacked in one box (`n0` … `n`), its label under it. const tb = lay.tally ?? null; const verdictSet = tb ? resolveFactcheck(render).verdicts : null; const countSize = tb ? Math.round(tb.height * 0.42) : 0; const countBox = Math.round(countSize * 1.12); const labelSize = 13; const labelBox = 17; const cellTop = tb ? Math.round((tb.height - (countBox + 8 + labelBox)) / 2) + 2 : 0; const tallyHtml = tb ? `
` + tally .map((v, j) => { const finals = schedule.factcheck.stamps.filter((s) => s.verdict === v).length; const color = verdictSet[v].color; const nums = Array.from({ length: finals + 1 }, (_, k) => `
${k}
`).join(""); return ( `
` + `
${nums}
` + `
${esc(verdictSet[v].label)}
` + `
` ); }) .join("") + `
` : ""; const fuseLeft = pips[0]?.x ?? tr.x0; const fuseWidth = pips.length > 1 ? pips[pips.length - 1].x - fuseLeft : 0; const data = { total, window: windowed ? { from, dur } : null, titleSize: t.titleSize, floor: Math.ceil(t.titleSize * 0.6), // The QR's host label is fitted to run the code's full height. hostLength: qr?.size ?? 0, hostTracking: HOST_TRACKING, ids: schedule.segments.map((s) => s.id), init, cues: cues.map(({ why, ...c }) => c), }; // `` inside a JSON string would close the tag; a title may say anything. const json = JSON.stringify(data).replace(/
${panel && qr ? `
` : ""} ${panel ? `
` : ""}
${fuseWidth > 0 ? `
` : ""} ${pipHtml}
${tallyHtml} ${segHtml}
`; }