// starfield.jsx — Universe background with twinkling stars + occasional comets // Performance: ~150 stars max, pauses when tab hidden, devicePixelRatio-aware. function Starfield() { const ref = React.useRef(null); React.useEffect(() => { const canvas = ref.current; if (!canvas) return; const ctx = canvas.getContext('2d'); let stars = []; let comets = []; let nebulae = []; let lastCometAt = performance.now() + 2500; // first comet ~2.5s in let raf = 0; let running = true; const palette = () => { const css = getComputedStyle(document.documentElement); return { a1: (css.getPropertyValue('--a1') || '#22e6ff').trim(), a2: (css.getPropertyValue('--a2') || '#7c5cff').trim(), dark: document.documentElement.getAttribute('data-theme') !== 'light', }; }; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = window.innerWidth; const h = window.innerHeight; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Regenerate stars proportional to area const target = Math.min(180, Math.floor((w * h) / 9000)); stars = new Array(target).fill(0).map(() => { const depth = Math.random(); return { x: Math.random() * w, y: Math.random() * h, r: 0.3 + Math.pow(depth, 2.2) * 1.4, a: 0.25 + Math.random() * 0.65, twinkS: 0.0005 + Math.random() * 0.0018, twinkP: Math.random() * Math.PI * 2, depth, color: Math.random() < 0.12 ? 'accent' : 'white', vx: (Math.random() - 0.5) * 0.02 * depth, vy: (Math.random() - 0.5) * 0.02 * depth, }; }); // Soft nebula blobs for color depth nebulae = [ { x: w * 0.15, y: h * 0.25, r: Math.max(180, w * 0.18), c: 'a1', a: 0.05 }, { x: w * 0.85, y: h * 0.55, r: Math.max(220, w * 0.22), c: 'a2', a: 0.05 }, { x: w * 0.45, y: h * 0.9, r: Math.max(160, w * 0.16), c: 'a1', a: 0.04 }, ]; }; const spawnComet = () => { // Diagonal trajectory, varies side & angle const fromLeft = Math.random() > 0.5; const speed = 6 + Math.random() * 4; const angleDeg = 18 + Math.random() * 24; // 18°–42° down const angle = (angleDeg * Math.PI) / 180; const yStart = Math.random() * (window.innerHeight * 0.6); comets.push({ x: fromLeft ? -120 : window.innerWidth + 120, y: yStart, vx: (fromLeft ? 1 : -1) * Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 0, maxLife: 260, trail: [], hue: Math.random() < 0.5 ? 'a1' : 'a2', size: 1.6 + Math.random() * 1.4, }); }; const draw = (t) => { if (!running) { raf = requestAnimationFrame(draw); return; } const w = window.innerWidth; const h = window.innerHeight; const p = palette(); ctx.clearRect(0, 0, w, h); // Nebulae (radial-gradient blobs via canvas) nebulae.forEach(n => { const grd = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, n.r); const col = n.c === 'a1' ? p.a1 : p.a2; grd.addColorStop(0, hexA(col, n.a)); grd.addColorStop(1, hexA(col, 0)); ctx.fillStyle = grd; ctx.fillRect(n.x - n.r, n.y - n.r, n.r * 2, n.r * 2); }); // Stars ctx.save(); stars.forEach(s => { s.x += s.vx; s.y += s.vy; if (s.x < 0) s.x = w; else if (s.x > w) s.x = 0; if (s.y < 0) s.y = h; else if (s.y > h) s.y = 0; const tw = (Math.sin(t * s.twinkS + s.twinkP) + 1) * 0.5; const alpha = s.a * (0.45 + 0.55 * tw); const color = s.color === 'accent' ? p.a1 : (p.dark ? '#e8f1ff' : '#3a4566'); ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fillStyle = hexA(color, alpha); ctx.fill(); // Halo on brighter stars if (s.r > 1.05) { const halo = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r * 5); halo.addColorStop(0, hexA(color, alpha * 0.4)); halo.addColorStop(1, hexA(color, 0)); ctx.fillStyle = halo; ctx.fillRect(s.x - s.r * 5, s.y - s.r * 5, s.r * 10, s.r * 10); } }); ctx.restore(); // Comet spawning if (t - lastCometAt > 4500 + Math.random() * 6500) { spawnComet(); lastCometAt = t; } // Occasionally a double comet for delight if (Math.random() < 0.0003 && comets.length < 2) spawnComet(); // Comets comets.forEach(c => { c.life++; c.trail.push({ x: c.x, y: c.y }); if (c.trail.length > 32) c.trail.shift(); c.x += c.vx; c.y += c.vy; const color = c.hue === 'a1' ? p.a1 : p.a2; // Trail for (let i = 0; i < c.trail.length - 1; i++) { const a = (i / c.trail.length) * 0.85; const p0 = c.trail[i]; const p1 = c.trail[i + 1]; ctx.strokeStyle = hexA(color, a); ctx.lineWidth = (i / c.trail.length) * c.size * 1.4 + 0.2; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.stroke(); } // Head — bright white core with colored glow const headGrad = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, c.size * 6); headGrad.addColorStop(0, hexA('#ffffff', 1)); headGrad.addColorStop(0.3, hexA(color, 0.7)); headGrad.addColorStop(1, hexA(color, 0)); ctx.fillStyle = headGrad; ctx.fillRect(c.x - c.size * 6, c.y - c.size * 6, c.size * 12, c.size * 12); ctx.beginPath(); ctx.arc(c.x, c.y, c.size, 0, Math.PI * 2); ctx.fillStyle = '#ffffff'; ctx.fill(); }); comets = comets.filter(c => c.life < c.maxLife && c.x > -200 && c.x < w + 200 && c.y < h + 200); raf = requestAnimationFrame(draw); }; const onVisibility = () => { running = !document.hidden; }; resize(); window.addEventListener('resize', resize); document.addEventListener('visibilitychange', onVisibility); raf = requestAnimationFrame(draw); return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', resize); document.removeEventListener('visibilitychange', onVisibility); }; }, []); return