// ParticleNetwork.jsx — lightweight Canvas 2D network // Agents as nodes, data flowing between them. Mouse-reactive + scroll-reactive. // Keeps particle count modest for mobile performance. function ParticleNetwork({ density = 1, accent = '#ff5a1f', accent2 = '#ffa726', ink = '#f5f2ec', speed = 1, showLabels = true, dark = true, }) { const canvasRef = React.useRef(null); const wrapRef = React.useRef(null); const stateRef = React.useRef({ nodes: [], mouse: { x: -1000, y: -1000, active: false }, scroll: 0, tick: 0, rafId: 0, size: { w: 0, h: 0, dpr: 1 }, }); // Key tool labels (drawn near anchor nodes for "technical" feel) const LABELS = React.useMemo(() => [ 'n8n', 'Claude', 'HubSpot', 'Salesforce', 'Apollo', 'Supabase', 'Airtable', 'Close', 'Zoominfo', 'Vercel', ], []); const init = React.useCallback(() => { const canvas = canvasRef.current; const wrap = wrapRef.current; if (!canvas || !wrap) return; const rect = wrap.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; stateRef.current.size = { w: rect.width, h: rect.height, dpr }; // Node count based on area, capped for mobile const area = rect.width * rect.height; const target = Math.min(90, Math.max(28, Math.round(area / 14000 * density))); const nodes = []; for (let i = 0; i < target; i++) { const labelIdx = i < LABELS.length ? i : -1; nodes.push({ x: Math.random() * rect.width, y: Math.random() * rect.height, z: Math.random() * 0.8 + 0.2, // depth 0.2..1 vx: (Math.random() - 0.5) * 0.25, vy: (Math.random() - 0.5) * 0.25, r: Math.random() * 1.8 + 1.0, label: labelIdx >= 0 ? LABELS[labelIdx] : null, pulse: Math.random() * Math.PI * 2, pulseSpeed: 0.01 + Math.random() * 0.02, }); } // Add a few "packet" particles that travel between nodes stateRef.current.nodes = nodes; stateRef.current.packets = Array.from({ length: 8 }, () => spawnPacket(nodes)); }, [density, LABELS]); function spawnPacket(nodes) { const from = nodes[(Math.random() * nodes.length) | 0]; const to = nodes[(Math.random() * nodes.length) | 0]; return { from, to, t: 0, speed: 0.004 + Math.random() * 0.008 }; } const render = React.useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const { w, h, dpr } = stateRef.current.size; const { nodes, mouse } = stateRef.current; const packets = stateRef.current.packets || []; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); stateRef.current.tick += 1; // Update nodes for (const n of nodes) { n.x += n.vx * speed; n.y += n.vy * speed; n.pulse += n.pulseSpeed; // Mouse repel (subtle) if (mouse.active) { const dx = n.x - mouse.x; const dy = n.y - mouse.y; const d2 = dx * dx + dy * dy; if (d2 < 180 * 180) { const d = Math.sqrt(d2) || 1; const f = (180 - d) / 180 * 0.6; n.x += (dx / d) * f; n.y += (dy / d) * f; } } if (n.x < -20) n.x = w + 20; if (n.x > w + 20) n.x = -20; if (n.y < -20) n.y = h + 20; if (n.y > h + 20) n.y = -20; } // Links const maxDist = 130; for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const a = nodes[i], b = nodes[j]; const dx = a.x - b.x, dy = a.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < maxDist * maxDist) { const d = Math.sqrt(d2); const alpha = (1 - d / maxDist) * 0.35 * Math.min(a.z, b.z); ctx.strokeStyle = hexWithAlpha(accent, alpha); ctx.lineWidth = 0.6; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } // Links to mouse if (mouse.active) { for (const n of nodes) { const dx = n.x - mouse.x, dy = n.y - mouse.y; const d2 = dx * dx + dy * dy; if (d2 < 180 * 180) { const d = Math.sqrt(d2); const alpha = (1 - d / 180) * 0.7; ctx.strokeStyle = hexWithAlpha(accent2, alpha); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke(); } } } // Nodes for (const n of nodes) { const pulseScale = 1 + Math.sin(n.pulse) * 0.25; const r = n.r * pulseScale * n.z; // Glow const g = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, r * 8); g.addColorStop(0, hexWithAlpha(accent2, 0.35 * n.z)); g.addColorStop(1, hexWithAlpha(accent2, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(n.x, n.y, r * 8, 0, Math.PI * 2); ctx.fill(); // Core ctx.fillStyle = hexWithAlpha(accent, 0.8 + 0.2 * n.z); ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.fill(); // Label if (showLabels && n.label) { ctx.font = '10px "JetBrains Mono", ui-monospace, monospace'; ctx.fillStyle = 'rgba(245,242,236,0.5)'; ctx.fillText(n.label.toUpperCase(), n.x + 10, n.y + 4); } } // Packets along lines for (const p of packets) { p.t += p.speed * speed; if (p.t >= 1) { const next = spawnPacket(nodes); Object.assign(p, next); continue; } const x = p.from.x + (p.to.x - p.from.x) * p.t; const y = p.from.y + (p.to.y - p.from.y) * p.t; // Trail ctx.strokeStyle = hexWithAlpha(accent2, 0.55); ctx.lineWidth = 1.6; const tailT = Math.max(0, p.t - 0.08); const tx = p.from.x + (p.to.x - p.from.x) * tailT; const ty = p.from.y + (p.to.y - p.from.y) * tailT; ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(x, y); ctx.stroke(); // Head ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(x, y, 2.2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = hexWithAlpha(accent2, 0.9); ctx.beginPath(); ctx.arc(x, y, 3.6, 0, Math.PI * 2); ctx.fill(); } stateRef.current.rafId = requestAnimationFrame(render); }, [accent, accent2, speed, showLabels, dark, ink]); React.useEffect(() => { init(); stateRef.current.rafId = requestAnimationFrame(render); const onResize = () => { init(); }; const wrap = wrapRef.current; const onMove = (e) => { const rect = wrap.getBoundingClientRect(); stateRef.current.mouse.x = e.clientX - rect.left; stateRef.current.mouse.y = e.clientY - rect.top; stateRef.current.mouse.active = true; }; const onLeave = () => { stateRef.current.mouse.active = false; }; window.addEventListener('resize', onResize); window.addEventListener('mousemove', onMove); wrap.addEventListener('mouseleave', onLeave); return () => { cancelAnimationFrame(stateRef.current.rafId); window.removeEventListener('resize', onResize); window.removeEventListener('mousemove', onMove); wrap.removeEventListener('mouseleave', onLeave); }; }, [init, render]); return (