// s11 Easing and springs. Every number on screen is the one the motion runs on. // Top card, Easing: three curves on one time axis (0–0.8 s), each drawn by the ball it moves. // enter: ease-out over 600 ms. exit: ease-in over 330 ms, 45% shorter (the brief's 40–50%). // move across: ease-in-out over 800 ms. // Middle card, Springs (hero): on "ratio" three blocks launch on the same frame on kit.spring01 with // damping 1.0, 0.8 and 0.5; each traces its own curve, and the overshoot is measured from it. // Bottom card: "Position can bounce" (a block on a ζ 0.5 spring, ✓), then "Opacity never does": // the same spring on opacity would reach 1.16, is clamped at 1.0 and is marked ✕. const NS = "http://www.w3.org/2000/svg"; export default function build(root, k) { const { make, place, show, cueIn, spring01, plot, easeOut, easeIn, easeInOut, p01, clamp, check, box, overlap, sfx, C } = k; const { cutIn, cutOut } = k.win; const FPS = 60; const F = (t) => Math.round(t * FPS) / FPS; // snap a launch to a frame // ---- cues ---- const tEnter = cueIn(k.id, "entrances"); const tExit = cueIn(k.id, "exits"); const tExitGo = F(cueIn(k.id, "in")); // "Exits ease in": the ball leaves on "in" const tShorter = cueIn(k.id, "forty to fifty"); const tMove = cueIn(k.id, "shorter"); const tSpring = cueIn(k.id, "damping ratio"); const tLaunch = F(cueIn(k.id, "ratio")); const tFeel = [cueIn(k.id, "one has"), cueIn(k.id, "point eight"), cueIn(k.id, "point five")]; const tPos = cueIn(k.id, "position"); const tOpa = cueIn(k.id, "opacity"); // ---- small builders ---- const MONO = '"Geist Mono", ui-monospace, monospace'; const txt = (parent, html, x, y, css = {}) => place(make("div", "", html, parent, { fontSize: "30px", fontWeight: "620", whiteSpace: "nowrap", lineHeight: "1.2", ...css }), x, y); const mono = (parent, html, x, y, css = {}) => txt(parent, html, x, y, { fontFamily: MONO, fontSize: "28px", fontWeight: "500", ...css }); const card = (x, y, w, h) => place(make("div", "card", "", root), x, y, w, h); const svgEl = (tag, attrs, parent) => { const e = document.createElementNS(NS, tag); for (const [a, v] of Object.entries(attrs)) e.setAttribute(a, v); parent.appendChild(e); return e; }; const trace = (P, fn, xa, xb, n = 90) => { if (xb <= xa) return ""; let d = ""; for (let i = 0; i <= n; i++) { const x = xa + ((xb - xa) * i) / n; d += `${i ? "L" : "M"}${P.sx(x).toFixed(2)} ${P.sy(fn(x)).toFixed(2)}`; } return d; }; // Icons: 2.5 px strokes, drawn, never glyphs. const icon = (parent, kind, color, size = 34) => { const s = svgEl("svg", { width: size, height: size, viewBox: "0 0 34 34" }, parent); Object.assign(s.style, { overflow: "visible", flex: "none" }); svgEl("circle", { cx: 17, cy: 17, r: 15.5, fill: "none", stroke: color, "stroke-width": 2.5 }, s); svgEl("path", { d: kind === "check" ? "M10 17.5 L15 22.5 L24.5 12" : "M11.5 11.5 L22.5 22.5 M22.5 11.5 L11.5 22.5", fill: "none", stroke: color, "stroke-width": 2.5, "stroke-linecap": "round", "stroke-linejoin": "round" }, s); return s; }; const COLW = 312, colX = (i) => 28 + i * (COLW + 24); const lines = []; // every text line, for the overlap and size checks // =========================== Easing =========================== const ENTER = 0.6, EXIT = 0.33, MOVE = 0.8; const SHORTER = 1 - EXIT / ENTER; // 0.45 const E = card(24, 8, 1032, 392); lines.push(txt(E, "Easing", 28, 20, { fontSize: "32px", fontWeight: "680" })); const ease = [ { role: "enter", curve: "ease-out", dur: ENTER, fn: easeOut, from: -40, to: 196, t0: tEnter, label: tEnter + 0.15 }, { role: "exit", curve: "ease-in", dur: EXIT, fn: easeIn, from: 64, to: 344, t0: tExitGo, label: tExit }, { role: "move across", curve: "ease-in-out", dur: MOVE, fn: easeInOut, from: 0, to: 280, t0: tMove, label: tMove + 0.15 }, ].map((c, i) => { const x = colX(i); const P = plot(E, { x, y: 72, w: COLW, h: 124, x0: 0, x1: 0.8, y0: 0, y1: 1 }); const path = svgEl("path", { class: "curve" }, P.svg); const pen = svgEl("circle", { r: 8, fill: C.accent }, P.svg); const track = place(make("div", "", "", E, { overflow: "hidden" }), x, 208, COLW, 40); make("div", "", "", track, { position: "absolute", left: "0px", right: "0px", top: "19px", height: "2px", background: C.line }); const ball = make("div", "", "", track, { position: "absolute", left: "0px", top: "4px", width: "32px", height: "32px", borderRadius: "16px", background: C.ink }); const l1 = txt(E, c.role, x, 262); const l2 = mono(E, c.curve, x, 302, { color: C.muted }); const l3 = mono(E, `${Math.round(c.dur * 1000)} ms`, x, 340); lines.push(l1, l2, l3); return { ...c, P, path, pen, track, ball, l1, l2, l3 }; }); const ballX = (c, t) => c.from + (c.to - c.from) * c.fn(p01(t, c.t0, c.dur)); // The comparison, on "forty to fifty": the entrance's curve as a ghost on the exit's axes, and the // part of the time axis the exit saves. const ex = ease[1]; const ghost = svgEl("path", { class: "ghost", d: trace(ex.P, (x) => easeOut(clamp(x / ENTER)), 0, ENTER) }, ex.P.svg); const saved = svgEl("path", { d: `M${ex.P.sx(EXIT)} 133 H${ex.P.sx(ENTER)} M${ex.P.sx(EXIT)} 127 V139 M${ex.P.sx(ENTER)} 127 V139`, fill: "none", stroke: C.accent, "stroke-width": 3, "stroke-linecap": "round" }, ex.P.svg); const pct = make("span", "", ` · −${Math.round(SHORTER * 100)}%`, ex.l3, { color: C.accent, fontWeight: "650" }); const range = make("span", "", " · 40–50% shorter", ex.l1, { color: C.accent }); // =========================== Springs =========================== const SPRING_DUR = 0.7, TRAVEL = 220, BLOCK = 44; const S = card(24, 416, 1032, 328); lines.push(txt(S, "Springs · damping ratio ζ", 28, 20, { fontSize: "32px", fontWeight: "680" })); const Z = [1.0, 0.8, 0.5], FEEL = ["no bounce", "premium", "playful"]; const springs = Z.map((z, i) => { const x = colX(i); const s = (dt) => spring01(tLaunch + dt, tLaunch, { duration: SPRING_DUR, damping: z }); // the motion itself // Measure it: peak over 3 s at 1 ms, and the moment it stays within 0.5% of its mark. let peak = 0, settle = 0; for (let ms = 0; ms <= 3000; ms++) { const v = s(ms / 1000); peak = Math.max(peak, v); if (Math.abs(1 - v) > 0.005) settle = ms / 1000; } const over = Math.max(0, peak - 1); const P = plot(S, { x, y: 66, w: COLW, h: 110, x0: 0, x1: 1.4, y0: -0.08, y1: 1.3, axes: false }); svgEl("path", { class: "axis", d: `M0 ${P.sy(0)} H${COLW}` }, P.svg); svgEl("path", { class: "ghost", d: `M0 ${P.sy(1)} H${COLW}` }, P.svg); const path = svgEl("path", { class: "curve" }, P.svg); const pen = svgEl("circle", { r: 7, fill: C.accent }, P.svg); const track = place(make("div", "", ""), x, 186, COLW, 48); S.appendChild(track); make("div", "", "", track, { position: "absolute", left: "0px", right: "0px", top: "23px", height: "2px", background: C.line }); make("div", "", "", track, { position: "absolute", left: `${TRAVEL + BLOCK / 2 - 1}px`, top: "0px", width: "0px", height: "48px", borderLeft: `2.5px dashed ${C.faint}` }); const block = make("div", "", "", track, { position: "absolute", left: "0px", top: "2px", width: `${BLOCK}px`, height: `${BLOCK}px`, borderRadius: "12px", background: C.accent }); block.id = `s11-spring-${String(z * 10).padStart(2, "0")}`; // #s11-spring-10, -08, -05 const l1 = mono(S, `ζ ${z.toFixed(1)}`, x, 244, { fontSize: "32px", fontWeight: "600", color: C.accent }); const feel = txt(S, FEEL[i], x + 108, 245); const l2 = mono(S, `overshoot ${over < 0.0005 ? "0" : (over * 100).toFixed(1)}%`, x, 284, { color: C.muted }); lines.push(l1, feel, l2); return { z, s, peak, over, settle, P, path, pen, block, l1, feel, l2 }; }); // =========================== Position / opacity =========================== const O = card(24, 760, 1032, 224); const BOUNCY = { duration: SPRING_DUR, damping: 0.5 }; const tPosGo = F(tPos + 0.35), tOpaGo = F(tOpa + 0.3), OK_AT = 0.75; // ✓ once the block is within 3% of its mark const sB = (t, t0) => spring01(t, t0, BOUNCY); let peakB = 0, tPeakB = 0; for (let ms = 0; ms <= 2000; ms++) { const v = sB(ms / 1000, 0); if (v > peakB) { peakB = v; tPeakB = ms / 1000; } } // Left: position may bounce. const head = (html, x) => txt(O, `${html}`, x, 22, { fontSize: "32px", fontWeight: "680", display: "flex", alignItems: "center", gap: "14px" }); const posHead = head("Position can bounce", 28); const posOk = icon(posHead, "check", C.green); const posTrack = place(make("div", "", ""), 28, 100, 230, 56); O.appendChild(posTrack); make("div", "", "", posTrack, { position: "absolute", left: "0px", right: "0px", top: "27px", height: "2px", background: C.line }); make("div", "", "", posTrack, { position: "absolute", left: "143px", top: "0px", width: "0px", height: "56px", borderLeft: `2.5px dashed ${C.faint}` }); const POS_TRAVEL = 120; const posBlock = make("div", "", "", posTrack, { position: "absolute", left: "0px", top: "6px", width: "44px", height: "44px", borderRadius: "12px", background: C.accent }); const posP = plot(O, { x: 282, y: 78, w: 220, h: 96, x0: 0, x1: 1.2, y0: 0, y1: 1.3, axes: false }); svgEl("path", { class: "axis", d: `M0 ${posP.sy(0)} H220` }, posP.svg); svgEl("path", { class: "ghost", d: `M0 ${posP.sy(1)} H220` }, posP.svg); const posPath = svgEl("path", { class: "curve" }, posP.svg); const posNote = mono(O, `peaks at ${peakB.toFixed(2)}`, 28, 180, { color: C.muted }); // Right: opacity never does. The same spring would reach peakB; opacity stops at 1.0. const opaHead = head("Opacity never does", 548); const swatch = place(make("div", "", "", O, { borderRadius: "14px", background: C.accent }), 548, 86, 80, 80); const slot = place(make("div", "", "", O, { borderRadius: "14px", border: `2.5px dashed ${C.line}` }), 548, 86, 80, 80); const opaP = plot(O, { x: 652, y: 78, w: 220, h: 96, x0: 0, x1: 1.2, y0: 0, y1: 1.3, axes: false }); svgEl("path", { class: "axis", d: `M0 ${opaP.sy(0)} H220` }, opaP.svg); svgEl("path", { class: "ghost", d: `M0 ${opaP.sy(1)} H220` }, opaP.svg); const lost = svgEl("path", { fill: "#fde8eb", stroke: "none" }, opaP.svg); // the part opacity loses const wouldPath = svgEl("path", { fill: "none", stroke: C.red, "stroke-width": 3.5, "stroke-linecap": "round" }, opaP.svg); const opaPath = svgEl("path", { class: "curve" }, opaP.svg); const peakMark = svgEl("g", {}, opaP.svg); svgEl("path", { d: "M-8 -8 L8 8 M8 -8 L-8 8", stroke: C.red, "stroke-width": 3.5, "stroke-linecap": "round" }, peakMark); peakMark.setAttribute("transform", `translate(${opaP.sx(tPeakB).toFixed(2)} ${(opaP.sy(peakB) - 16).toFixed(2)})`); const opaNote = mono(O, `${peakB.toFixed(2)} → clamped 1.00`, 652, 180, { color: C.muted }); lines.push(posHead, posNote, opaHead, opaNote); const opacityOf = (t) => Math.min(1, sB(t, tOpaGo)); // what the swatch is given sfx(tPosGo + OK_AT + 1 / FPS, "success"); // the ✓ lands as the block settles sfx(tOpaGo + tPeakB + 1 / FPS, "tap"); // the ✕ pins on the clamped peak // =========================== checks =========================== const offset = (el) => box(el).x - box(el.parentElement).x; // a block's travel, as the viewer sees it check("s11 springs: ζ 1.0, 0.8, 0.5 launch on one frame", [tLaunch, tLaunch + 1 / FPS], (t) => { const xs = springs.map((s) => offset(s.block)); return t < tLaunch + 0.5 / FPS ? xs.every((x) => Math.abs(x) < 0.01) || `at launch ${xs.map((x) => x.toFixed(2))}` : xs.every((x) => x > 0) || `next frame ${xs.map((x) => x.toFixed(2))}`; }); for (const sp of springs) { if (sp.z < 1) { // Peak of an underdamped step: at π/ωd, 1 + exp(−ζπ/√(1−ζ²)). Measure the block there. const wd = (2 * Math.PI / SPRING_DUR) * Math.sqrt(1 - sp.z * sp.z), tp = Math.PI / wd; const want = Math.exp((-sp.z * Math.PI) / Math.sqrt(1 - sp.z * sp.z)); check(`s11 spring ζ ${sp.z}: rendered overshoot ${(want * 100).toFixed(1)}% at its peak`, F(tLaunch + tp), (t) => { const got = offset(sp.block) / TRAVEL - 1, exact = sp.s(t - tLaunch) - 1; return (Math.abs(got - exact) < 0.5 / TRAVEL && Math.abs(exact - want) < 0.002) || `got ${(got * 100).toFixed(2)}% want ${(want * 100).toFixed(2)}%`; }); } else { check("s11 spring ζ 1.0: never passes its mark", [tLaunch, tLaunch + 1.4], () => offset(sp.block) <= TRAVEL + 0.01 || `x ${offset(sp.block).toFixed(2)}`); } check(`s11 spring ζ ${sp.z.toFixed(1)}: settled on its mark from f${Math.ceil((tLaunch + sp.settle) * FPS)}`, [F(tLaunch + sp.settle) + 1 / FPS, cutOut - 1 / FPS], () => Math.abs(offset(sp.block) - TRAVEL) <= 0.005 * TRAVEL + 0.01 || `x ${offset(sp.block).toFixed(2)}`); } check("s11 springs: overshoot readouts 0%, 1.5%, 16.3%", tFeel[2] + 1, () => springs.map((s) => s.l2.textContent).join(" | ") === "overshoot 0% | overshoot 1.5% | overshoot 16.3%" || springs.map((s) => s.l2.textContent).join(" | ")); // The balls, timed from the rendered frames: on every frame of the move, the ball's progress is // exactly its curve at (t − t0) / duration, so the duration on screen is the duration in the label. for (const c of ease) { check(`s11 ${c.role}: the ball follows ${c.curve} over exactly ${Math.round(c.dur * 1000)} ms`, [c.t0, c.t0 + c.dur + 3 / FPS], (t) => { const got = (box(c.ball).x - box(c.track).x - c.from) / (c.to - c.from), want = c.fn(p01(t, c.t0, c.dur)); return Math.abs(got - want) < 0.002 || `progress ${got.toFixed(4)} want ${want.toFixed(4)}`; }); } check(`s11 exit is ${Math.round(SHORTER * 100)}% shorter than the entrance (40–50%)`, tShorter + 1, () => (SHORTER >= 0.4 && SHORTER <= 0.5 && ease[1].l3.textContent === `${EXIT * 1000} ms · −${Math.round(SHORTER * 100)}%`) || `${SHORTER} "${ease[1].l3.textContent}"`); check("s11 the exit's curve ends at 55% of the entrance's on the shared axis", tShorter + 1, () => { const r = (ex.P.sx(EXIT) - ex.P.sx(0)) / (ex.P.sx(ENTER) - ex.P.sx(0)); return Math.abs(r - EXIT / ENTER) < 1e-9 || `${r}`; }); check("s11 opacity is clamped: never above 1, and 1.0 at the would-be peak", [tOpaGo, tOpaGo + 1.2], (t) => { const o = Number(getComputedStyle(swatch).opacity); if (o > 1) return `opacity ${o}`; if (Math.abs(t - (tOpaGo + tPeakB)) < 0.5 / FPS) return (o === 1 && sB(t, tOpaGo) > 1.1) || `at peak ${o}`; return Math.abs(o - opacityOf(t)) < 0.001 || `opacity ${o} want ${opacityOf(t)}`; }); check("s11 text is 28 px or larger", cutOut - 2 / FPS, () => { const small = [...root.querySelectorAll("div, span")].filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) .filter((el) => parseFloat(getComputedStyle(el).fontSize) < 28); return !small.length || small.map((el) => `${el.textContent.slice(0, 16)} ${getComputedStyle(el).fontSize}`).join("; "); }); check("s11 text lines and plots don't overlap; all inside the stage", cutOut - 2 / FPS, () => { const els = [...lines, ...ease.map((c) => c.P.svg), ...springs.map((s) => s.P.svg), posP.svg, opaP.svg, swatch]; for (let i = 0; i < els.length; i++) { const b = box(els[i]); if (b.x < 0 || b.x + b.w > 1080 || b.y < 128 || b.y + b.h > 1120) return `${els[i].textContent?.slice(0, 20)} outside`; for (let j = i + 1; j < els.length; j++) { const o = overlap(els[i], els[j]); if (o > 0) return `${(els[i].textContent || els[i].tagName).slice(0, 18)} x ${(els[j].textContent || els[j].tagName).slice(0, 18)}: ${o | 0} px²`; } } return true; }); // =========================== seek =========================== return (t) => { // Easing: the curve is drawn by the ball's own clock, so the pen and the ball move together. for (const c of ease) { const e = clamp(t - c.t0, 0, c.dur); c.path.setAttribute("d", t > c.t0 ? trace(c.P, (x) => c.fn(x / c.dur), 0, e) : ""); c.pen.setAttribute("cx", c.P.sx(e).toFixed(2)); c.pen.setAttribute("cy", c.P.sy(c.fn(e / c.dur)).toFixed(2)); c.pen.style.opacity = t > c.t0 ? 1 : 0; c.ball.style.transform = `translateX(${ballX(c, t).toFixed(3)}px)`; [c.l1, c.l2, c.l3].forEach((el, j) => show(el, t, c.label + j * 0.06, Infinity, { dur: 0.5, dy: 12 })); } show(ex.ball, t, tExit, Infinity, { dur: 0.4, dy: 0 }); // the exit's ball arrives with its label, then leaves ex.ball.style.transform = `translateX(${ballX(ex, t).toFixed(3)}px)`; const g = easeOut(p01(t, tShorter, 0.5)); ghost.style.opacity = g.toFixed(4); saved.style.opacity = g.toFixed(4); show(range, t, tShorter, Infinity, { dur: 0.5, dy: 0 }); show(pct, t, tShorter + 0.15, Infinity, { dur: 0.5, dy: 0 }); // Springs: the hero. Three blocks, one launch frame; each traces its own step response. for (const sp of springs) { const dt = clamp(t - tLaunch, 0, 1.4); sp.path.setAttribute("d", t > tLaunch ? trace(sp.P, sp.s, 0, dt, 120) : ""); sp.pen.setAttribute("cx", sp.P.sx(dt).toFixed(2)); sp.pen.setAttribute("cy", sp.P.sy(sp.s(dt)).toFixed(2)); sp.pen.style.opacity = t > tLaunch ? 1 : 0; sp.block.style.transform = `translateX(${(TRAVEL * sp.s(t - tLaunch)).toFixed(2)}px)`; show(sp.l1, t, tSpring, Infinity, { dur: 0.5, dy: 12 }); } springs.forEach((sp, i) => { show(sp.feel, t, tFeel[i], Infinity, { dur: 0.5, dy: 12 }); show(sp.l2, t, tFeel[i] + 0.06, Infinity, { dur: 0.5, dy: 12 }); }); // Position can bounce. show(O, t, tPos, Infinity, { dur: 0.5, dy: 16 }); for (const el of [posHead, posTrack, posP.svg]) show(el, t, tPos, Infinity, { dur: 0.5, dy: 12 }); posBlock.style.transform = `translateX(${(POS_TRAVEL * sB(t, tPosGo)).toFixed(2)}px)`; posPath.setAttribute("d", t > tPosGo ? trace(posP, (x) => sB(x, 0), 0, clamp(t - tPosGo, 0, 1.2), 100) : ""); show(posOk, t, tPosGo + OK_AT, Infinity, { dur: 0.35, dy: 0, scale: 0.6 }); show(posNote, t, tPosGo + OK_AT, Infinity, { dur: 0.5, dy: 12 }); // Opacity never does: the spring's overshoot is cut off at 1.0 and marked. for (const el of [opaHead, opaP.svg, slot]) show(el, t, tOpa, Infinity, { dur: 0.5, dy: 12 }); swatch.style.opacity = opacityOf(t).toFixed(4); const de = clamp(t - tOpaGo, 0, 1.2); // Only the part above 1.0 is drawn in red, with its area tinted: what the clamp throws away. const above = []; for (let i = 0; i <= 100; i++) { const x = (de * i) / 100, v = sB(x, 0); if (v > 1) above.push([x, v]); } const segD = above.map(([x, v], i) => `${i ? "L" : "M"}${opaP.sx(x).toFixed(2)} ${opaP.sy(v).toFixed(2)}`).join(""); wouldPath.setAttribute("d", t > tOpaGo && above.length > 1 ? segD : ""); lost.setAttribute("d", t > tOpaGo && above.length > 1 ? `${segD}L${opaP.sx(above.at(-1)[0]).toFixed(2)} ${opaP.sy(1).toFixed(2)}L${opaP.sx(above[0][0]).toFixed(2)} ${opaP.sy(1).toFixed(2)}Z` : ""); opaPath.setAttribute("d", t > tOpaGo ? trace(opaP, (x) => Math.min(1, sB(x, 0)), 0, de, 100) : ""); peakMark.style.opacity = t >= tOpaGo + tPeakB ? 1 : 0; show(opaNote, t, tOpaGo + tPeakB + 0.1, Infinity, { dur: 0.5, dy: 12 }); }; }