// s04 Experiment 1. Two stacked panes (1080 x 460 each) with the labels in the band between them.
// Top: hero3d "default", the generic AI look on purpose (navy space, halo ring, particles, spin),
// labelled "3 adjectives". As the narrator lists the defaults, each gets a red callout pinned to it,
// 250 ms after its word. Bottom: hero3d "directed", labelled "450 words". Hero move: on "direction"
// the bottom mark hops (touchdown f36, settled at HOP.settle) and the top pane dims to 40%.
import * as THREE from "/_vendor/three/build/three.module.js";
import { createHero, HOP } from "../hero3d.js";
export default async function build(root, k) {
const { make, place, show, swap, style, cueIn, easeOut, p01, spring01, check, sfx, box, overlap, C } = k;
const { cutIn, cutOut } = k.win;
const FPS = 60, PW = 1080, PH = 460, BAND = 72, BOTTOM_Y = PH + BAND;
const word = (phrase) => cueIn(k.id, phrase, 1, 0); // the word's own start
const tSame = [cueIn(k.id, "same model"), cueIn(k.id, "same earbuds"), cueIn(k.id, "same six second loop")];
const tRunA = cueIn(k.id, "one run");
const tRunB = cueIn(k.id, "other run");
const PIN_LAG = 0.25; // each callout 250 ms after its word
const tPins = [word("navy space"), word("a glowing ring"), word("particles"), word("a spin")].map((t) => t + PIN_LAG);
// The touchdown (the picture hit, f36) lands on "direction"; the jump itself rides "words of".
const tHop = word("direction") - HOP.contact / FPS;
const touchdown = tHop + HOP.contact / FPS;
const settled = tHop + HOP.settle / FPS;
// ---- the two panes ----
const topWrap = make("div", "", "", root, { position: "absolute", left: "0px", top: "0px", width: `${PW}px`, height: `${PH}px`, overflow: "hidden" });
const topCanvas = make("canvas", "", "", topWrap, { position: "absolute", left: "0px", top: "0px" });
const top = createHero(topCanvas, { variant: "default", width: PW, height: PH, seed: 7 });
const callouts = make("div", "", "", topWrap, { position: "absolute", inset: "0px" });
const bottomCanvas = make("canvas", "", "", root, { position: "absolute", left: "0px", top: `${BOTTOM_Y}px` });
const bottom = createHero(bottomCanvas, { variant: "directed", width: PW, height: PH, seed: 7, framing: { fill: 0.413 } });
const hopState = { hop: tHop };
// ---- the band between them ----
const bandY = PH + (BAND - 54) / 2;
const same = place(make("div", "", "", root, { fontSize: "30px", fontWeight: "560", whiteSpace: "nowrap", height: "54px", display: "flex", alignItems: "center" }), 40, bandY);
const sameParts = ["same model", " · same subject", " · same 6-second loop"].map((txt, i) => {
const s = make("span", "", "", same, { whiteSpace: "pre" });
const [dot, rest] = i ? [" · ", txt.slice(3)] : ["", txt];
if (dot) make("span", "", dot, s, { color: C.faint });
make("span", "", rest, s);
return s;
});
const chevron = (dir, color) => ``;
const pill = (html, css) => make("div", "", html, root, {
position: "absolute", top: `${bandY}px`, height: "54px", display: "flex", alignItems: "center", gap: "12px",
padding: "0 24px 0 18px", borderRadius: "27px", fontSize: "30px", fontWeight: "620", whiteSpace: "nowrap", ...css,
});
const labelA = pill(`${chevron("up", "#e9e7f5")}3 adjectives`, { left: "40px", background: C.night, color: "#e9e7f5" });
const labelB = pill(`450 words${chevron("down", C.ink)}`, { right: "40px", background: C.paper, color: C.ink, padding: "0 18px 0 24px", boxShadow: "0 1px 0 #d8d6d0 inset, 0 8px 24px rgba(28,28,26,0.08)" });
// ---- callouts on the top pane: a target dot, a leader and a red chip ----
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", PW); svg.setAttribute("height", PH);
Object.assign(svg.style, { position: "absolute", left: "0px", top: "0px", overflow: "visible" });
callouts.appendChild(svg);
const PINK = "#fde8eb";
// [text, chip x, chip y, target x, target y, leader starts at the chip's ...]
const PINS = [
["navy space", 40, 34, 232, 150],
["glowing ring", 40, 372, 362, 300],
["particles", 828, 34, 900, 200],
["spin", 828, 372, 548, 300], // on the spin axis: the slab covers it at every angle
];
const pins = PINS.map(([txt, cx, cy, tx, ty]) => {
const chip = place(make("div", "chip red", txt, callouts, { fontSize: "30px", padding: "10px 22px" }), cx, cy);
const line = document.createElementNS(svg.namespaceURI, "line");
Object.entries({ x1: tx, y1: ty, stroke: PINK, "stroke-width": 2.5, "stroke-linecap": "round" }).forEach(([a, v]) => line.setAttribute(a, v));
const dot = document.createElementNS(svg.namespaceURI, "circle");
Object.entries({ cx: tx, cy: ty, r: 9, fill: C.red, stroke: PINK, "stroke-width": 2.5 }).forEach(([a, v]) => dot.setAttribute(a, v));
svg.append(line, dot);
return { chip, line, dot, tx, ty };
});
// An invisible box that follows the bottom mark: the speed graph samples it, checks use it.
const tracker = make("div", "", "", root, { position: "absolute", pointerEvents: "none" });
tracker.dataset.track = "s04-mark";
const v = new THREE.Vector3();
sfx(touchdown + 1 / FPS, "tap"); // the landing, one frame after its picture
// Leader endpoints need the chips' laid-out sizes: measure once fonts are ready.
await document.fonts.ready;
for (const p of pins) {
const w = p.chip.offsetWidth, h = p.chip.offsetHeight, x = p.chip.offsetLeft, y = p.chip.offsetTop;
// From the chip edge nearest the target.
p.x2 = Math.max(x + 18, Math.min(x + w - 18, p.tx));
p.y2 = p.ty < y ? y : y + h;
if (p.tx > x + w) { p.x2 = x + w; p.y2 = y + h / 2; }
if (p.tx < x) { p.x2 = x; p.y2 = y + h / 2; }
p.len = Math.hypot(p.x2 - p.tx, p.y2 - p.ty);
}
// ---- checks ----
const DIM = 0.6; // the top pane ends at 40% brightness
const dimAt = (t) => easeOut(p01(t, tHop, 0.4));
check("s04 callouts start 250 ms after their words", cutIn, () =>
["navy space", "a glowing ring", "particles", "a spin"].every((w, i) => Math.abs(tPins[i] - word(w) - 0.25) < 1e-9) || "lag");
check("s04 mark settled by hop + f" + HOP.settle, settled, () => {
const p = bottom.pose(settled, hopState);
return (Math.abs(p.sy - 1) < 0.003 && Math.abs(p.tilt) < 0.15) || `sy ${p.sy.toFixed(4)} tilt ${p.tilt.toFixed(2)}`;
});
check("s04 top pane at 40% once the hop starts", [tHop + 0.4, cutOut - 1 / FPS], () => {
const m = /brightness\(([\d.]+)\)/.exec(callouts.style.filter);
return (m && Math.abs(Number(m[1]) - 0.4) < 0.005) || `filter ${callouts.style.filter}`;
});
check("s04 top pane at full brightness before the hop", [cutIn, tHop - 1 / FPS], () => callouts.style.filter === "none" || callouts.style.filter);
check("s04 callouts clear each other and stay in the top pane", [tPins[3] + 0.6, cutOut - 1 / FPS], () => {
const chips = pins.map((p) => p.chip);
for (let i = 0; i < chips.length; i++) {
const b = box(chips[i]);
if (b.x < 0 || b.x + b.w > PW || b.y < 128 || b.y + b.h > 128 + PH) return `chip ${i} outside the pane`;
for (let j = i + 1; j < chips.length; j++) if (overlap(chips[i], chips[j]) > 0) return `chips ${i} and ${j} overlap`;
}
return true;
});
check("s04 band labels clear each other and stay in the band", [tRunB + 0.6, cutOut - 1 / FPS], () => {
const a = box(labelA), b = box(labelB);
return (overlap(labelA, labelB) === 0 && a.y >= 128 + PH && b.y + b.h <= 128 + BOTTOM_Y) || "labels";
});
check("s04 touchdown lands on “direction” and settles before the cut", cutOut - 1 / FPS, () =>
(Math.abs(touchdown - word("direction")) < 1 / FPS && cutOut - settled >= 0.5) || `touchdown ${touchdown.toFixed(3)} settled ${settled.toFixed(3)} cut ${cutOut}`);
check("s04 mark stays inside the bottom pane", [tHop, settled], () => {
const b = box(tracker);
return (b.y >= 128 + BOTTOM_Y && b.y + b.h <= 128 + BOTTOM_Y + PH) || `mark y ${b.y.toFixed(0)}–${(b.y + b.h).toFixed(0)}`;
});
return (t) => {
const d = dimAt(t);
top.render(t, { dim: (DIM * d) / 0.62 }); // hero3d darkens by 0.62 x dim
callouts.style.filter = d > 0 ? `brightness(${(1 - DIM * d).toFixed(4)})` : "none";
bottom.render(t, hopState);
// Tracker: the bottom mark's on-screen box.
const p = bottom.pose(t, hopState);
v.set(p.x, p.y + 0.5 * p.sy, 0).project(bottom.camera);
const x = (v.x * 0.5 + 0.5) * PW, y = BOTTOM_Y + (0.5 - v.y * 0.5) * PH;
const hpx = 190 * p.sy, wpx = 180 * p.sxz;
Object.assign(tracker.style, { left: `${(x - wpx / 2).toFixed(2)}px`, top: `${(y - hpx / 2).toFixed(2)}px`, width: `${wpx.toFixed(2)}px`, height: `${hpx.toFixed(2)}px` });
// Band: "same model · same subject · same 6-second loop" builds on its three cues, then gives
// way to the two labels, each on its "run".
const out = swap(t, tRunA, { exit: 0.2, gap: 0.06, enter: 0.4 });
sameParts.forEach((s, i) => show(s, t, tSame[i], Infinity, { dur: 0.4, dy: 0, dx: -12 }));
style(same, out.out);
same.style.visibility = out.out.opacity < 0.001 ? "hidden" : "visible";
show(labelA, t, tRunA + 0.26, Infinity, { dur: 0.5, dy: -10 });
labelA.style.opacity = (Number(labelA.style.opacity) * (1 - 0.5 * d)).toFixed(4); // dims with its pane
show(labelB, t, tRunB, Infinity, { dur: 0.5, dy: 10 });
// Callouts: the dot pops (ζ 0.82), the leader draws from it, then the chip arrives.
for (const [i, pn] of pins.entries()) {
const t0 = tPins[i];
const s = spring01(t, t0, { duration: 0.4, damping: 0.82 });
pn.dot.setAttribute("r", (9 * s).toFixed(3));
pn.dot.style.opacity = t > t0 ? "1" : "0";
const q = easeOut(p01(t, t0 + 0.06, 0.3));
pn.line.setAttribute("x2", (pn.tx + (pn.x2 - pn.tx) * q).toFixed(2));
pn.line.setAttribute("y2", (pn.ty + (pn.y2 - pn.ty) * q).toFixed(2));
pn.line.style.opacity = q > 0 ? "1" : "0";
show(pn.chip, t, t0 + 0.12, Infinity, { dur: 0.4, dy: 0, scale: 0.94 });
}
};
}