| |
| |
| |
| |
| |
| |
| (() => { |
| "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; |
|
|
| |
| 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); |
|
|
| |
| |
| 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); |
|
|
| |
| 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", |
| }); |
| } |
| })(); |
|
|