/** * AnimatedBackground.tsx * --------------------------------------------------------------------------- * The hero background island. Layers: * * 1. A CSS-driven night-sky gradient (deep blue, darker at top → lighter near * horizon) plus a diagonal Milky Way galaxy band and teal/violet nebula * clouds. GPU-friendly, animated via `animate-gradient-shift` keyframes. * 2. The Patiun layered magic seal, faint and slowly turning in the top-left * corner (the surface's one focal motif). * 3. An optional star field: a dense field of slowly-twinkling stars * with thin constellation lines between near neighbors, plus the occasional * shooting star. The WHOLE field rotates together very slowly around the * field center — reading as a slice of sky turning as the planet rotates. * We render at devicePixelRatio and cap the count for perf. It pauses when * off-screen and is fully skipped when prefers-reduced-motion is set. * * The component is presentational only; it reads CSS variables for colors so it * adapts to the section surface tone automatically. */ import { useEffect, useRef } from 'react'; interface Props { /** Show the canvas star field. Off on interest pages for a calmer feel. */ particles?: boolean; /** * Tailwind/className passthrough for the root. The CALL SITE (in .astro) is * responsible for including `vt-sky` here when this element should carry the * `view-transition-name: sky` morph. We intentionally do NOT compute the * transition class inside this React component: any conditional className * interpolation causes a server/client hydration attribute mismatch under * Astro's client:idle. Passing a static, fully-formed string guarantees the * server-rendered and hydrated class attributes are byte-identical. */ className?: string; } export default function AnimatedBackground({ particles = true, className = '', }: Props) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce || !particles) return; const ctx = canvas.getContext('2d'); if (!ctx) return; let raf = 0; let width = 0; let height = 0; const dpr = Math.min(window.devicePixelRatio || 1, 2); interface Star { // Position in sky coordinates (pre-rotation). The whole field rotates. rx: number; // offset from field center (cx, cy) ry: number; r: number; // base radius tw: number; // twinkle phase tws: number; // twinkle speed (slow) baseA: number; // base alpha anchor: boolean; // brighter "anchor" star used for constellations hue: 'white' | 'gold' | 'violet' | 'teal'; } interface Shooter { x: number; y: number; len: number; speed: number; angle: number; life: number; } let stars: Star[] = []; let shooters: Shooter[] = []; let nextShooterAt = 0; let t0 = performance.now(); let angle = 0; // current field rotation (radians) // Resolve star colors from CSS variables so the field matches the theme. const gold = getComputedStyle(document.documentElement).getPropertyValue('--color-gold').trim() || '#c9a24a'; // Nebula Violet is background light only, so violet stars use a lifted // tint of it; teal stars use the on-dark teal so they read at dot size. const violet = '#8a63ee'; const teal = getComputedStyle(document.documentElement).getPropertyValue('--color-teal-bright').trim() || '#2bc2b7'; const white = getComputedStyle(document.documentElement).getPropertyValue('--color-starlight').trim() || '#f4f2ed'; const hueColor = (h: Star['hue']) => (h === 'gold' ? gold : h === 'violet' ? violet : h === 'teal' ? teal : white); // Max distance for two stars to be connected by a constellation line. const LINK_DIST = 168; const LINK_DIST_SQ = LINK_DIST * LINK_DIST; // Rotation speed: a full turn takes ~6 minutes, so the drift is felt but // never distracting — "as the planet rotates". const ROT_SPEED = (Math.PI * 2) / (6 * 60); // radians per second function pickHue(): Star['hue'] { const n = Math.random(); // Mostly white stars, with occasional colored ones for richness. if (n < 0.7) return 'white'; if (n < 0.85) return 'gold'; if (n < 0.94) return 'violet'; return 'teal'; } function resize() { if (!canvas || !ctx) return; const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Dense field tuned for ~40% constellation coverage on any slice. Stars // live in a sky disk large enough that rotation never reveals an empty // edge. The disk is centered on the PIVOT (the top-right corner) so the // whole field rotates around that corner like a star chart turning about // its corner mount; maxR reaches the far (bottom-left) corner. const cx = width; const cy = 0; const maxR = Math.sqrt(cx * cx + height * height) + 40; // Ultra-dense starfield: ~5x the previous density for a richly packed sky. // One star per ~220 px^2 of the disk, capped for perf (~17% anchors). const diskArea = Math.PI * maxR * maxR; const count = Math.min(3000, Math.floor(diskArea / 220)); stars = Array.from({ length: count }, (_, i) => { const anchor = i % 6 === 0; // ~17% anchors -> very dense constellation backbone // Uniform-ish distribution across the disk via sqrt on radius. const rr = maxR * Math.sqrt(Math.random()); const a = Math.random() * Math.PI * 2; const r = anchor ? 1.4 + Math.random() * 1.4 : 0.35 + Math.random() * 1.1; return { rx: cx + rr * Math.cos(a), ry: cy + rr * Math.sin(a), r, tw: Math.random() * Math.PI * 2, // Slow twinkle: 0.15–0.5 cycles/sec. tws: 0.15 + Math.random() * 0.35, baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.16 + Math.random() * 0.4, anchor, hue: anchor ? (Math.random() < 0.5 ? 'gold' : 'white') : pickHue(), }; }); } function spawnShooter(now: number) { const startX = Math.random() * width * 0.8; const startY = Math.random() * height * 0.5; const a = Math.PI * (0.18 + Math.random() * 0.12); shooters.push({ x: startX, y: startY, len: 90 + Math.random() * 120, speed: 5.5 + Math.random() * 3.5, angle: a, life: 1, }); nextShooterAt = now + 5000 + Math.random() * 8000; } function frame(now: number) { if (!ctx) return; const dt = Math.min(0.05, (now - t0) / 1000); t0 = now; angle += ROT_SPEED * dt; // whole sky rotates together ctx.clearRect(0, 0, width, height); // Pivot = top-right corner (matches the disk center used in resize()). const cx = width; const cy = 0; const cosA = Math.cos(angle); const sinA = Math.sin(angle); // Rotate each sky point about the field center into screen space. const project = (rx: number, ry: number): [number, number] => [ cx + (rx - cx) * cosA - (ry - cy) * sinA, cy + (rx - cx) * sinA + (ry - cy) * cosA, ]; // Project every star's sky position into screen space once. // Each entry carries the source star + its rotated (x, y). const proj = stars.map((s) => { const [x, y] = project(s.rx, s.ry); return { s, x, y }; }); // 1) Constellation lines (behind stars) — connect near anchor pairs. ctx.lineWidth = 0.6; for (let i = 0; i < proj.length; i++) { const A = proj[i]; if (!A.s.anchor) continue; for (let j = i + 1; j < proj.length; j++) { const B = proj[j]; if (!B.s.anchor) continue; const ddx = A.x - B.x; const ddy = A.y - B.y; const d2 = ddx * ddx + ddy * ddy; if (d2 > LINK_DIST_SQ) continue; const proximity = 1 - Math.sqrt(d2) / LINK_DIST; const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7); const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse); ctx.strokeStyle = `rgba(201, 162, 74, ${alpha.toFixed(3)})`; ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.stroke(); } } // 2) Stars (slow twinkle; positions already rotated). for (const p of proj) { const s = p.s; s.tw += s.tws * dt; // Cull stars rotated off-screen for perf. if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue; const twinkle = 0.6 + 0.4 * Math.sin(s.tw); const alpha = Math.max(0, Math.min(1, s.baseA * twinkle)); const color = hueColor(s.hue); if (s.anchor) { ctx.beginPath(); ctx.fillStyle = color; ctx.globalAlpha = alpha * 0.5; ctx.shadowBlur = 10; ctx.shadowColor = color; ctx.arc(p.x, p.y, s.r * 1.8, 0, Math.PI * 2); ctx.fill(); } ctx.beginPath(); ctx.fillStyle = color; ctx.globalAlpha = alpha; ctx.shadowBlur = s.anchor ? 6 : 3; ctx.shadowColor = color; ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; ctx.shadowBlur = 0; // 3) Shooting stars (screen-space; not part of the rotating sky). if (now >= nextShooterAt) spawnShooter(now); for (let i = shooters.length - 1; i >= 0; i--) { const sh = shooters[i]; sh.x += Math.cos(sh.angle) * sh.speed; sh.y += Math.sin(sh.angle) * sh.speed; sh.life -= dt * 0.5; if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) { shooters.splice(i, 1); continue; } const tailX = sh.x - Math.cos(sh.angle) * sh.len; const tailY = sh.y - Math.sin(sh.angle) * sh.len; const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY); const headA = Math.max(0, Math.min(1, sh.life)); grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`); grad.addColorStop(0.4, `rgba(201, 162, 74, ${(headA * 0.6).toFixed(3)})`); grad.addColorStop(1, 'rgba(201, 162, 74, 0)'); ctx.strokeStyle = grad; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(sh.x, sh.y); ctx.lineTo(tailX, tailY); ctx.stroke(); ctx.beginPath(); ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`; ctx.shadowBlur = 8; ctx.shadowColor = gold; ctx.arc(sh.x, sh.y, 1.4, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } raf = requestAnimationFrame(frame); } resize(); t0 = performance.now(); nextShooterAt = t0 + 3000 + Math.random() * 4000; raf = requestAnimationFrame(frame); const onResize = () => resize(); window.addEventListener('resize', onResize); // Pause when off-screen to save cycles. const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) { if (!raf) { t0 = performance.now(); raf = requestAnimationFrame(frame); } } else { cancelAnimationFrame(raf); raf = 0; } } }); io.observe(canvas); return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', onResize); io.disconnect(); }; }, [particles]); return (