357 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|