future-ts / app.js
jameswlepage's picture
Publish FUTURE-TS v0.1.0 public preview
3314fdf verified
Raw
History Blame Contribute Delete
3.83 kB
// ============================================================
// 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",
});
}
})();