"use client"; import React, { useEffect, useRef } from "react"; /* ============================================================ NeuralField — the animated neural-network + particle backdrop behind the MileTruth™ AI band on /solutions. Deliberately a plain 2D canvas rather than the R3F MileTruthCanvas scene: this one sits *behind* body copy at low opacity, so it has to stay cheap enough to run under the fold of a long marketing page. It follows the same rules as the other canvases on the site (WorkflowScene, MileTruthNetwork): · DPR-aware, resized from the parent box (ResizeObserver) · rAF paused while off-screen and while the tab is hidden · prefers-reduced-motion paints ONE static frame and stops Three layers, back to front: 1. particle field — tiny drifting motes, no links 2. neural mesh — drifting nodes + proximity links, accent-tinted as the two ends get closer 3. signals — a few bright packets travelling node-to-node, which is what reads as "a decision engine thinking" ============================================================ */ type Node = { x: number; y: number; vx: number; vy: number; r: number; hub: boolean; /** phase offset so hub glow does not pulse in lockstep */ ph: number; }; type Mote = { x: number; y: number; vy: number; sway: number; ph: number; a: number }; type Signal = { a: number; b: number; t: number; sp: number }; /** Distance at which two nodes stop being linked (CSS px). */ const LINK = 132; /** Pointer radius that brightens the mesh. */ const CURSOR = 170; function hexToRgb(hex: string): [number, number, number] { const h = hex.replace("#", ""); const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; const n = parseInt(full, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } export default function NeuralField({ accent = "#E2354A", tone = "dark", className, }: { accent?: string; /** Surface the field is painted on. "light" inks the mesh near-black instead * of white and drops the additive glow, which is invisible on paper. */ tone?: "dark" | "light"; className?: string; }) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const parent = canvas?.parentElement; if (!canvas || !parent) return; const ctx = canvas.getContext("2d", { alpha: true }); if (!ctx) return; const [ar, ag, ab] = hexToRgb(accent); const light = tone === "light"; // The ink the mesh fades towards when a link is long / far from the pointer. const [ir, ig, ib] = light ? [17, 17, 17] : [255, 255, 255]; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let w = 0; let h = 0; let nodes: Node[] = []; let motes: Mote[] = []; let signals: Signal[] = []; let raf = 0; let visible = true; let running = false; let last = 0; let nextSignal = 600; const pointer = { x: -9999, y: -9999 }; const rand = (a: number, b: number) => a + Math.random() * (b - a); function build() { const area = w * h; // One node per ~24k css px², clamped — enough to read as a mesh at // 1440px wide without turning the O(n²) link pass into real work. const count = Math.max(16, Math.min(72, Math.round(area / 24000))); nodes = Array.from({ length: count }, (_, i) => ({ x: Math.random() * w, y: Math.random() * h, vx: rand(-0.14, 0.14), vy: rand(-0.11, 0.11), r: rand(1.1, 2.3), hub: i % 9 === 0, ph: Math.random() * Math.PI * 2, })); motes = Array.from({ length: Math.min(140, Math.round(area / 9000)) }, () => ({ x: Math.random() * w, y: Math.random() * h, vy: rand(-0.22, -0.06), sway: rand(6, 20), ph: Math.random() * Math.PI * 2, a: rand(0.06, 0.28), })); signals = []; } function resize() { const rect = parent!.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(2, window.devicePixelRatio || 1); w = rect.width; h = rect.height; 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); build(); if (reduce) draw(0, 0); } function spawnSignal() { if (nodes.length < 2 || signals.length >= 3) return; const a = Math.floor(Math.random() * nodes.length); // Prefer a neighbour that is actually linked, so the packet rides a // visible edge instead of crossing empty space. let b = -1; let best = Infinity; for (let i = 0; i < nodes.length; i++) { if (i === a) continue; const d = Math.hypot(nodes[i].x - nodes[a].x, nodes[i].y - nodes[a].y); if (d < LINK && d < best && Math.random() > 0.45) { best = d; b = i; } } if (b < 0) return; signals.push({ a, b, t: 0, sp: rand(0.5, 0.95) }); } function draw(dt: number, time: number) { ctx!.clearRect(0, 0, w, h); /* ---- 1. particle field ---- */ for (const m of motes) { ctx!.beginPath(); ctx!.arc(m.x + Math.sin(time * 0.0004 + m.ph) * m.sway, m.y, 0.9, 0, Math.PI * 2); ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + m.a * (light ? 0.4 : 0.55) + ")"; ctx!.fill(); } /* ---- 2. neural mesh ---- */ 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 d2 = dx * dx + dy * dy; if (d2 > LINK * LINK) continue; const d = Math.sqrt(d2); const prox = 1 - d / LINK; // Near the pointer the mesh warms towards the accent and brightens. const mx = (a.x + b.x) * 0.5; const my = (a.y + b.y) * 0.5; const cur = Math.max(0, 1 - Math.hypot(mx - pointer.x, my - pointer.y) / CURSOR); const tint = Math.min(1, prox * 0.85 + cur * 0.6); const alpha = (prox * 0.3 + cur * 0.35) * (light ? 0.6 : 1); const r = Math.round(ir + (ar - ir) * tint); const g = Math.round(ig + (ag - ig) * tint); const bl = Math.round(ib + (ab - ib) * tint); ctx!.strokeStyle = "rgba(" + r + "," + g + "," + bl + "," + alpha.toFixed(3) + ")"; ctx!.beginPath(); ctx!.moveTo(a.x, a.y); ctx!.lineTo(b.x, b.y); ctx!.stroke(); } } for (const n of nodes) { const cur = Math.max(0, 1 - Math.hypot(n.x - pointer.x, n.y - pointer.y) / CURSOR); if (n.hub) { const pulse = 0.5 + 0.5 * Math.sin(time * 0.0016 + n.ph); ctx!.save(); // An additive bloom only reads on a dark surface; on paper it muddies. if (!light) { ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",0.9)"; ctx!.shadowBlur = 10 + pulse * 12; } ctx!.beginPath(); ctx!.arc(n.x, n.y, n.r + 0.7 + pulse * 0.6, 0, Math.PI * 2); ctx!.fillStyle = "rgba(" + ar + "," + ag + "," + ab + "," + (0.5 + pulse * 0.35) * (light ? 0.7 : 1) + ")"; ctx!.fill(); ctx!.restore(); } else { ctx!.beginPath(); ctx!.arc(n.x, n.y, n.r, 0, Math.PI * 2); ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + (0.16 + cur * 0.4) * (light ? 0.55 : 1) + ")"; ctx!.fill(); } } /* ---- 3. travelling signals ---- */ for (const s of signals) { const a = nodes[s.a]; const b = nodes[s.b]; if (!a || !b) continue; const x = a.x + (b.x - a.x) * s.t; const y = a.y + (b.y - a.y) * s.t; // Fade in and out so packets never pop at the endpoints. const fade = Math.sin(s.t * Math.PI); ctx!.save(); if (!light) { ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",1)"; ctx!.shadowBlur = 14; } ctx!.beginPath(); ctx!.arc(x, y, 1.9, 0, Math.PI * 2); // On paper the packet is the accent itself; on dark it burns towards white. ctx!.fillStyle = light ? "rgba(" + ar + "," + ag + "," + ab + "," + fade + ")" : "rgba(255," + Math.round(ag * 0.7 + 90) + "," + Math.round(ab * 0.7 + 90) + "," + fade + ")"; ctx!.fill(); ctx!.restore(); } if (reduce) return; /* ---- integrate ---- */ const step = Math.min(3, dt / 16.67); for (const n of nodes) { n.x += n.vx * step; n.y += n.vy * step; 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; } for (const m of motes) { m.y += m.vy * step; if (m.y < -10) { m.y = h + 10; m.x = Math.random() * w; } } for (let i = signals.length - 1; i >= 0; i--) { signals[i].t += (signals[i].sp * step) / 60; if (signals[i].t >= 1) signals.splice(i, 1); } nextSignal -= dt; if (nextSignal <= 0) { spawnSignal(); nextSignal = rand(700, 1800); } } function frame(time: number) { const dt = last ? time - last : 16.67; last = time; draw(dt, time); raf = requestAnimationFrame(frame); } function start() { if (running || reduce) return; running = true; last = 0; raf = requestAnimationFrame(frame); } function stop() { running = false; cancelAnimationFrame(raf); } const ro = new ResizeObserver(resize); ro.observe(parent); resize(); const io = new IntersectionObserver( (entries) => { visible = entries.some((e) => e.isIntersecting); if (visible && !document.hidden) start(); else stop(); }, { rootMargin: "120px 0px" }, ); io.observe(canvas); const onVisibility = () => { if (document.hidden) stop(); else if (visible) start(); }; const onMove = (e: PointerEvent) => { const rect = canvas.getBoundingClientRect(); pointer.x = e.clientX - rect.left; pointer.y = e.clientY - rect.top; }; const onLeave = () => { pointer.x = -9999; pointer.y = -9999; }; document.addEventListener("visibilitychange", onVisibility); // Listener on the section (not the canvas — it is pointer-events: none). const host = parent.closest("section") ?? parent; host.addEventListener("pointermove", onMove as EventListener); host.addEventListener("pointerleave", onLeave); return () => { stop(); ro.disconnect(); io.disconnect(); document.removeEventListener("visibilitychange", onVisibility); host.removeEventListener("pointermove", onMove as EventListener); host.removeEventListener("pointerleave", onLeave); }; }, [accent, tone]); return