'use client'; import { useEffect, useRef } from 'react'; /** * Lightweight 2D hex-grid particle network. * - Nodes drift slowly, repelled by the cursor. * - Edges drawn between nearby nodes form a connection mesh. * - Pauses when the tab is hidden or the section scrolls offscreen. */ export function ParticleCanvas() { const ref = useRef(null); useEffect(() => { const canvas = ref.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const DPR = Math.min(window.devicePixelRatio || 1, 2); let width = 0; let height = 0; let raf = 0; let running = true; const mouse = { x: -9999, y: -9999, active: false }; type Node = { x: number; y: number; vx: number; vy: number; r: number; hue: number }; let nodes: Node[] = []; const COLORS = [ { r: 56, g: 189, b: 248 }, // electric { r: 129, g: 140, b: 248 }, // violet { r: 232, g: 121, b: 249 }, // magenta { r: 34, g: 211, b: 238 }, // cyan ]; const seed = () => { const area = width * height; const density = window.matchMedia('(max-width: 640px)').matches ? 14000 : 9000; const count = Math.min(140, Math.max(40, Math.floor(area / density))); nodes = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.18, vy: (Math.random() - 0.5) * 0.18, r: 0.8 + Math.random() * 1.6, hue: Math.floor(Math.random() * COLORS.length), })); }; const resize = () => { const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height; canvas.width = Math.floor(width * DPR); canvas.height = Math.floor(height * DPR); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); seed(); }; const onMove = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect(); mouse.x = e.clientX - rect.left; mouse.y = e.clientY - rect.top; mouse.active = true; }; const onLeave = () => { mouse.active = false; mouse.x = -9999; mouse.y = -9999; }; const onVisibility = () => { running = !document.hidden; if (running && !raf) raf = requestAnimationFrame(tick); }; const tick = () => { raf = 0; if (!running) return; ctx.clearRect(0, 0, width, height); // Drift + cursor repel for (const n of nodes) { n.x += n.vx; n.y += n.vy; // Wrap around edges if (n.x < -10) n.x = width + 10; else if (n.x > width + 10) n.x = -10; if (n.y < -10) n.y = height + 10; else if (n.y > height + 10) n.y = -10; if (mouse.active) { const dx = n.x - mouse.x; const dy = n.y - mouse.y; const d2 = dx * dx + dy * dy; const R = 140; if (d2 < R * R && d2 > 0.01) { const d = Math.sqrt(d2); const force = (R - d) / R; n.x += (dx / d) * force * 2.4; n.y += (dy / d) * force * 2.4; } } } // Edges const LINK_DIST = 130; for (let i = 0; i < nodes.length; i++) { const a = nodes[i]; for (let j = i + 1; j < nodes.length; j++) { const b = nodes[j]; const dx = a.x - b.x; const dy = a.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < LINK_DIST * LINK_DIST) { const d = Math.sqrt(d2); const alpha = (1 - d / LINK_DIST) * 0.35; const ca = COLORS[a.hue]; const cb = COLORS[b.hue]; const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y); grad.addColorStop(0, `rgba(${ca.r},${ca.g},${ca.b},${alpha})`); grad.addColorStop(1, `rgba(${cb.r},${cb.g},${cb.b},${alpha})`); ctx.strokeStyle = grad; ctx.lineWidth = 0.7; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } // Nodes for (const n of nodes) { const c = COLORS[n.hue]; ctx.beginPath(); ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.85)`; ctx.shadowBlur = 8; ctx.shadowColor = `rgba(${c.r},${c.g},${c.b},0.55)`; ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2); ctx.fill(); } ctx.shadowBlur = 0; raf = requestAnimationFrame(tick); }; resize(); raf = requestAnimationFrame(tick); window.addEventListener('resize', resize); window.addEventListener('mousemove', onMove); window.addEventListener('mouseleave', onLeave); document.addEventListener('visibilitychange', onVisibility); return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', resize); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseleave', onLeave); document.removeEventListener('visibilitychange', onVisibility); }; }, []); return ( ); }