// ============================================================ // FUTURE-TS header illustration: a flat, minimal line chart of // a synthetic series — observed history, a freeze marker, and a // future-only forecast. Crisp 1px strokes (non-scaling), drawn // once on load. Deterministic so the curve is stable. // ============================================================ (() => { "use strict"; const svg = document.getElementById("series-svg"); if (!svg) return; const NS = "http://www.w3.org/2000/svg"; const VW = 1200, VH = 220, PAD_T = 28, PAD_B = 30; const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; // small seeded PRNG → the same curve every load function mulberry32(a) { return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rand = mulberry32(20260605); // synthesize a believable series: slow trend + seasonality + a // gentle random walk + light noise const N = 150; const FREEZE = Math.round(N * 0.62); const raw = []; let drift = 0; for (let i = 0; i < N; i++) { const t = i / N; drift += (rand() - 0.5) * 0.12; const trend = Math.sin(t * Math.PI * 0.9) * 0.7; const season = Math.sin(t * Math.PI * 9) * 0.28 + Math.sin(t * Math.PI * 23) * 0.12; const noise = (rand() - 0.5) * 0.14; raw.push(trend + season + drift * 0.5 + noise); } let mn = Math.min.apply(null, raw), mx = Math.max.apply(null, raw); const span = mx - mn || 1; const X = (i) => (i / (N - 1)) * VW; const Y = (v) => PAD_T + (1 - (v - mn) / span) * (VH - PAD_T - PAD_B); function pathD(a, b) { let d = ""; for (let i = a; i <= b; i++) d += (i === a ? "M" : "L") + X(i).toFixed(1) + " " + Y(raw[i]).toFixed(1) + " "; return d.trim(); } function el(tag, attrs) { const n = document.createElementNS(NS, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); svg.appendChild(n); return n; } const css = getComputedStyle(document.documentElement); const INK = css.getPropertyValue("--ink").trim() || "#141414"; const ACCENT = css.getPropertyValue("--accent").trim() || "#8f46ff"; const LINE2 = css.getPropertyValue("--line-2").trim() || "#dadada"; const baseY = (VH - PAD_B).toFixed(1); el("line", { x1: 0, y1: baseY, x2: VW, y2: baseY, stroke: LINE2, "stroke-width": 1, "stroke-opacity": 0.6, "vector-effect": "non-scaling-stroke", }); const fx = X(FREEZE).toFixed(1); const freeze = el("line", { x1: fx, y1: 14, x2: fx, y2: VH - 12, stroke: ACCENT, "stroke-width": 1, "stroke-opacity": 0.5, "stroke-dasharray": "2 5", "vector-effect": "non-scaling-stroke", }); function series(d, stroke) { return el("path", { d, fill: "none", stroke, "stroke-width": 1.6, "stroke-linecap": "round", "stroke-linejoin": "round", "vector-effect": "non-scaling-stroke", }); } const observed = series(pathD(0, FREEZE), INK); const forecast = series(pathD(FREEZE, N - 1), ACCENT); // draw the lines in once, observed then forecast if (!reduce && typeof observed.getTotalLength === "function") { freeze.style.opacity = "0"; [ [observed, 0, 1150], [forecast, 1000, 950], ].forEach(([p, delay, dur]) => { const len = p.getTotalLength(); p.style.strokeDasharray = len; p.style.strokeDashoffset = len; p.animate( [{ strokeDashoffset: len }, { strokeDashoffset: 0 }], { duration: dur, delay, easing: "cubic-bezier(0.4,0,0.1,1)", fill: "forwards" } ); }); freeze.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, delay: 750, fill: "forwards", }); } })();