mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 11:03:50 +00:00
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
|
|
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
|
|
import { ConfettiEngine } from "@/components/background/engines/confetti";
|
|
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
|
|
import { PipesEngine } from "@/components/background/engines/pipes";
|
|
import { ShuffleEngine } from "@/components/background/engines/shuffle";
|
|
import { getStoredAnimationId } from "@/lib/animations/engine";
|
|
import type { AnimationEngine } from "@/lib/animations/types";
|
|
import type { AnimationId } from "@/lib/animations";
|
|
|
|
const SIDEBAR_WIDTH = 240;
|
|
|
|
const FALLBACK_PALETTE: [number, number, number][] = [
|
|
[204, 36, 29], [152, 151, 26], [215, 153, 33],
|
|
[69, 133, 136], [177, 98, 134], [104, 157, 106],
|
|
[251, 73, 52], [184, 187, 38], [250, 189, 47],
|
|
[131, 165, 152], [211, 134, 155], [142, 192, 124],
|
|
];
|
|
|
|
function createEngine(id: AnimationId): AnimationEngine {
|
|
switch (id) {
|
|
case "lava-lamp":
|
|
return new LavaLampEngine();
|
|
case "confetti":
|
|
return new ConfettiEngine();
|
|
case "asciiquarium":
|
|
return new AsciiquariumEngine();
|
|
case "pipes":
|
|
return new PipesEngine();
|
|
case "shuffle":
|
|
return new ShuffleEngine();
|
|
case "game-of-life":
|
|
default:
|
|
return new GameOfLifeEngine();
|
|
}
|
|
}
|
|
|
|
function readPaletteFromCSS(): [number, number, number][] {
|
|
try {
|
|
const style = getComputedStyle(document.documentElement);
|
|
const keys = [
|
|
"--color-red", "--color-green", "--color-yellow",
|
|
"--color-blue", "--color-purple", "--color-aqua",
|
|
"--color-red-bright", "--color-green-bright", "--color-yellow-bright",
|
|
"--color-blue-bright", "--color-purple-bright", "--color-aqua-bright",
|
|
];
|
|
const palette: [number, number, number][] = [];
|
|
for (const key of keys) {
|
|
const val = style.getPropertyValue(key).trim();
|
|
if (val) {
|
|
const parts = val.split(" ").map(Number);
|
|
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
|
palette.push([parts[0], parts[1], parts[2]]);
|
|
}
|
|
}
|
|
}
|
|
return palette.length > 0 ? palette : FALLBACK_PALETTE;
|
|
} catch {
|
|
return FALLBACK_PALETTE;
|
|
}
|
|
}
|
|
|
|
function readBgFromCSS(): string {
|
|
try {
|
|
const val = getComputedStyle(document.documentElement)
|
|
.getPropertyValue("--color-background")
|
|
.trim();
|
|
if (val) {
|
|
const [r, g, b] = val.split(" ");
|
|
return `rgb(${r}, ${g}, ${b})`;
|
|
}
|
|
} catch {}
|
|
return "rgb(0, 0, 0)";
|
|
}
|
|
|
|
interface BackgroundProps {
|
|
layout?: "index" | "sidebar" | "content";
|
|
position?: "left" | "right";
|
|
mobileOnly?: boolean;
|
|
}
|
|
|
|
const Background: React.FC<BackgroundProps> = ({
|
|
layout = "index",
|
|
position = "left",
|
|
mobileOnly = false,
|
|
}) => {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const engineRef = useRef<AnimationEngine | null>(null);
|
|
const animationFrameRef = useRef<number>();
|
|
const lastUpdateTimeRef = useRef<number>(0);
|
|
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
|
|
const dimensionsRef = useRef({ width: 0, height: 0 });
|
|
|
|
const setupCanvas = (
|
|
canvas: HTMLCanvasElement,
|
|
width: number,
|
|
height: number
|
|
) => {
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = width * dpr;
|
|
canvas.height = height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
|
|
return ctx;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const controller = new AbortController();
|
|
const signal = controller.signal;
|
|
|
|
const displayWidth =
|
|
layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
|
const displayHeight = window.innerHeight;
|
|
dimensionsRef.current = { width: displayWidth, height: displayHeight };
|
|
|
|
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
|
|
if (!ctx) return;
|
|
|
|
const palette = readPaletteFromCSS();
|
|
const bgColor = readBgFromCSS();
|
|
|
|
// Initialize engine
|
|
if (!engineRef.current) {
|
|
const animId = getStoredAnimationId();
|
|
engineRef.current = createEngine(animId);
|
|
engineRef.current.init(displayWidth, displayHeight, palette, bgColor);
|
|
}
|
|
|
|
// Handle animation switching
|
|
const handleAnimationChanged = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (!detail?.id) return;
|
|
|
|
if (engineRef.current) {
|
|
engineRef.current.cleanup();
|
|
}
|
|
|
|
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
|
const h = window.innerHeight;
|
|
engineRef.current = createEngine(detail.id);
|
|
engineRef.current.init(w, h, readPaletteFromCSS(), readBgFromCSS());
|
|
};
|
|
|
|
document.addEventListener("animation-changed", handleAnimationChanged, {
|
|
signal,
|
|
});
|
|
|
|
// Handle theme changes — only update if palette actually changed
|
|
let currentPalette = palette;
|
|
const handleThemeChanged = () => {
|
|
const newPalette = readPaletteFromCSS();
|
|
const newBg = readBgFromCSS();
|
|
const same =
|
|
newPalette.length === currentPalette.length &&
|
|
newPalette.every(
|
|
(c, i) =>
|
|
c[0] === currentPalette[i][0] &&
|
|
c[1] === currentPalette[i][1] &&
|
|
c[2] === currentPalette[i][2]
|
|
);
|
|
if (!same && engineRef.current) {
|
|
currentPalette = newPalette;
|
|
engineRef.current.updatePalette(newPalette, newBg);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("theme-changed", handleThemeChanged, { signal });
|
|
|
|
// Handle resize
|
|
const handleResize = () => {
|
|
if (signal.aborted) return;
|
|
|
|
if (resizeTimeoutRef.current) {
|
|
clearTimeout(resizeTimeoutRef.current);
|
|
}
|
|
|
|
resizeTimeoutRef.current = setTimeout(() => {
|
|
if (signal.aborted) return;
|
|
|
|
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
|
const h = window.innerHeight;
|
|
|
|
const newCtx = setupCanvas(canvas, w, h);
|
|
if (!newCtx) return;
|
|
|
|
lastUpdateTimeRef.current = 0;
|
|
dimensionsRef.current = { width: w, height: h };
|
|
|
|
if (engineRef.current) {
|
|
engineRef.current.handleResize(w, h);
|
|
}
|
|
}, 250);
|
|
};
|
|
|
|
// Mouse events
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
if (!engineRef.current || !canvas) return;
|
|
|
|
// Don't spawn when clicking interactive elements
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest("a, button, [role='button'], input, select, textarea, label, [onclick], [tabindex]")) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
if (
|
|
mouseX < 0 ||
|
|
mouseX > rect.width ||
|
|
mouseY < 0 ||
|
|
mouseY > rect.height
|
|
)
|
|
return;
|
|
|
|
e.preventDefault();
|
|
engineRef.current.handleMouseDown(mouseX, mouseY);
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!engineRef.current || !canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (engineRef.current) {
|
|
engineRef.current.handleMouseUp();
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (engineRef.current) {
|
|
engineRef.current.handleMouseLeave();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("mousedown", handleMouseDown, { signal });
|
|
window.addEventListener("mousemove", handleMouseMove, { signal });
|
|
window.addEventListener("mouseup", handleMouseUp, { signal });
|
|
|
|
// Visibility change
|
|
const handleVisibilityChange = () => {
|
|
if (document.hidden) {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
animationFrameRef.current = undefined;
|
|
}
|
|
} else {
|
|
if (!animationFrameRef.current) {
|
|
lastUpdateTimeRef.current = performance.now();
|
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Animation loop
|
|
const animate = (currentTime: number) => {
|
|
if (signal.aborted) return;
|
|
|
|
if (!lastUpdateTimeRef.current) {
|
|
lastUpdateTimeRef.current = currentTime;
|
|
}
|
|
|
|
const deltaTime = currentTime - lastUpdateTimeRef.current;
|
|
const clampedDeltaTime = Math.min(deltaTime, 100);
|
|
lastUpdateTimeRef.current = currentTime;
|
|
|
|
const engine = engineRef.current;
|
|
if (engine) {
|
|
engine.update(clampedDeltaTime);
|
|
|
|
// Clear canvas
|
|
const bg = readBgFromCSS();
|
|
ctx.fillStyle = bg;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
const { width: rw, height: rh } = dimensionsRef.current;
|
|
engine.render(ctx, rw, rh);
|
|
}
|
|
|
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
};
|
|
|
|
document.addEventListener("visibilitychange", handleVisibilityChange, {
|
|
signal,
|
|
});
|
|
window.addEventListener("resize", handleResize, { signal });
|
|
animate(performance.now());
|
|
|
|
return () => {
|
|
controller.abort();
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
}
|
|
if (resizeTimeoutRef.current) {
|
|
clearTimeout(resizeTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [layout]);
|
|
|
|
const isIndex = layout === "index";
|
|
const isSidebar = !isIndex;
|
|
|
|
const getContainerStyle = (): React.CSSProperties => {
|
|
if (isIndex) return {};
|
|
// Fade the inner edge so blobs don't hard-cut at the content boundary
|
|
return {
|
|
maskImage:
|
|
position === "left"
|
|
? "linear-gradient(to right, black 60%, transparent 100%)"
|
|
: "linear-gradient(to left, black 60%, transparent 100%)",
|
|
WebkitMaskImage:
|
|
position === "left"
|
|
? "linear-gradient(to right, black 60%, transparent 100%)"
|
|
: "linear-gradient(to left, black 60%, transparent 100%)",
|
|
};
|
|
};
|
|
|
|
const getContainerClasses = () => {
|
|
if (isIndex) {
|
|
return mobileOnly
|
|
? "fixed inset-0 -z-10 desk:hidden"
|
|
: "fixed inset-0 -z-10";
|
|
}
|
|
|
|
const baseClasses = "fixed top-0 bottom-0 hidden desk:block -z-10";
|
|
return position === "left"
|
|
? `${baseClasses} left-0`
|
|
: `${baseClasses} right-0`;
|
|
};
|
|
|
|
return (
|
|
<div className={getContainerClasses()} style={getContainerStyle()}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-full bg-background"
|
|
style={{ cursor: "default" }}
|
|
/>
|
|
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Background;
|