This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Ambient network-traffic background for the login screen.
|
||||
// Procedural node graph biased to the edges, with pulses that travel edges
|
||||
// like packets. Center-right stays calm so the sign-in card reads cleanly.
|
||||
|
||||
interface Node {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
flash: number; // 0..1, brief brighten when a packet arrives
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
a: number;
|
||||
b: number;
|
||||
len: number;
|
||||
}
|
||||
|
||||
interface Pulse {
|
||||
edge: number;
|
||||
dir: 1 | -1; // travel a->b or b->a
|
||||
t: number; // 0..1 progress
|
||||
speed: number; // progress per second
|
||||
delay: number; // seconds until it starts
|
||||
life: number; // seconds since spawn
|
||||
}
|
||||
|
||||
const BG_TOP = "#0B2A58";
|
||||
const BG_BOTTOM = "#0B1120";
|
||||
const NODE_COLOR = "94, 122, 168"; // steel navy, rgb parts for alpha use
|
||||
const LINE_COLOR = "30, 58, 95"; // #1E3A5F
|
||||
const PULSE_COLOR = "93, 202, 165"; // #5DCAA5
|
||||
const PULSE_CORE = "29, 158, 117"; // #1D9E75
|
||||
|
||||
const MAX_DIST = 190; // connect nodes within this px
|
||||
const NODE_DENSITY = 1 / 22000; // nodes per px^2
|
||||
const CALM_CENTER = { cx: 0.62, cy: 0.5, rx: 0.28, ry: 0.34 }; // normalised ellipse to avoid
|
||||
|
||||
export function NetworkBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
let nodes: Node[] = [];
|
||||
let edges: Edge[] = [];
|
||||
let pulses: Pulse[] = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
let raf = 0;
|
||||
let lastTime = 0;
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Distance (0..1+) into the calm zone; <1 means inside the avoid ellipse.
|
||||
function calmFactor(nx: number, ny: number) {
|
||||
const { cx, cy, rx, ry } = CALM_CENTER;
|
||||
const dx = (nx - cx) / rx;
|
||||
const dy = (ny - cy) / ry;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function build() {
|
||||
nodes = [];
|
||||
edges = [];
|
||||
pulses = [];
|
||||
|
||||
const target = Math.round(width * height * NODE_DENSITY);
|
||||
const count = Math.max(24, Math.min(140, target));
|
||||
|
||||
let guard = 0;
|
||||
while (nodes.length < count && guard < count * 40) {
|
||||
guard++;
|
||||
// Bias toward edges: pull samples away from centre.
|
||||
const ex = Math.pow(Math.random(), 0.65);
|
||||
const ey = Math.pow(Math.random(), 0.65);
|
||||
const nx = Math.random() < 0.5 ? ex * 0.5 : 1 - ex * 0.5;
|
||||
const ny = Math.random() < 0.5 ? ey * 0.5 : 1 - ey * 0.5;
|
||||
|
||||
const calm = calmFactor(nx, ny);
|
||||
// Reject most nodes inside the calm zone, a few survive for texture.
|
||||
if (calm < 1 && Math.random() > calm * 0.35) continue;
|
||||
|
||||
nodes.push({
|
||||
x: nx * width,
|
||||
y: ny * height,
|
||||
r: 2 + Math.random() * 2,
|
||||
flash: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Connect nearby nodes, capped degree so it stays sparse.
|
||||
const degree = new Array(nodes.length).fill(0);
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
if (degree[i] >= 3) break;
|
||||
if (degree[j] >= 3) continue;
|
||||
const dx = nodes[i].x - nodes[j].x;
|
||||
const dy = nodes[i].y - nodes[j].y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len > MAX_DIST) continue;
|
||||
// Skip edges that cut across the calm centre.
|
||||
const mx = (nodes[i].x + nodes[j].x) / 2 / width;
|
||||
const my = (nodes[i].y + nodes[j].y) / 2 / height;
|
||||
if (calmFactor(mx, my) < 0.9) continue;
|
||||
edges.push({ a: i, b: j, len });
|
||||
degree[i]++;
|
||||
degree[j]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed one pulse per few edges, staggered.
|
||||
for (let e = 0; e < edges.length; e++) {
|
||||
if (Math.random() < 0.55) pulses.push(spawnPulse(e, true));
|
||||
}
|
||||
}
|
||||
|
||||
function spawnPulse(edge: number, initial = false): Pulse {
|
||||
return {
|
||||
edge,
|
||||
dir: Math.random() < 0.5 ? 1 : -1,
|
||||
t: 0,
|
||||
speed: 0.18 + Math.random() * 0.22, // ~2.5-5.5s per edge
|
||||
delay: (initial ? Math.random() * 8 : Math.random() * 6),
|
||||
life: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
build();
|
||||
}
|
||||
|
||||
function drawBackground() {
|
||||
const g = ctx.createLinearGradient(0, 0, width, height);
|
||||
g.addColorStop(0, BG_TOP);
|
||||
g.addColorStop(1, BG_BOTTOM);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
function drawStatic() {
|
||||
drawBackground();
|
||||
ctx.lineWidth = 1;
|
||||
for (const edge of edges) {
|
||||
const a = nodes[edge.a];
|
||||
const b = nodes[edge.b];
|
||||
ctx.strokeStyle = `rgba(${LINE_COLOR}, 0.2)`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (const n of nodes) {
|
||||
ctx.fillStyle = `rgba(${NODE_COLOR}, 0.5)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.05) : 0;
|
||||
lastTime = now;
|
||||
|
||||
drawBackground();
|
||||
|
||||
// Edges.
|
||||
ctx.lineWidth = 1;
|
||||
for (const edge of edges) {
|
||||
const a = nodes[edge.a];
|
||||
const b = nodes[edge.b];
|
||||
ctx.strokeStyle = `rgba(${LINE_COLOR}, 0.22)`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Pulses.
|
||||
for (let i = 0; i < pulses.length; i++) {
|
||||
const p = pulses[i];
|
||||
p.life += dt;
|
||||
if (p.life < p.delay) continue;
|
||||
p.t += p.speed * dt;
|
||||
|
||||
const edge = edges[p.edge];
|
||||
if (!edge) {
|
||||
pulses[i] = spawnPulse(Math.floor(Math.random() * edges.length));
|
||||
continue;
|
||||
}
|
||||
const a = nodes[edge.a];
|
||||
const b = nodes[edge.b];
|
||||
const from = p.dir === 1 ? a : b;
|
||||
const to = p.dir === 1 ? b : a;
|
||||
|
||||
if (p.t >= 1) {
|
||||
to.flash = 1; // packet arrived
|
||||
pulses[i] = spawnPulse(p.edge);
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = from.x + (to.x - from.x) * p.t;
|
||||
const y = from.y + (to.y - from.y) * p.t;
|
||||
// Fade in over first 15%, out over last 25%.
|
||||
const fade = Math.min(p.t / 0.15, 1) * Math.min((1 - p.t) / 0.25, 1);
|
||||
const alpha = 0.85 * fade;
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = `rgba(${PULSE_COLOR}, ${alpha})`;
|
||||
ctx.fillStyle = `rgba(${PULSE_COLOR}, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = `rgba(${PULSE_CORE}, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 1.1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Nodes, brighter while flashing.
|
||||
for (const n of nodes) {
|
||||
if (n.flash > 0) n.flash = Math.max(0, n.flash - dt * 2);
|
||||
const base = 0.45 + n.flash * 0.5;
|
||||
if (n.flash > 0) {
|
||||
ctx.save();
|
||||
ctx.shadowBlur = 8 * n.flash;
|
||||
ctx.shadowColor = `rgba(${PULSE_COLOR}, ${n.flash})`;
|
||||
ctx.fillStyle = `rgba(${PULSE_COLOR}, ${0.4 + n.flash * 0.5})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.r + n.flash, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.fillStyle = `rgba(${NODE_COLOR}, ${base})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
resize();
|
||||
if (reducedMotion) drawStatic();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
resize();
|
||||
if (reducedMotion) {
|
||||
drawStatic();
|
||||
} else {
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(resizeTimer);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed inset-0 -z-10 h-full w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user