/* ============================================================ ChartField — frosted-glass trading chart, infinite drift. Canvas background for the hero. White semi-transparent glass candles in three parallax depth layers + flowing wave lines, on a transparent canvas (the hero supplies the dark backdrop). Pauses when scrolled out of view. Props: speed, frost, amp, density ('sparse'|'mid'|'dense') ============================================================ */ function ChartField({ speed = 0.7, frost = 0.9, amp = 0.92, density = "mid", theme = "dark", style }) { const ref = React.useRef(null); const cfg = React.useRef({ speed, frost, amp, density, theme }); cfg.current = { speed, frost, amp, density, theme }; React.useEffect(() => { const cv = ref.current; const host = cv.parentElement; const ctx = cv.getContext("2d"); const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; let W = 0, H = 0, DPR = 1, scl = 1; const resize = () => { DPR = Math.min(window.devicePixelRatio || 1, 2); W = host.clientWidth || window.innerWidth; H = host.clientHeight || window.innerHeight; cv.style.width = W + "px"; cv.style.height = H + "px"; cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); scl = Math.max(0.7, Math.min(1.5, W / 1280)); }; const ro = new ResizeObserver(resize); ro.observe(host); resize(); // frosted micro-grain const grain = document.createElement("canvas"); grain.width = grain.height = 150; (function () { const g = grain.getContext("2d"); const id = g.createImageData(150, 150); for (let i = 0; i < id.data.length; i += 4) { const v = 120 + Math.random() * 135; id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 255; } g.putImageData(id, 0, 0); })(); const grainPat = ctx.createPattern(grain, "repeat"); const hash = (n) => { const s = Math.sin(n * 127.1 + 31.7) * 43758.5453; return s - Math.floor(s); }; const wave = (wx, t, a) => a * ( 72 * Math.sin(wx * 0.0015 + t * 0.18) + 40 * Math.sin(wx * 0.0041 - t * 0.12 + 1.3) + 18 * Math.sin(wx * 0.0090 + t * 0.27 + 2.1) ); const roundRect = (x, y, w, h, r) => { r = Math.max(0, Math.min(r, w / 2, h / 2)); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }; const layers = [ { par: 0.40, S: 44, w: 10, op: 0.30, glow: 5, base: 0.40, hi: 0.16, hMin: 22, hMax: 120, lbl: false }, { par: 0.70, S: 74, w: 19, op: 0.60, glow: 11, base: 0.52, hi: 0.30, hMin: 46, hMax: 210, lbl: true }, { par: 1.00, S: 116, w: 31, op: 1.00, glow: 18, base: 0.60, hi: 0.55, hMin: 68, hMax: 320, lbl: true }, ]; function drawCandle(L, x, baseY, n, f, time) { const light = cfg.current.theme === "light"; const RGB = light ? "14,14,12" : "255,255,255"; const r1 = hash(n), r2 = hash(n * 2.3 + 0.7), r3 = hash(n * 0.7 + 5.1); // animated level — the candle body height drifts over time like a live // price print (slow swing + small fast wobble), not a frozen shape. let frac = 0.5 + 0.5 * (0.62 * Math.sin(n * 0.5 + L.par * 3 + time * 0.5) + 0.38 * (r1 * 2 - 1)) + 0.09 * Math.sin(time * 1.7 + n * 1.3); frac = Math.max(0.05, Math.min(1, frac)); let h = (L.hMin + (L.hMax - L.hMin) * frac) * scl; if (h < 10 * scl) h = 10 * scl; const up = r2 > 0.32, w = L.w * scl; const wickTop = (6 + r3 * 22) * scl, wickBot = (6 + hash(n + 3) * 22) * scl; let yTop, yBot; if (up) { yBot = baseY + 4 * scl; yTop = baseY - h; } else { yTop = baseY - 4 * scl; yBot = baseY + h; } const op = L.op * f; ctx.strokeStyle = "rgba(" + RGB + "," + (0.26 * op) + ")"; ctx.lineWidth = Math.max(1, 1.1 * scl); ctx.beginPath(); ctx.moveTo(x, yTop - wickTop); ctx.lineTo(x, yBot + wickBot); ctx.stroke(); ctx.save(); ctx.shadowColor = "rgba(" + RGB + "," + (0.30 * op) + ")"; ctx.shadowBlur = L.glow * scl; const g = ctx.createLinearGradient(0, yTop, 0, yBot); g.addColorStop(0, "rgba(" + RGB + "," + ((light ? 0.42 : 0.30) * op) + ")"); g.addColorStop(0.5, "rgba(" + RGB + "," + ((light ? 0.20 : 0.14) * op) + ")"); g.addColorStop(1, "rgba(" + RGB + "," + ((light ? 0.10 : 0.07) * op) + ")"); ctx.fillStyle = g; roundRect(x - w / 2, yTop, w, yBot - yTop, 2 * scl); ctx.fill(); ctx.restore(); ctx.fillStyle = "rgba(" + RGB + "," + (L.hi * f) + ")"; ctx.fillRect(x - w / 2, yTop, w, Math.max(1, 1.2 * scl)); ctx.fillStyle = "rgba(" + RGB + "," + (0.5 * L.hi * f) + ")"; ctx.fillRect(x - w / 2, yTop, Math.max(1, scl), yBot - yTop); if (L.lbl && hash(n + 9) > 0.78) { // direction + value follow THIS candle's own price level over time: // an up-candle's level is its body top, a down-candle's is its body // bottom (its leading edge). Rising level = green ▲, falling = red ▼. const a = cfg.current.amp, dt2 = 0.12; const lvlAt = (tt) => { const bY = H * L.base + wave(x + off * 0.85, tt, a) * 0.5; let fr = 0.5 + 0.5 * (0.62 * Math.sin(n * 0.5 + L.par * 3 + tt * 0.5) + 0.38 * (r1 * 2 - 1)) + 0.09 * Math.sin(tt * 1.7 + n * 1.3); fr = Math.max(0.05, Math.min(1, fr)); let hh = (L.hMin + (L.hMax - L.hMin) * fr) * scl; if (hh < 10 * scl) hh = 10 * scl; return up ? (bY - hh) : (bY + hh); }; const priceY = up ? yTop : yBot; const vel = lvlAt(time - dt2) - priceY; // >0 ⇒ level moved up (y decreased) const rising = vel >= 0; const pct = Math.min(9.99, Math.abs(vel) / scl * 0.3 + 0.04); // market semantics — the one place colour is allowed (real gain/loss). // brand green/red on light, brightened on dark. const col = rising ? (light ? "21,128,61" : "82,214,138") : (light ? "194,54,43" : "242,108,96"); const aL = (rising ? 0.92 : 0.88) * f; const fp = 11 * scl, tx = x + w * 0.7, ty = yTop - 10 * scl; // classic exchange arrow ▲ / ▼ const aw = fp * 0.5, ah = fp * 0.6; ctx.fillStyle = "rgba(" + col + "," + aL + ")"; ctx.beginPath(); if (rising) { ctx.moveTo(tx, ty - ah); ctx.lineTo(tx + aw, ty); ctx.lineTo(tx - aw, ty); } else { ctx.moveTo(tx, ty); ctx.lineTo(tx + aw, ty - ah); ctx.lineTo(tx - aw, ty - ah); } ctx.closePath(); ctx.fill(); // ticking percent after the arrow ctx.font = '500 ' + fp + 'px "JetBrains Mono", monospace'; ctx.fillText((rising ? "+" : "\u2212") + pct.toFixed(2) + "%", tx + aw + 4 * scl, ty); } } function drawWaveLine(t, off, f, a, p) { const light = cfg.current.theme === "light"; const RGB = light ? "14,14,12" : "255,255,255"; ctx.beginPath(); for (let x = -24; x <= W + 24; x += 6) { const wx = x + off * 0.85; const y = H * p.yMul + wave(wx, t + p.phase, p.amp * a); if (x <= -24) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.save(); ctx.shadowColor = "rgba(" + RGB + "," + (0.5 * p.glow * f) + ")"; ctx.shadowBlur = 22 * scl; ctx.lineWidth = 7 * scl; ctx.strokeStyle = "rgba(" + RGB + "," + (0.05 * f) + ")"; ctx.stroke(); ctx.restore(); ctx.lineWidth = p.core * scl; ctx.strokeStyle = "rgba(" + RGB + "," + ((light ? 0.6 : 0.72) * f) + ")"; ctx.lineCap = "round"; ctx.stroke(); } let last = performance.now(), off = 0, time = 0, raf = 0, visible = true; const io = new IntersectionObserver((es) => { visible = es[0].isIntersecting; }, { threshold: 0 }); io.observe(host); function frame(now) { raf = requestAnimationFrame(frame); const dt = Math.min(0.05, (now - last) / 1000); last = now; if (!visible) return; const c = cfg.current; if (!reduce) { off += 42 * scl * c.speed * dt; time += dt * c.speed; } ctx.clearRect(0, 0, W, H); const dens = c.density === "dense" ? 0.72 : c.density === "sparse" ? 1.4 : 1.0; for (const L of layers) { const S = L.S * scl * dens, scroll = off * L.par; const start = Math.floor(scroll / S) - 2, end = start + Math.ceil(W / S) + 4; for (let n = start; n <= end; n++) { const x = n * S - scroll; const baseY = H * L.base + wave(x + off * 0.85, time, c.amp) * 0.5; drawCandle(L, x, baseY, n, c.frost, time); } } drawWaveLine(time, off, c.frost, c.amp, { amp: 1.00, phase: 0.0, yMul: 0.50, core: 1.9, glow: 1.0 }); drawWaveLine(time, off, c.frost, c.amp, { amp: 0.72, phase: 2.4, yMul: 0.55, core: 1.4, glow: 0.7 }); drawWaveLine(time, off, c.frost, c.amp, { amp: 0.46, phase: 4.7, yMul: 0.47, core: 1.0, glow: 0.45 }); ctx.globalAlpha = 0.035 * c.frost; ctx.fillStyle = grainPat; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; } raf = requestAnimationFrame(frame); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); }; }, []); return ; } Object.assign(window, { ChartField });