"use client"; import { useEffect, useRef } from "react"; type Particle = { x: number; y: number; vx: number; vy: number; baseAlpha: number; }; const ACCENT = [250, 204, 13] as const; // --accent-500 const LINK_DISTANCE = 140; const POINTER_RADIUS = 220; /** * Particle constellation canvas. Subtle drift; pointer attraction within radius. * Particles near pointer tint to accent. Respects prefers-reduced-motion (no drift). * Mount as absolute child inside a feature slide. */ export function HeroCanvas() { const ref = useRef(null); useEffect(() => { const canvas = ref.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; let particles: Particle[] = []; let rafId = 0; const pointer = { x: -1000, y: -1000, active: false }; const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); function resize() { if (!canvas || !ctx) return; const parent = canvas.parentElement; if (!parent) return; const { width, height } = parent.getBoundingClientRect(); canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const area = width * height; const target = Math.min(120, Math.max(50, Math.floor(area / 14000))); particles = Array.from({ length: target }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.18, vy: (Math.random() - 0.5) * 0.18, baseAlpha: 0.25 + Math.random() * 0.45, })); } function step() { if (!canvas || !ctx) return; const width = canvas.width / dpr; const height = canvas.height / dpr; ctx.clearRect(0, 0, width, height); for (let i = 0; i < particles.length; i++) { const p = particles[i]; if (!reduce) { p.x += p.vx; p.y += p.vy; if (p.x < -10) p.x = width + 10; if (p.x > width + 10) p.x = -10; if (p.y < -10) p.y = height + 10; if (p.y > height + 10) p.y = -10; } let glow = 0; if (pointer.active) { const dx = p.x - pointer.x; const dy = p.y - pointer.y; const d2 = dx * dx + dy * dy; if (d2 < POINTER_RADIUS * POINTER_RADIUS) { const t = 1 - Math.sqrt(d2) / POINTER_RADIUS; glow = t; if (!reduce) { p.vx += (-dx / POINTER_RADIUS) * 0.0012; p.vy += (-dy / POINTER_RADIUS) * 0.0012; } } } p.vx *= 0.985; p.vy *= 0.985; const alpha = p.baseAlpha * 0.35 + glow * 0.85; if (glow > 0.02) { ctx.fillStyle = `rgba(${ACCENT[0]},${ACCENT[1]},${ACCENT[2]},${alpha})`; } else { ctx.fillStyle = `rgba(255,255,255,${p.baseAlpha * 0.35})`; } ctx.beginPath(); ctx.arc(p.x, p.y, 1.2 + glow * 1.4, 0, Math.PI * 2); ctx.fill(); } for (let i = 0; i < particles.length; i++) { const a = particles[i]; for (let j = i + 1; j < particles.length; j++) { const b = particles[j]; const dx = a.x - b.x; const dy = a.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < LINK_DISTANCE * LINK_DISTANCE) { const d = Math.sqrt(d2); const t = 1 - d / LINK_DISTANCE; let pointerBoost = 0; if (pointer.active) { const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; const pdx = mx - pointer.x; const pdy = my - pointer.y; const pd2 = pdx * pdx + pdy * pdy; if (pd2 < POINTER_RADIUS * POINTER_RADIUS) { pointerBoost = (1 - Math.sqrt(pd2) / POINTER_RADIUS) * 0.6; } } const lineAlpha = t * (0.1 + pointerBoost * 0.45); if (pointerBoost > 0.02) { ctx.strokeStyle = `rgba(${ACCENT[0]},${ACCENT[1]},${ACCENT[2]},${lineAlpha})`; } else { ctx.strokeStyle = `rgba(255,255,255,${lineAlpha * 0.55})`; } ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } rafId = requestAnimationFrame(step); } function onPointerMove(e: PointerEvent) { if (!canvas) return; const rect = canvas.getBoundingClientRect(); pointer.x = e.clientX - rect.left; pointer.y = e.clientY - rect.top; pointer.active = true; } function onPointerLeave() { pointer.active = false; pointer.x = -1000; pointer.y = -1000; } resize(); window.addEventListener("resize", resize); window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerleave", onPointerLeave); rafId = requestAnimationFrame(step); return () => { cancelAnimationFrame(rafId); window.removeEventListener("resize", resize); window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerleave", onPointerLeave); }; }, []); return ( ); }