File size: 3,827 Bytes
3314fdf
 
 
 
 
 
 
 
2dcf2ac
3314fdf
 
 
 
 
 
 
 
2dcf2ac
3314fdf
 
 
 
 
 
 
 
 
2dcf2ac
3314fdf
2dcf2ac
3314fdf
 
 
 
 
 
 
 
 
 
 
 
 
 
2dcf2ac
3314fdf
 
 
 
 
2dcf2ac
3314fdf
 
 
 
 
2dcf2ac
3314fdf
 
 
 
 
 
2dcf2ac
3314fdf
 
 
 
2dcf2ac
3314fdf
 
 
 
 
2dcf2ac
3314fdf
 
 
 
 
 
 
 
 
 
 
 
 
2dcf2ac
3314fdf
 
2dcf2ac
3314fdf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2dcf2ac
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// ============================================================
// 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",
    });
  }
})();