293 lines
9.5 KiB
TypeScript
293 lines
9.5 KiB
TypeScript
import { useEffect, useRef, useCallback } from 'react';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
interface Point { x: number; y: number }
|
|
interface Seg { from: Point; to: Point }
|
|
interface Trace { segs: Seg[]; bright: boolean }
|
|
interface Pulse {
|
|
trace: Trace;
|
|
segIdx: number;
|
|
t: number; // [0,1] within current segment
|
|
speed: number; // segments per second
|
|
hue: number; // HSL hue
|
|
trail: Point[];
|
|
}
|
|
|
|
interface ThemePalette {
|
|
bg: string;
|
|
traceBright: string;
|
|
traceDim: string;
|
|
padBright: string;
|
|
padDim: string;
|
|
viaStroke: string;
|
|
viaFill: string;
|
|
pulseHues: number[];
|
|
trailLightness: number; // % lightness for trail hsla
|
|
glowLightness: number; // % lightness for glow center
|
|
coreLightness: number; // % lightness for core dot
|
|
coreSaturation: number; // % saturation for core dot
|
|
}
|
|
|
|
// ─── Theme palettes ───────────────────────────────────────────────────────────
|
|
|
|
const DARK_PALETTE: ThemePalette = {
|
|
bg: '#040c07',
|
|
traceBright: '#112b1a',
|
|
traceDim: '#0a1f10',
|
|
padBright: '#183322',
|
|
padDim: '#0f2219',
|
|
viaStroke: '#1a3d25',
|
|
viaFill: '#0d2218',
|
|
pulseHues: [140, 150, 155, 160, 165, 170, 175, 180, 185],
|
|
trailLightness: 65,
|
|
glowLightness: 80,
|
|
coreLightness: 96,
|
|
coreSaturation: 60,
|
|
};
|
|
|
|
const LIGHT_PALETTE: ThemePalette = {
|
|
bg: '#e8f2f7',
|
|
traceBright: '#7aafc8',
|
|
traceDim: '#9dc4d8',
|
|
padBright: '#689fbc',
|
|
padDim: '#88b8cc',
|
|
viaStroke: '#5590ac',
|
|
viaFill: '#c2dce8',
|
|
pulseHues: [185, 190, 195, 200, 205, 210, 215, 220, 170],
|
|
trailLightness: 38,
|
|
glowLightness: 28,
|
|
coreLightness: 22,
|
|
coreSaturation: 75,
|
|
};
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
|
|
const CELL = 56;
|
|
const TRAIL = 28;
|
|
const PULSE_COUNT = 22;
|
|
const DX = [1, 0, -1, 0];
|
|
const DY = [0, 1, 0, -1];
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function pt(c: number, r: number): Point {
|
|
return { x: c * CELL, y: r * CELL };
|
|
}
|
|
|
|
function buildTraces(w: number, h: number): Trace[] {
|
|
const cols = Math.floor(w / CELL) + 2;
|
|
const rows = Math.floor(h / CELL) + 2;
|
|
const count = 40 + Math.floor((w * h) / 40000);
|
|
const traces: Trace[] = [];
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
let c = Math.floor(Math.random() * cols);
|
|
let r = Math.floor(Math.random() * rows);
|
|
let dir = Math.floor(Math.random() * 4);
|
|
const bright = Math.random() < 0.35;
|
|
const maxLen = bright
|
|
? 6 + Math.floor(Math.random() * 14)
|
|
: 3 + Math.floor(Math.random() * 8);
|
|
const segs: Seg[] = [];
|
|
|
|
for (let step = 0; step < maxLen; step++) {
|
|
const nc = c + DX[dir];
|
|
const nr = r + DY[dir];
|
|
if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) break;
|
|
segs.push({ from: pt(c, r), to: pt(nc, nr) });
|
|
c = nc;
|
|
r = nr;
|
|
if (Math.random() < 0.28) {
|
|
dir = (dir + (Math.random() < 0.5 ? 1 : 3)) % 4;
|
|
}
|
|
if (step >= 2 && Math.random() < 0.06) break;
|
|
}
|
|
|
|
if (segs.length >= 2) traces.push({ segs, bright });
|
|
}
|
|
|
|
return traces;
|
|
}
|
|
|
|
function buildPulses(traces: Trace[], palette: ThemePalette): Pulse[] {
|
|
const candidates = traces.filter((t) => t.segs.length >= 3 && t.bright);
|
|
const pool = candidates.length > 0 ? candidates : traces.filter((t) => t.segs.length >= 3);
|
|
const pulses: Pulse[] = [];
|
|
|
|
for (let i = 0; i < PULSE_COUNT; i++) {
|
|
const trace = pool[Math.floor(Math.random() * pool.length)];
|
|
if (!trace) continue;
|
|
pulses.push({
|
|
trace,
|
|
segIdx: Math.floor(Math.random() * trace.segs.length),
|
|
t: Math.random(),
|
|
speed: 0.45 + Math.random() * 0.85,
|
|
hue: palette.pulseHues[Math.floor(Math.random() * palette.pulseHues.length)],
|
|
trail: [],
|
|
});
|
|
}
|
|
return pulses;
|
|
}
|
|
|
|
function lerp(a: Point, b: Point, t: number): Point {
|
|
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
interface Props {
|
|
theme?: 'dark' | 'light';
|
|
}
|
|
|
|
export default function CircuitBackground({ theme = 'dark' }: Props) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const stateRef = useRef<{ traces: Trace[]; pulses: Pulse[]; raf: number }>({
|
|
traces: [], pulses: [], raf: 0,
|
|
});
|
|
|
|
const palette = theme === 'light' ? LIGHT_PALETTE : DARK_PALETTE;
|
|
|
|
const init = useCallback((w: number, h: number) => {
|
|
const traces = buildTraces(w, h);
|
|
const pulses = buildPulses(traces, palette);
|
|
stateRef.current.traces = traces;
|
|
stateRef.current.pulses = pulses;
|
|
}, [palette]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
|
if (!ctx) return;
|
|
|
|
let lastTime = performance.now();
|
|
|
|
const resize = () => {
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
init(canvas.width, canvas.height);
|
|
};
|
|
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
// ── Draw helpers ──────────────────────────────────────────────────────────
|
|
|
|
function drawTraces(traces: Trace[]) {
|
|
for (const trace of traces) {
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = trace.bright ? palette.traceBright : palette.traceDim;
|
|
ctx.lineWidth = trace.bright ? 1.5 : 1;
|
|
ctx.lineCap = 'square';
|
|
ctx.moveTo(trace.segs[0].from.x, trace.segs[0].from.y);
|
|
for (const seg of trace.segs) {
|
|
ctx.lineTo(seg.to.x, seg.to.y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
const padCol = trace.bright ? palette.padBright : palette.padDim;
|
|
for (const seg of trace.segs) {
|
|
ctx.fillStyle = padCol;
|
|
ctx.beginPath();
|
|
ctx.arc(seg.to.x, seg.to.y, 2.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
if (trace.bright && Math.random() < 0.4) {
|
|
const last = trace.segs[trace.segs.length - 1].to;
|
|
ctx.strokeStyle = palette.viaStroke;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(last.x, last.y, 5, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.fillStyle = palette.viaFill;
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawPulse(pulse: Pulse) {
|
|
const len = pulse.trail.length;
|
|
if (len < 2) return;
|
|
|
|
const { trailLightness, glowLightness, coreLightness, coreSaturation } = palette;
|
|
|
|
for (let i = 1; i < len; i++) {
|
|
const alpha = Math.pow(i / len, 1.6) * 0.75;
|
|
const width = (i / len) * 2.5;
|
|
ctx.strokeStyle = `hsla(${pulse.hue}, 90%, ${trailLightness}%, ${alpha})`;
|
|
ctx.lineWidth = width;
|
|
ctx.lineCap = 'round';
|
|
ctx.beginPath();
|
|
ctx.moveTo(pulse.trail[i - 1].x, pulse.trail[i - 1].y);
|
|
ctx.lineTo(pulse.trail[i].x, pulse.trail[i].y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
const head = pulse.trail[len - 1];
|
|
const glow = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 16);
|
|
glow.addColorStop(0, `hsla(${pulse.hue}, 90%, ${glowLightness}%, 0.7)`);
|
|
glow.addColorStop(0.35, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0.25)`);
|
|
glow.addColorStop(1, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0)`);
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(head.x, head.y, 16, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = `hsl(${pulse.hue}, ${coreSaturation}%, ${coreLightness}%)`;
|
|
ctx.beginPath();
|
|
ctx.arc(head.x, head.y, 2.2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// ── Animation loop ────────────────────────────────────────────────────────
|
|
|
|
const animate = (now: number) => {
|
|
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
|
lastTime = now;
|
|
|
|
const { traces, pulses } = stateRef.current;
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
|
|
ctx.fillStyle = palette.bg;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
drawTraces(traces);
|
|
|
|
for (const pulse of pulses) {
|
|
pulse.t += pulse.speed * dt;
|
|
while (pulse.t >= 1) {
|
|
pulse.t -= 1;
|
|
pulse.segIdx = (pulse.segIdx + 1) % pulse.trace.segs.length;
|
|
}
|
|
|
|
const seg = pulse.trace.segs[pulse.segIdx];
|
|
const pos = lerp(seg.from, seg.to, pulse.t);
|
|
pulse.trail.push(pos);
|
|
if (pulse.trail.length > TRAIL) pulse.trail.shift();
|
|
|
|
drawPulse(pulse);
|
|
}
|
|
|
|
stateRef.current.raf = requestAnimationFrame(animate);
|
|
};
|
|
|
|
stateRef.current.raf = requestAnimationFrame(animate);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', resize);
|
|
cancelAnimationFrame(stateRef.current.raf);
|
|
};
|
|
}, [init, palette]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="fixed inset-0 pointer-events-none"
|
|
style={{ zIndex: 0 }}
|
|
/>
|
|
);
|
|
}
|