"use client"; import { useEffect, useRef, type ReactNode } from "react"; export type FooterSphereGroup = { title: string; links: { label: string; href: string }[]; }; type FooterSphereProps = { /** Name or logo. */ brand: ReactNode; tagline?: string; groups: FooterSphereGroup[]; /** Bottom line, such as the copyright. */ legal?: ReactNode; /** Goes under the tagline: a sign-up form or social links. */ children?: ReactNode; /** Dot colour. */ color?: string; /** Colour of the dots near the pointer. */ accent?: string; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** How many dots make the sphere. */ dots?: number; /** Set the background and text colour here. */ className?: string; }; const GOLDEN = Math.PI * (3 - Math.sqrt(5)); const TILT = -0.42; const REACH = 0.28; /* FooterSphere: a site footer over a dotted globe rising from the bottom edge. It turns slowly, and the dots near the pointer lift and change colour. It stops off screen. */ export function FooterSphere({ brand, tagline, groups, legal, children, color = "#8b7fd6", accent = "#22d3ee", speed = 1, dots = 2800, className = "bg-[#06060e] text-white", }: FooterSphereProps) { const box = useRef(null); const canvasRef = useRef(null); useEffect(() => { const footer = box.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!footer || !canvas || !ctx) return; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const points = Array.from({ length: dots }, (_, i) => { const y = 1 - ((i + 0.5) / dots) * 2; const ring = Math.sqrt(1 - y * y); return [Math.cos(i * GOLDEN) * ring, y, Math.sin(i * GOLDEN) * ring]; }); let w = 0; let h = 0; let turn = 0; let last = 0; let frame = 0; let seen = false; let pointer: { x: number; y: number } | null = null; const draw = () => { ctx.clearRect(0, 0, w, h); const r = Math.min(w * 0.6, h * 0.82, 560); const cx = w / 2; const cy = h + r * 0.32; const fade = Math.min(64, h * 0.12); const cosY = Math.cos(turn); const sinY = Math.sin(turn); const cosX = Math.cos(TILT); const sinX = Math.sin(TILT); for (const [px, py, pz] of points) { const x = px * cosY + pz * sinY; const z1 = pz * cosY - px * sinY; const y = py * cosX - z1 * sinX; const z = py * sinX + z1 * cosX; let sx = cx + x * r; let sy = cy - y * r; if (sy > h + 4) continue; const near = (z + 1) / 2; let lift = 0; if (pointer && z > 0) { const reach = r * REACH; const gap = Math.hypot(sx - pointer.x, sy - pointer.y); if (gap < reach) { lift = (1 - gap / reach) ** 2; sx += (x * r * lift) / 9; sy -= (y * r * lift) / 9; } } const edge = Math.min(1, Math.max(0, (h - sy) / fade)); ctx.globalAlpha = Math.min(1, 0.1 + near * near * 0.9 + lift * 0.4) * edge; ctx.fillStyle = lift > 0.05 ? accent : color; ctx.beginPath(); ctx.arc(sx, sy, 0.6 + near * 1.1 + lift * 1.2, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; }; const loop = (now: number) => { const gap = last ? Math.min(now - last, 60) : 16; last = now; turn += gap * 0.00012 * speed; draw(); frame = requestAnimationFrame(loop); }; const start = () => { if (still || frame || !seen) return; last = 0; frame = requestAnimationFrame(loop); }; const stop = () => { cancelAnimationFrame(frame); frame = 0; }; const resize = () => { const scale = Math.min(window.devicePixelRatio || 1, 2); w = canvas.clientWidth; h = canvas.clientHeight; canvas.width = w * scale; canvas.height = h * scale; ctx.setTransform(scale, 0, 0, scale, 0, 0); draw(); }; const move = (e: PointerEvent) => { const rect = canvas.getBoundingClientRect(); pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; const leave = () => { pointer = null; }; const watcher = new ResizeObserver(resize); watcher.observe(canvas); const onScreen = new IntersectionObserver(([entry]) => { seen = entry.isIntersecting; if (seen) start(); else stop(); }); onScreen.observe(footer); if (!still) { footer.addEventListener("pointermove", move); footer.addEventListener("pointerleave", leave); } return () => { stop(); watcher.disconnect(); onScreen.disconnect(); footer.removeEventListener("pointermove", move); footer.removeEventListener("pointerleave", leave); }; }, [accent, color, dots, speed]); return ( ); }