"use client"; import { useEffect, useRef } from "react"; /* Ambient "shift" background — a faithful port of crnacura/AmbientCanvasBackgrounds * (js/shift.js): a few dozen large circles drift at random, their hue driven by * 3D simplex noise at their position + time; everything is drawn to an offscreen * buffer, then blurred and composited onto the visible canvas over a flat base. * Re-themed to a dark, infrastructure palette (deep-ink base; glowing azure/indigo * blooms with a faint ember). Light hero copy sits on top. Scoped to its container, not the * window. Respects prefers-reduced-motion and pauses when scrolled out of view. */ // --- tunables (all easy to tweak) --------------------------------------- const CIRCLE_COUNT = 120; const BASE_SPEED = 0.06; const RANGE_SPEED = 0.7; const BASE_TTL = 180; const RANGE_TTL = 260; const BASE_RADIUS = 140; const RANGE_RADIUS = 260; const NOISE_OFF = 0.0015; // x/y/z noise sampling step const BLUR_PX = 55; const BASE_COLOR = "#0b1220"; // deep ink — blooms read as glowing light sources const ALPHA = 1; // global multiplier on each blob's peak alpha // Cool glow palette on dark [r, g, b, peakAlpha]. Azure + indigo dominate; a // faint ember adds warmth (re-themed from the original hue-band approach). const PALETTE: [number, number, number, number][] = [ [59, 130, 246, 0.22], // azure #3B82F6 [79, 70, 229, 0.18], // indigo #4F46E5 [224, 88, 74, 0.12], // ember #E0584A (faint, rare) ]; const TAU = Math.PI * 2; const rand = (n: number) => n * Math.random(); const fadeInOut = (t: number, m: number) => { const hm = 0.5 * m; return Math.abs(((t + hm) % m) - hm) / hm; }; // --- minimal 3D simplex noise (public domain, after S. Gustavson / J. Wagner) --- const GRAD3 = new Float32Array([ 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, ]); class SimplexNoise { private perm = new Uint8Array(512); private permMod12 = new Uint8Array(512); constructor(random: () => number = Math.random) { const p = new Uint8Array(256); for (let i = 0; i < 256; i++) p[i] = i; for (let i = 255; i > 0; i--) { const n = Math.floor(random() * (i + 1)); const tmp = p[i]; p[i] = p[n]; p[n] = tmp; } for (let i = 0; i < 512; i++) { this.perm[i] = p[i & 255]; this.permMod12[i] = this.perm[i] % 12; } } noise3D(xin: number, yin: number, zin: number): number { const { perm, permMod12 } = this; const F3 = 1 / 3; const G3 = 1 / 6; let n0 = 0; let n1 = 0; let n2 = 0; let n3 = 0; const s = (xin + yin + zin) * F3; const i = Math.floor(xin + s); const j = Math.floor(yin + s); const k = Math.floor(zin + s); const t = (i + j + k) * G3; const x0 = xin - (i - t); const y0 = yin - (j - t); const z0 = zin - (k - t); let i1, j1, k1, i2, j2, k2; if (x0 >= y0) { if (y0 >= z0) { i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 1; k2 = 0; } else if (x0 >= z0) { i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 0; k2 = 1; } else { i1 = 0; j1 = 0; k1 = 1; i2 = 1; j2 = 0; k2 = 1; } } else { if (y0 < z0) { i1 = 0; j1 = 0; k1 = 1; i2 = 0; j2 = 1; k2 = 1; } else if (x0 < z0) { i1 = 0; j1 = 1; k1 = 0; i2 = 0; j2 = 1; k2 = 1; } else { i1 = 0; j1 = 1; k1 = 0; i2 = 1; j2 = 1; k2 = 0; } } const x1 = x0 - i1 + G3; const y1 = y0 - j1 + G3; const z1 = z0 - k1 + G3; const x2 = x0 - i2 + 2 * G3; const y2 = y0 - j2 + 2 * G3; const z2 = z0 - k2 + 2 * G3; const x3 = x0 - 1 + 3 * G3; const y3 = y0 - 1 + 3 * G3; const z3 = z0 - 1 + 3 * G3; const ii = i & 255; const jj = j & 255; const kk = k & 255; let t0 = 0.6 - x0 * x0 - y0 * y0 - z0 * z0; if (t0 >= 0) { const g = permMod12[ii + perm[jj + perm[kk]]] * 3; t0 *= t0; n0 = t0 * t0 * (GRAD3[g] * x0 + GRAD3[g + 1] * y0 + GRAD3[g + 2] * z0); } let t1 = 0.6 - x1 * x1 - y1 * y1 - z1 * z1; if (t1 >= 0) { const g = permMod12[ii + i1 + perm[jj + j1 + perm[kk + k1]]] * 3; t1 *= t1; n1 = t1 * t1 * (GRAD3[g] * x1 + GRAD3[g + 1] * y1 + GRAD3[g + 2] * z1); } let t2 = 0.6 - x2 * x2 - y2 * y2 - z2 * z2; if (t2 >= 0) { const g = permMod12[ii + i2 + perm[jj + j2 + perm[kk + k2]]] * 3; t2 *= t2; n2 = t2 * t2 * (GRAD3[g] * x2 + GRAD3[g + 1] * y2 + GRAD3[g + 2] * z2); } let t3 = 0.6 - x3 * x3 - y3 * y3 - z3 * z3; if (t3 >= 0) { const g = permMod12[ii + 1 + perm[jj + 1 + perm[kk + 1]]] * 3; t3 *= t3; n3 = t3 * t3 * (GRAD3[g] * x3 + GRAD3[g + 1] * y3 + GRAD3[g + 2] * z3); } return 32 * (n0 + n1 + n2 + n3); } } const PROPS = 8; // x, y, vx, vy, life, ttl, radius, hue export function HeroAmbient() { const hostRef = useRef(null); useEffect(() => { const host = hostRef.current; if (!host) return; // Offscreen buffer (a) + visible canvas (b), mirroring shift.js. const a = document.createElement("canvas"); const b = document.createElement("canvas"); b.style.cssText = "position:absolute;inset:0;width:100%;height:100%;"; host.appendChild(b); const actx = a.getContext("2d")!; const bctx = b.getContext("2d")!; const simplex = new SimplexNoise(); const circles = new Float32Array(CIRCLE_COUNT * PROPS); let tick = 0; let raf = 0; let running = true; function initCircle(i: number) { const x = rand(a.width); const y = rand(a.height); const n = simplex.noise3D(x * NOISE_OFF, y * NOISE_OFF, tick * NOISE_OFF); const ang = rand(TAU); const speed = BASE_SPEED + rand(RANGE_SPEED); // Noise picks the glow color: azure dominant, indigo next, ember rare. const colorIndex = n > 0.05 ? 0 : n > -0.4 ? 1 : 2; circles.set( [ x, y, speed * Math.cos(ang), speed * Math.sin(ang), 0, BASE_TTL + rand(RANGE_TTL), BASE_RADIUS + rand(RANGE_RADIUS), colorIndex, ], i, ); } function drawCircle( x: number, y: number, life: number, ttl: number, radius: number, colorIndex: number, ) { const [r, g, b, peak] = PALETTE[colorIndex] ?? PALETTE[0]; actx.save(); actx.fillStyle = `rgba(${r},${g},${b},${fadeInOut(life, ttl) * peak * ALPHA})`; actx.beginPath(); actx.arc(x, y, radius, 0, TAU); actx.fill(); actx.restore(); } function updateCircle(i: number) { const x = circles[i] + circles[i + 2]; const y = circles[i + 1] + circles[i + 3]; const life = circles[i + 4] + 1; const ttl = circles[i + 5]; const radius = circles[i + 6]; drawCircle(circles[i], circles[i + 1], life, ttl, radius, circles[i + 7]); circles[i] = x; circles[i + 1] = y; circles[i + 4] = life; const out = x < -radius || x > a.width + radius || y < -radius || y > a.height + radius; if (out || life > ttl) initCircle(i); } function frame() { actx.clearRect(0, 0, a.width, a.height); bctx.fillStyle = BASE_COLOR; bctx.fillRect(0, 0, b.width, b.height); tick++; // advances the noise field used when respawning circles for (let i = 0; i < circles.length; i += PROPS) updateCircle(i); bctx.save(); bctx.filter = `blur(${BLUR_PX}px)`; bctx.drawImage(a, 0, 0); bctx.restore(); if (running) raf = window.requestAnimationFrame(frame); } function resize() { const w = Math.max(1, Math.floor(host!.clientWidth)); const h = Math.max(1, Math.floor(host!.clientHeight)); a.width = w; a.height = h; b.width = w; b.height = h; } function start() { if (raf) return; running = true; raf = window.requestAnimationFrame(frame); } function stop() { running = false; if (raf) cancelAnimationFrame(raf); raf = 0; } resize(); for (let i = 0; i < circles.length; i += PROPS) initCircle(i); const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce) { // Static single frame — no animation. for (let i = 0; i < circles.length; i += PROPS) updateCircle(i); bctx.fillStyle = BASE_COLOR; bctx.fillRect(0, 0, b.width, b.height); bctx.save(); bctx.filter = `blur(${BLUR_PX}px)`; bctx.drawImage(a, 0, 0); bctx.restore(); } const ro = new ResizeObserver(() => { resize(); if (reduce) { actx.clearRect(0, 0, a.width, a.height); for (let i = 0; i < circles.length; i += PROPS) updateCircle(i); bctx.fillStyle = BASE_COLOR; bctx.fillRect(0, 0, b.width, b.height); bctx.save(); bctx.filter = `blur(${BLUR_PX}px)`; bctx.drawImage(a, 0, 0); bctx.restore(); } }); ro.observe(host); // Pause when the hero is scrolled out of view (saves CPU/battery). const io = reduce ? null : new IntersectionObserver( ([e]) => (e.isIntersecting ? start() : stop()), { threshold: 0 }, ); if (io) io.observe(host); else if (!reduce) start(); const onVisibility = () => { if (reduce) return; if (document.hidden) stop(); else start(); }; document.addEventListener("visibilitychange", onVisibility); return () => { stop(); ro.disconnect(); io?.disconnect(); document.removeEventListener("visibilitychange", onVisibility); b.remove(); }; }, []); return (
. className="absolute inset-0 -z-0" style={{ backgroundColor: BASE_COLOR }} /> ); }