// Three hero animation variants. Each renders into a filling .hero-canvas. // Variants: constellation, signal, aperture const { useEffect, useRef } = React; function useDPR(canvas) { useEffect(() => { if (!canvas.current) return; const c = canvas.current; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); const r = c.getBoundingClientRect(); c.width = r.width * dpr; c.height = r.height * dpr; const ctx = c.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); const ro = new ResizeObserver(resize); ro.observe(c); return () => ro.disconnect(); }, []); } function getAccent() { const v = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim(); return v || "oklch(0.86 0.14 195)"; } // ============== CONSTELLATION ============== function HeroConstellation() { const ref = useRef(null); useDPR(ref); useEffect(() => { const c = ref.current; if (!c) return; const ctx = c.getContext("2d"); let running = true; const N = 70; const nodes = Array.from({length: N}, () => ({ x: Math.random(), y: Math.random(), vx: (Math.random()-0.5)*0.00012, vy: (Math.random()-0.5)*0.00012, r: Math.random()*1.4 + 0.5, phase: Math.random()*Math.PI*2, })); let mouse = {x: 0.5, y: 0.5, active: false}; const onMove = (e) => { const r = c.getBoundingClientRect(); mouse.x = (e.clientX - r.left) / r.width; mouse.y = (e.clientY - r.top) / r.height; mouse.active = true; }; const onLeave = () => { mouse.active = false; }; window.addEventListener("mousemove", onMove); c.parentElement.addEventListener("mouseleave", onLeave); let t0 = performance.now(); function frame(now) { if (!running) return; const dt = now - t0; t0 = now; const r = c.getBoundingClientRect(); const W = r.width, H = r.height; ctx.clearRect(0, 0, W, H); // subtle grid ctx.strokeStyle = "oklch(0.28 0.013 250 / 0.4)"; ctx.lineWidth = 1; const gx = 80; for (let x = 0; x < W; x += gx) { ctx.beginPath(); ctx.moveTo(x+0.5, 0); ctx.lineTo(x+0.5, H); ctx.stroke(); } for (let y = 0; y < H; y += gx) { ctx.beginPath(); ctx.moveTo(0, y+0.5); ctx.lineTo(W, y+0.5); ctx.stroke(); } const accent = getAccent(); const ax = mouse.x * W, ay = mouse.y * H; // update for (const n of nodes) { n.x += n.vx * dt; n.y += n.vy * dt; if (n.x < 0 || n.x > 1) n.vx *= -1; if (n.y < 0 || n.y > 1) n.vy *= -1; n.x = Math.max(0, Math.min(1, n.x)); n.y = Math.max(0, Math.min(1, n.y)); } // links for (let i = 0; i < N; i++) { for (let j = i+1; j < N; j++) { const dx = (nodes[i].x - nodes[j].x) * W; const dy = (nodes[i].y - nodes[j].y) * H; const d = Math.hypot(dx, dy); if (d < 160) { const a = (1 - d/160) * 0.35; ctx.strokeStyle = `oklch(0.86 0.14 195 / ${a})`; // use accent var via filter trick: just compute alpha and use accent's hue via CSS color-mix wouldn't work in canvas, so we hardcode accent color string ctx.strokeStyle = withAlpha(accent, a); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(nodes[i].x*W, nodes[i].y*H); ctx.lineTo(nodes[j].x*W, nodes[j].y*H); ctx.stroke(); } } } // mouse links if (mouse.active) { for (const n of nodes) { const dx = n.x*W - ax, dy = n.y*H - ay; const d = Math.hypot(dx, dy); if (d < 220) { ctx.strokeStyle = withAlpha(accent, (1 - d/220) * 0.7); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(n.x*W, n.y*H); ctx.stroke(); } } } // nodes for (const n of nodes) { n.phase += 0.01; const r2 = n.r + Math.sin(n.phase) * 0.4; ctx.fillStyle = withAlpha(accent, 0.95); ctx.beginPath(); ctx.arc(n.x*W, n.y*H, r2, 0, Math.PI*2); ctx.fill(); // halo ctx.fillStyle = withAlpha(accent, 0.12); ctx.beginPath(); ctx.arc(n.x*W, n.y*H, r2*4, 0, Math.PI*2); ctx.fill(); } // vignette top-right diagonal sheen const grad = ctx.createLinearGradient(0, 0, W, H); grad.addColorStop(0, "transparent"); grad.addColorStop(1, "oklch(0.16 0.014 250 / 0.6)"); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); requestAnimationFrame(frame); } requestAnimationFrame(frame); return () => { running = false; window.removeEventListener("mousemove", onMove); }; }, []); return