/** * ConstellationField.tsx * --------------------------------------------------------------------------- * A calmer, reusable star-field island for SECTION backgrounds (everything that * isn't the hero). Compared to AnimatedBackground it is: * - sparser and dimmer (so foreground content stays readable), * - slow-blink only (no shooting stars / nebula), * - the whole field rotates together very slowly, reading as a slice of sky * turning as the planet rotates. * * Carries a `view-transition-name` (default "sky") so that when the browser * morphs between pages via Astro's , the hero's star field and * a section's constellation field cross-fade/translate as one continuous sky. * On the Professional page we render CircuitField instead, which shares the * same transition name — so constellations visibly MORPH into circuit traces. * * Honors prefers-reduced-motion (renders a static field) and pauses when the * section is off-screen. */ import { useEffect, useRef } from 'react'; interface Props { /** Density multiplier. 1 = default; lower for quieter sections. */ density?: number; /** * Tailwind/className passthrough. The .astro call site is responsible for * including `vt-sky` when this element should carry the view-transition * morph. We do NOT compute the transition class here: conditional className * interpolation triggers a server/client hydration attribute mismatch under * Astro's client:idle. A static, fully-formed string keeps SSR and hydration * byte-identical. */ className?: string; /** Star color theme. 'gold' leans warm, 'violet' leans mystic. */ tint?: 'gold' | 'violet' | 'teal'; } export default function ConstellationField({ density = 1, className = '', tint = 'gold', }: Props) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const dpr = Math.min(window.devicePixelRatio || 1, 2); let raf = 0; let width = 0; let height = 0; let angle = 0; let t0 = performance.now(); interface Star { rx: number; ry: number; r: number; tw: number; tws: number; baseA: number; anchor: boolean; } let stars: Star[] = []; const gold = '#c9a24a'; const violet = '#6a2fe0'; const teal = '#2bc2b7'; const white = '#f4f2ed'; const accent = tint === 'violet' ? violet : tint === 'teal' ? teal : gold; // One full rotation every ~10 minutes — barely perceptible, "planet turning". const ROT_SPEED = (Math.PI * 2) / (10 * 60); const LINK_DIST = 175; const LINK_DIST_SQ = LINK_DIST * LINK_DIST; 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); // Pivot = top-right corner. The disk is centered there so the field // rotates around that corner; maxR reaches the far (bottom-left) corner. const cx = width; const cy = 0; const maxR = Math.sqrt(cx * cx + height * height) + 40; // Ultra-dense: ~5x. One star per ~300 px^2, scaled by density; ~17% // anchors for a very dense constellation backbone. const count = Math.min( 3000, Math.floor(((Math.PI * maxR * maxR) / 300) * density), ); stars = Array.from({ length: count }, (_, i) => { const anchor = i % 7 === 0; const rr = maxR * Math.sqrt(Math.random()); const a = Math.random() * Math.PI * 2; return { rx: cx + rr * Math.cos(a), ry: cy + rr * Math.sin(a), r: anchor ? 1.1 + Math.random() * 1.0 : 0.3 + Math.random() * 0.9, tw: Math.random() * Math.PI * 2, tws: 0.1 + Math.random() * 0.25, baseA: anchor ? 0.4 + Math.random() * 0.3 : 0.1 + Math.random() * 0.3, anchor, }; }); } function drawStatic() { if (!ctx) return; ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; for (const s of stars) { ctx.beginPath(); ctx.fillStyle = s.anchor ? accent : white; ctx.globalAlpha = s.baseA; ctx.arc(s.rx, s.ry, s.r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; void cx; void cy; } function frame(now: number) { if (!ctx) return; const dt = Math.min(0.05, (now - t0) / 1000); t0 = now; angle += ROT_SPEED * dt; 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); const project = (rx: number, ry: number): [number, number] => [ cx + (rx - cx) * cosA - (ry - cy) * sinA, cy + (rx - cx) * sinA + (ry - cy) * cosA, ]; const proj = stars.map((s) => { const [x, y] = project(s.rx, s.ry); return { s, x, y }; }); // Constellation lines between near anchors. ctx.lineWidth = 0.5; 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 / 1600 + (i + j) * 0.7); const alpha = proximity * 0.24 * (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(); } } // Slow-blink stars. for (const p of proj) { const s = p.s; s.tw += s.tws * dt; if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue; const blink = 0.55 + 0.45 * Math.sin(s.tw); const alpha = Math.max(0, Math.min(1, s.baseA * blink)); ctx.beginPath(); ctx.fillStyle = s.anchor ? accent : white; ctx.globalAlpha = alpha; if (s.anchor) { ctx.shadowBlur = 6; ctx.shadowColor = accent; } else { ctx.shadowBlur = 0; } ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; ctx.shadowBlur = 0; raf = requestAnimationFrame(frame); } resize(); if (reduce) { drawStatic(); } else { t0 = performance.now(); raf = requestAnimationFrame(frame); } const onResize = () => { resize(); if (reduce) drawStatic(); }; window.addEventListener('resize', onResize); const io = new IntersectionObserver((entries) => { if (reduce) return; 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(); }; }, [density, tint]); return (