Files
site/src/components/layout/background.tsx
2026-03-15 22:47:27 +01:00

357 lines
11 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback } from "react";
interface Node {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
isHub: boolean;
pulsePhase: number;
radius: number;
}
interface Pulse {
edgeIdx: number;
progress: number;
speed: number;
direction: 1 | -1;
}
const NODE_COUNT = 80;
const MAX_EDGE_DIST = 180;
const MOUSE_RADIUS = 250;
const MOUSE_REPEL = 0.3;
const DRIFT_SPEED = 0.15;
const RETURN_FORCE = 0.005;
function createNodes(w: number, h: number): Node[] {
const nodes: Node[] = [];
for (let i = 0; i < NODE_COUNT; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
nodes.push({
x,
y,
baseX: x,
baseY: y,
vx: (Math.random() - 0.5) * DRIFT_SPEED,
vy: (Math.random() - 0.5) * DRIFT_SPEED,
isHub: i < 12,
pulsePhase: Math.random() * Math.PI * 2,
radius: i < 12 ? 2.5 : 1.2,
});
}
return nodes;
}
function buildEdges(nodes: Node[]): [number, number][] {
const edges: [number, number][] = [];
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[i].x - nodes[j].x;
const dy = nodes[i].y - nodes[j].y;
if (dx * dx + dy * dy < MAX_EDGE_DIST * MAX_EDGE_DIST) {
edges.push([i, j]);
}
}
}
return edges;
}
export function Background() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const nodesRef = useRef<Node[]>([]);
const pulsesRef = useRef<Pulse[]>([]);
const mouseRef = useRef({ x: -1000, y: -1000 });
const frameRef = useRef<number>(0);
const timeRef = useRef(0);
const spawnPulse = useCallback((edges: [number, number][]) => {
if (edges.length === 0) return;
const idx = Math.floor(Math.random() * edges.length);
pulsesRef.current.push({
edgeIdx: idx,
progress: 0,
speed: 0.008 + Math.random() * 0.012,
direction: Math.random() > 0.5 ? 1 : -1,
});
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let w = window.innerWidth;
let h = window.innerHeight;
canvas.width = w;
canvas.height = h;
nodesRef.current = createNodes(w, h);
pulsesRef.current = [];
const handleResize = () => {
w = window.innerWidth;
h = window.innerHeight;
canvas.width = w;
canvas.height = h;
nodesRef.current = createNodes(w, h);
pulsesRef.current = [];
};
const handleMouseMove = (e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
};
const handleMouseLeave = () => {
mouseRef.current = { x: -1000, y: -1000 };
};
window.addEventListener("resize", handleResize);
window.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseleave", handleMouseLeave);
const animate = () => {
timeRef.current += 1;
const nodes = nodesRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
ctx.clearRect(0, 0, w, h);
// Update node positions
for (const node of nodes) {
// Drift
node.x += node.vx;
node.y += node.vy;
// Return to base
node.vx += (node.baseX - node.x) * RETURN_FORCE;
node.vy += (node.baseY - node.y) * RETURN_FORCE;
// Damping
node.vx *= 0.99;
node.vy *= 0.99;
// Mouse interaction — repel
const dmx = node.x - mx;
const dmy = node.y - my;
const distMouse = Math.sqrt(dmx * dmx + dmy * dmy);
if (distMouse < MOUSE_RADIUS && distMouse > 0) {
const force = (1 - distMouse / MOUSE_RADIUS) * MOUSE_REPEL;
node.vx += (dmx / distMouse) * force;
node.vy += (dmy / distMouse) * force;
}
// Boundary wrap
if (node.x < -20) node.x = w + 20;
if (node.x > w + 20) node.x = -20;
if (node.y < -20) node.y = h + 20;
if (node.y > h + 20) node.y = -20;
}
// Build edges dynamically
const edges = buildEdges(nodes);
// Draw edges
for (let i = 0; i < edges.length; i++) {
const [a, b] = edges[i];
const dx = nodes[a].x - nodes[b].x;
const dy = nodes[a].y - nodes[b].y;
const dist = Math.sqrt(dx * dx + dy * dy);
const alpha = (1 - dist / MAX_EDGE_DIST) * 0.12;
// Brighter edges near mouse
const edgeMx = (nodes[a].x + nodes[b].x) / 2;
const edgeMy = (nodes[a].y + nodes[b].y) / 2;
const edgeMouseDist = Math.sqrt(
(edgeMx - mx) ** 2 + (edgeMy - my) ** 2
);
const mouseBoost =
edgeMouseDist < MOUSE_RADIUS
? (1 - edgeMouseDist / MOUSE_RADIUS) * 0.2
: 0;
ctx.beginPath();
ctx.moveTo(nodes[a].x, nodes[a].y);
ctx.lineTo(nodes[b].x, nodes[b].y);
ctx.strokeStyle = `rgba(192, 119, 144, ${alpha + mouseBoost})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
// Spawn pulses
if (timeRef.current % 8 === 0) {
spawnPulse(edges);
}
// Draw & update pulses
const activePulses: Pulse[] = [];
for (const pulse of pulsesRef.current) {
pulse.progress += pulse.speed;
if (pulse.progress > 1) {
// Chain reaction — sometimes spawn a new pulse from the destination node
if (Math.random() < 0.4 && pulse.edgeIdx < edges.length) {
const destNode =
pulse.direction === 1
? edges[pulse.edgeIdx][1]
: edges[pulse.edgeIdx][0];
// Find connected edges from destination
const connected = edges
.map((e, idx) => ({ e, idx }))
.filter(
({ e, idx }) =>
idx !== pulse.edgeIdx &&
(e[0] === destNode || e[1] === destNode)
);
if (connected.length > 0) {
const next =
connected[Math.floor(Math.random() * connected.length)];
activePulses.push({
edgeIdx: next.idx,
progress: 0,
speed: 0.01 + Math.random() * 0.01,
direction:
next.e[0] === destNode ? 1 : -1,
});
}
}
continue;
}
if (pulse.edgeIdx >= edges.length) continue;
const [a, b] = edges[pulse.edgeIdx];
const na = nodes[a];
const nb = nodes[b];
const t = pulse.direction === 1 ? pulse.progress : 1 - pulse.progress;
const px = na.x + (nb.x - na.x) * t;
const py = na.y + (nb.y - na.y) * t;
// Pulse head glow
const gradient = ctx.createRadialGradient(px, py, 0, px, py, 12);
gradient.addColorStop(0, "rgba(192, 119, 144, 0.6)");
gradient.addColorStop(0.5, "rgba(192, 119, 144, 0.15)");
gradient.addColorStop(1, "rgba(192, 119, 144, 0)");
ctx.beginPath();
ctx.arc(px, py, 12, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// Bright core
ctx.beginPath();
ctx.arc(px, py, 2, 0, Math.PI * 2);
ctx.fillStyle = "rgba(219, 168, 184, 0.8)";
ctx.fill();
// Trail
const trailLen = 0.15;
const tStart = Math.max(0, t - trailLen);
const sx = na.x + (nb.x - na.x) * tStart;
const sy = na.y + (nb.y - na.y) * tStart;
const trailGrad = ctx.createLinearGradient(sx, sy, px, py);
trailGrad.addColorStop(0, "rgba(192, 119, 144, 0)");
trailGrad.addColorStop(1, "rgba(192, 119, 144, 0.3)");
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(px, py);
ctx.strokeStyle = trailGrad;
ctx.lineWidth = 1.5;
ctx.stroke();
activePulses.push(pulse);
}
pulsesRef.current = activePulses;
// Draw nodes
for (const node of nodes) {
const pulse =
Math.sin(timeRef.current * 0.03 + node.pulsePhase) * 0.5 + 0.5;
// Mouse proximity glow
const dMouse = Math.sqrt(
(node.x - mx) ** 2 + (node.y - my) ** 2
);
const mouseGlow =
dMouse < MOUSE_RADIUS ? (1 - dMouse / MOUSE_RADIUS) : 0;
if (node.isHub) {
// Hub outer glow
const glowR = node.radius * (3 + pulse * 2) + mouseGlow * 8;
const glowAlpha = 0.06 + pulse * 0.04 + mouseGlow * 0.1;
const gradient = ctx.createRadialGradient(
node.x, node.y, 0,
node.x, node.y, glowR
);
gradient.addColorStop(0, `rgba(192, 119, 144, ${glowAlpha})`);
gradient.addColorStop(1, "rgba(192, 119, 144, 0)");
ctx.beginPath();
ctx.arc(node.x, node.y, glowR, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// Hub core
ctx.beginPath();
ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(192, 119, 144, ${0.4 + pulse * 0.2 + mouseGlow * 0.3})`;
ctx.fill();
} else {
ctx.beginPath();
ctx.arc(node.x, node.y, node.radius + mouseGlow * 1.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(120, 113, 108, ${0.15 + mouseGlow * 0.4})`;
ctx.fill();
}
}
frameRef.current = requestAnimationFrame(animate);
};
frameRef.current = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(frameRef.current);
window.removeEventListener("resize", handleResize);
window.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseleave", handleMouseLeave);
};
}, [spawnPulse]);
return (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
{/* Primary glow — top center */}
<div
className="absolute -top-[20%] left-1/2 h-[800px] w-[800px] -translate-x-1/2 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(122,59,78,0.15) 0%, rgba(122,59,78,0.05) 40%, transparent 70%)",
}}
/>
{/* Accent glow — bottom right */}
<div
className="absolute -bottom-[10%] -right-[10%] h-[600px] w-[600px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(194,112,62,0.08) 0%, rgba(194,112,62,0.03) 40%, transparent 70%)",
}}
/>
{/* Secondary glow — left */}
<div
className="absolute top-[40%] -left-[10%] h-[500px] w-[500px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(74,107,130,0.06) 0%, transparent 60%)",
}}
/>
</div>
);
}