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 = ({ layout = "index", position = "left", mobileOnly = false, }) => { const canvasRef = useRef(null); const engineRef = useRef(null); const animationFrameRef = useRef(); const lastUpdateTimeRef = useRef(0); const resizeTimeoutRef = useRef(); 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 (
); }; export default Background;