/* ============================================================================ particles.js — ambient "network of machines" field. Self-contained, no dependencies. Mounts a into a host element and draws slow-drifting nodes with proximity links. import { mountParticles } from '/particles.js'; const field = mountParticles(document.getElementById('bg-field')); field.setMode('static'); // freeze: draws one frame, then zero cost field.destroy(); Budget rules baked in, because this runs on whatever desktop is on the desk: - node count is derived from viewport area and hard-capped - device pixel ratio is capped at 2 - the loop is throttled to ~30fps and uses no shadows or gradients - rAF is cancelled outright when the tab is hidden or the mode is static - prefers-reduced-motion renders a single static frame and never loops ========================================================================= */ 'use strict'; const DEFAULTS = { /* one node per this many CSS pixels of area */ areaPerNode: 26000, minNodes: 14, maxNodes: 78, /* proximity links */ linkDistance: 138, linkAlpha: 0.20, /* nodes */ nodeAlpha: 0.62, nodeSize: 2, hubEvery: 7, /* every Nth node is drawn as a larger "hub" */ /* drift, CSS px per second */ speed: 7, fps: 30, colors: ['#3ddc97', '#3ddc97', '#3ddc97', '#9aa2ff', '#e9a05c'], linkColor: '61, 220, 151', mode: 'animate', }; const NOOP_HANDLE = { setMode() {}, destroy() {}, canvas: null, }; export function mountParticles(host, options = {}) { if (!host || typeof document === 'undefined') return NOOP_HANDLE; const cfg = { ...DEFAULTS, ...options }; const canvas = document.createElement('canvas'); canvas.setAttribute('aria-hidden', 'true'); const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true }); if (!ctx) return NOOP_HANDLE; host.append(canvas); const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); let nodes = []; let width = 0; let height = 0; let raf = 0; let lastFrame = 0; let mode = cfg.mode; let destroyed = false; /* ------------------------------------------------------------ geometry */ function targetCount() { const raw = Math.round((width * height) / cfg.areaPerNode); return Math.max(cfg.minNodes, Math.min(cfg.maxNodes, raw)); } function makeNode(index) { const angle = Math.random() * Math.PI * 2; return { x: Math.random() * width, y: Math.random() * height, vx: Math.cos(angle) * cfg.speed * (0.35 + Math.random() * 0.85), vy: Math.sin(angle) * cfg.speed * (0.35 + Math.random() * 0.85), color: cfg.colors[index % cfg.colors.length], hub: index % cfg.hubEvery === 0, /* per-node brightness keeps the field from looking like a lattice */ alpha: cfg.nodeAlpha * (0.45 + Math.random() * 0.55), }; } function reconcileNodes() { const want = targetCount(); while (nodes.length > want) nodes.pop(); while (nodes.length < want) nodes.push(makeNode(nodes.length)); for (const n of nodes) { if (n.x > width) n.x = Math.random() * width; if (n.y > height) n.y = Math.random() * height; } } function resize() { if (destroyed) return; const w = host.clientWidth || window.innerWidth; const h = host.clientHeight || window.innerHeight; if (!w || !h) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); width = w; height = h; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); reconcileNodes(); if (!running()) draw(); } /* --------------------------------------------------------------- paint */ function draw() { ctx.clearRect(0, 0, width, height); /* links first, so nodes sit on top of them */ const max = cfg.linkDistance; const maxSq = max * max; ctx.lineWidth = 1; 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 distSq = dx * dx + dy * dy; if (distSq > maxSq) continue; const strength = 1 - Math.sqrt(distSq) / max; ctx.strokeStyle = `rgba(${cfg.linkColor}, ${(strength * strength * cfg.linkAlpha).toFixed(3)})`; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } /* nodes: small squares read as devices, not as bokeh */ ctx.globalAlpha = 1; for (const n of nodes) { const s = n.hub ? cfg.nodeSize + 1.5 : cfg.nodeSize; ctx.fillStyle = withAlpha(n.color, n.alpha); ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s); if (n.hub) { ctx.strokeStyle = withAlpha(n.color, n.alpha * 0.30); ctx.strokeRect(n.x - s * 1.6, n.y - s * 1.6, s * 3.2, s * 3.2); } } } function step(now) { raf = window.requestAnimationFrame(step); const interval = 1000 / cfg.fps; const elapsed = now - lastFrame; if (elapsed < interval) return; /* keep the phase, but never integrate a huge dt after a stall */ lastFrame = now - (elapsed % interval); const dt = Math.min(elapsed, 100) / 1000; for (const n of nodes) { n.x += n.vx * dt; n.y += n.vy * dt; if (n.x < 0) { n.x = 0; n.vx = -n.vx; } else if (n.x > width) { n.x = width; n.vx = -n.vx; } if (n.y < 0) { n.y = 0; n.vy = -n.vy; } else if (n.y > height) { n.y = height; n.vy = -n.vy; } } draw(); } /* ------------------------------------------------------------ lifecycle */ function running() { return raf !== 0; } function shouldAnimate() { return !destroyed && mode === 'animate' && !document.hidden && !motionQuery.matches; } function stop() { if (raf) window.cancelAnimationFrame(raf); raf = 0; } function sync() { if (shouldAnimate()) { if (!running()) { lastFrame = performance.now(); raf = window.requestAnimationFrame(step); } return; } stop(); if (!destroyed && mode !== 'off') draw(); if (mode === 'off') ctx.clearRect(0, 0, width, height); } /* ------------------------------------------------------------- plumbing */ let resizeTimer = 0; const onResize = () => { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(resize, 140); }; const onVisibility = () => sync(); window.addEventListener('resize', onResize, { passive: true }); document.addEventListener('visibilitychange', onVisibility); addMediaListener(motionQuery, sync); let observer = null; if (typeof ResizeObserver !== 'undefined') { observer = new ResizeObserver(onResize); observer.observe(host); } resize(); sync(); return { canvas, /** 'animate' | 'static' (one frozen frame) | 'off' (blank) */ setMode(next) { if (next === mode) return; mode = next; sync(); }, destroy() { destroyed = true; stop(); window.clearTimeout(resizeTimer); window.removeEventListener('resize', onResize); document.removeEventListener('visibilitychange', onVisibility); removeMediaListener(motionQuery, sync); observer?.disconnect(); canvas.remove(); }, }; } /* ------------------------------------------------------------------ utils */ function withAlpha(hex, alpha) { const h = hex.replace('#', ''); const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16); const r = (n >> 16) & 255; const g = (n >> 8) & 255; const b = n & 255; return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`; } /* Safari < 14 only has the deprecated listener API. */ function addMediaListener(query, fn) { if (query.addEventListener) query.addEventListener('change', fn); else if (query.addListener) query.addListener(fn); } function removeMediaListener(query, fn) { if (query.removeEventListener) query.removeEventListener('change', fn); else if (query.removeListener) query.removeListener(fn); } export default mountParticles;