333 lines
11 KiB
TypeScript
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" />;
|
|
}
|