Files
doormile_react/src/components/sections/NeuralField.tsx
2026-07-29 17:47:05 +05:30

333 lines
11 KiB
TypeScript

"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<HTMLCanvasElement>(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 <canvas ref={canvasRef} className={className} aria-hidden="true" />;
}