/* Cosmic plexus background — multilayered purple network/nebula scene. Layers (back-to-front): 1. radial nebula gradient + drifting fog blobs 2. ambient star dust (small slow particles) 3. flowing wave-grid of points (large undulating sheet, low-left) 4. plexus network (animated nodes + thin lines) Performance: single canvas, single rAF, pauses offscreen + on hidden tab, respects prefers-reduced-motion. */ const ParticleNetwork = () => { const ref = React.useRef(null); const wrapRef = React.useRef(null); React.useEffect(() => { const canvas = ref.current; const wrap = wrapRef.current; if (!canvas || !wrap) return; const ctx = canvas.getContext('2d'); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const dpr = Math.min(window.devicePixelRatio || 1, 2); let W = 0, H = 0; let nodes = []; // plexus let dust = []; // ambient stars let fog = []; // soft drifting glow blobs let wave; // flowing point-grid const mouse = { x: -9999, y: -9999, tx: -9999, ty: -9999 }; let running = true, visible = true; let t0 = performance.now(); function resize() { const rect = wrap.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = Math.floor(W * dpr); canvas.height = Math.floor(H * dpr); canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Plexus density tuned by area. Higher count for richness. const isMobile = W < 720; const nodeCount = isMobile ? 90 : Math.min(220, Math.floor((W * H) / 9000)); nodes = new Array(nodeCount).fill(0).map(() => spawnNode()); // Ambient dust const dustCount = isMobile ? 80 : 220; dust = new Array(dustCount).fill(0).map(() => ({ x: Math.random() * W, y: Math.random() * H, r: 0.4 + Math.random() * 1.1, a: 0.18 + Math.random() * 0.55, vx: (Math.random() - 0.5) * 0.04, vy: (Math.random() - 0.5) * 0.04, twk: Math.random() * Math.PI * 2, })); // Fog blobs — give the cosmic milky-way feel fog = [ { x: W * 0.18, y: H * 0.30, r: Math.max(W, H) * 0.55, hue: 270, sat: 70, l: 32, a: 0.18, vx: 0.02 }, { x: W * 0.78, y: H * 0.22, r: Math.max(W, H) * 0.50, hue: 280, sat: 65, l: 28, a: 0.14, vx: -0.015 }, { x: W * 0.50, y: H * 0.72, r: Math.max(W, H) * 0.55, hue: 265, sat: 75, l: 30, a: 0.13, vx: 0.018 }, { x: W * 0.20, y: H * 0.85, r: Math.max(W, H) * 0.40, hue: 290, sat: 60, l: 26, a: 0.10, vx: 0.025 }, ]; // Flowing wave grid — point sheet that undulates, anchored bottom-left const cols = isMobile ? 26 : 44; const rows = isMobile ? 16 : 22; wave = { cols, rows, spacingX: W / (cols - 1), spacingY: H * 0.55 / (rows - 1), originX: -W * 0.05, originY: H * 0.55, // tilt parameters (3D-ish skew) skew: 0.22, }; } function spawnNode() { // 80% violet, 15% bright violet, 5% warm coral hint const r = Math.random(); let col; if (r < 0.80) col = [165, 130, 255]; // violet primary else if (r < 0.95) col = [200, 170, 255]; // light violet else col = [251, 130, 150]; // warm pinch return { x: Math.random() * W, y: Math.random() * H, vx: (Math.random() - 0.5) * 0.22, vy: (Math.random() - 0.5) * 0.22, r: 1 + Math.random() * 1.6, col, a: 0.55 + Math.random() * 0.4, }; } function drawFog(time) { // Soft animated radial blobs that breathe and drift ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const f of fog) { f.x += f.vx; if (f.x < -f.r) f.x = W + f.r; if (f.x > W + f.r) f.x = -f.r; const breath = 0.85 + Math.sin(time * 0.0004 + f.hue) * 0.12; const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r * breath); grad.addColorStop(0, `hsla(${f.hue}, ${f.sat}%, ${f.l}%, ${f.a})`); grad.addColorStop(0.5, `hsla(${f.hue}, ${f.sat}%, ${f.l}%, ${f.a * 0.35})`); grad.addColorStop(1, `hsla(${f.hue}, ${f.sat}%, ${f.l}%, 0)`); ctx.fillStyle = grad; ctx.fillRect(f.x - f.r, f.y - f.r, f.r * 2, f.r * 2); } ctx.restore(); } function drawDust(time) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const d of dust) { d.x += d.vx; d.y += d.vy; if (d.x < 0) d.x = W; else if (d.x > W) d.x = 0; if (d.y < 0) d.y = H; else if (d.y > H) d.y = 0; const tw = 0.55 + Math.sin(time * 0.002 + d.twk) * 0.45; ctx.fillStyle = `rgba(220, 200, 255, ${d.a * tw})`; ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } function drawWave(time) { // Flowing point grid that undulates with 2-axis sin/cos noise. // Drawn with mild 3D perspective (skew + scale by row). ctx.save(); ctx.globalCompositeOperation = 'lighter'; const { cols, rows, spacingX, spacingY, originX, originY, skew } = wave; const tt = time * 0.0007; for (let j = 0; j < rows; j++) { const depth = j / (rows - 1); // 0 (front) → 1 (back) const persp = 1 - depth * 0.55; // points further away → smaller const yBase = originY + j * spacingY * 0.6; for (let i = 0; i < cols; i++) { const xBase = originX + i * spacingX + (j * spacingX * skew); const wave1 = Math.sin(i * 0.35 + j * 0.18 + tt * 1.6) * 14; const wave2 = Math.cos(i * 0.18 - j * 0.22 + tt * 1.1) * 10; const wave3 = Math.sin((i + j) * 0.12 + tt * 0.8) * 6; const yy = yBase + (wave1 + wave2 + wave3) * persp; const xx = xBase + Math.sin(j * 0.4 + tt * 0.7) * 8; // Fade by distance from center vertical const distFromCenter = Math.abs(xx - W * 0.5) / (W * 0.5); const horizFade = 1 - Math.min(1, Math.max(0, distFromCenter * 0.6)); const alpha = 0.55 * persp * horizFade; if (alpha < 0.04) continue; ctx.fillStyle = `rgba(170, 130, 255, ${alpha})`; ctx.beginPath(); ctx.arc(xx, yy, 1 * persp + 0.4, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); } function drawPlexus() { // Mouse damping mouse.x += (mouse.tx - mouse.x) * 0.06; mouse.y += (mouse.ty - mouse.y) * 0.06; // Update + draw nodes for (const p of nodes) { p.x += p.vx; p.y += p.vy; // gentle mouse repulsion if (mouse.x > -9000) { const dx = p.x - mouse.x; const dy = p.y - mouse.y; const d2 = dx*dx + dy*dy; const R = 110; if (d2 < R*R) { const d = Math.sqrt(d2) || 1; const f = (1 - d / R) * 0.6; p.x += (dx / d) * f; p.y += (dy / d) * f; } } if (p.x < -10) p.x = W + 10; if (p.x > W + 10) p.x = -10; if (p.y < -10) p.y = H + 10; if (p.y > H + 10) p.y = -10; } // Lines first (under nodes for cleaner glow) const maxLink = 150; ctx.lineWidth = 0.7; 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, dy = a.y - b.y; const d2 = dx*dx + dy*dy; if (d2 < maxLink * maxLink) { const d = Math.sqrt(d2); const t = 1 - d / maxLink; ctx.strokeStyle = `rgba(165, 130, 255, ${t * 0.32})`; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } // Nodes with subtle glow (lighter blend) ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const p of nodes) { const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 4); g.addColorStop(0, `rgba(${p.col[0]},${p.col[1]},${p.col[2]},${p.a})`); g.addColorStop(1, `rgba(${p.col[0]},${p.col[1]},${p.col[2]},0)`); ctx.fillStyle = g; ctx.fillRect(p.x - p.r * 4, p.y - p.r * 4, p.r * 8, p.r * 8); ctx.fillStyle = `rgba(${p.col[0]},${p.col[1]},${p.col[2]},${Math.min(1, p.a * 1.2)})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } function frame(now) { if (!running || !visible) { rafId = requestAnimationFrame(frame); return; } ctx.clearRect(0, 0, W, H); const time = now - t0; drawFog(time); drawWave(time); drawDust(time); drawPlexus(); rafId = requestAnimationFrame(frame); } let rafId; resize(); if (reduced) { // single static frame const time = 0; drawFog(time); drawWave(time); drawDust(time); drawPlexus(); return () => {}; } rafId = requestAnimationFrame(frame); function onResize() { resize(); } function onMouse(e) { const rect = wrap.getBoundingClientRect(); mouse.tx = e.clientX - rect.left; mouse.ty = e.clientY - rect.top; } function onLeave() { mouse.tx = -9999; mouse.ty = -9999; } function onVis() { visible = !document.hidden; } const io = new IntersectionObserver((entries) => { for (const e of entries) running = e.isIntersecting; }, { threshold: 0.05 }); io.observe(wrap); window.addEventListener('resize', onResize, { passive: true }); window.addEventListener('mousemove', onMouse, { passive: true }); wrap.addEventListener('mouseleave', onLeave, { passive: true }); document.addEventListener('visibilitychange', onVis); return () => { cancelAnimationFrame(rafId); window.removeEventListener('resize', onResize); window.removeEventListener('mousemove', onMouse); wrap.removeEventListener('mouseleave', onLeave); document.removeEventListener('visibilitychange', onVis); io.disconnect(); }; }, []); return (