// Constellation orbital map: every created planet orbits a shared star. // Click a planet to open its viewer. const { useState: useStateC, useEffect: useEffectC, useRef: useRefC } = React; function Constellation({ starfieldDensity, animIntensity }) { const canvasRef = useRefC(null); const [hovered, setHovered] = useStateC(null); const [planets, setPlanets] = useStateC([]); const stateRef = useRefC({ planets: [], hovered: null, selected: null }); useEffectC(() => { const all = window.PLANETALIN.allPlanets(); setPlanets(all); stateRef.current.planets = all; }, []); useEffectC(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); let raf; let stars = []; let farStars = []; let shootingStars = []; let alive = true; // MK-class color distribution — same curve as the 3D renderer. function pickStarColor(roll) { if (roll < 0.42) return `rgb(255, ${Math.floor(155 + Math.random()*30)}, ${Math.floor(115 + Math.random()*30)})`; if (roll < 0.68) return `rgb(255, ${Math.floor(200 + Math.random()*20)}, ${Math.floor(155 + Math.random()*25)})`; if (roll < 0.83) return `rgb(255, ${Math.floor(237 + Math.random()*12)}, ${Math.floor(204 + Math.random()*25)})`; if (roll < 0.93) return `rgb(250, 250, ${Math.floor(235 + Math.random()*15)})`; if (roll < 0.985) return `rgb(${Math.floor(204 + Math.random()*20)}, ${Math.floor(225 + Math.random()*15)}, 255)`; return 'rgb(153, 191, 255)'; // rare O-type blue } function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = canvas.clientWidth || 800, h = canvas.clientHeight || 500; canvas.width = w*dpr; canvas.height = h*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); stars = []; farStars = []; // Denser field + an additional far layer. Density scales with canvas area. const n = Math.floor(w*h/1500 * starfieldDensity); const bandY = h * 0.5; // Milky-Way equator runs through the center const bandH = h * 0.35; for (let i=0;i 0.85, }); } const fn = Math.floor(w*h/900 * starfieldDensity); for (let i=0;i { pct[a.sym] = (pct[a.sym]||0) + a.pct; }); const sum = (keys) => keys.reduce((s,k) => s + (pct[k]||0), 0); const gas = sum(['H','He']); const air = sum(['N','O','H₂O','Ar','O₃']); const cold = sum(['CH4','Ne','Kr','Xe']); const hot = sum(['SO₂','CO₂','NH₃','PH₃','C']); const max = Math.max(gas, air, cold, hot); if (max < 20) return 4; if (max === gas) return 0; // inner — hydrogen/helium (close, hot) if (max === hot) return 1; // next — sulfurous/carbonaceous if (max === air) return 2; // middle — temperate breathable if (max === cold) return 3; // outer — icy/methane return 4; // exotic/other — outermost } const FAMILY_COUNT = 5; function orbitRadius(fam, indexInFam, famCount, w, h) { const maxR = Math.max(130, Math.min(w,h)/2 - 40); const minR = 65; // Each family gets a ring band; worlds within a family space out by a // small radial wobble so they don't overlap on the same arc. const bandStep = (maxR - minR) / FAMILY_COUNT; const band = minR + fam * bandStep; const wobble = famCount > 1 ? ((indexInFam % 3) - 1) * (bandStep * 0.18) : 0; return band + bandStep*0.45 + wobble; } function draw(t) { if (!alive) return; raf = requestAnimationFrame(draw); const w = canvas.clientWidth, h = canvas.clientHeight; const cx = w/2, cy = h/2; ctx.clearRect(0,0,w,h); const bgGrad = ctx.createRadialGradient(cx, cy, 20, cx, cy, Math.max(w,h)); bgGrad.addColorStop(0, 'rgba(40, 25, 70, 0.4)'); bgGrad.addColorStop(1, 'rgba(8, 6, 20, 0)'); ctx.fillStyle = bgGrad; ctx.fillRect(0,0,w,h); // Milky-Way band — horizontal gradient adds a diffuse glow across the // center that sells the galactic-plane bias in the star distribution. const mwGrad = ctx.createLinearGradient(0, h*0.25, 0, h*0.75); mwGrad.addColorStop(0.0, 'rgba(120, 110, 180, 0)'); mwGrad.addColorStop(0.5, 'rgba(150, 130, 210, 0.06)'); mwGrad.addColorStop(1.0, 'rgba(120, 110, 180, 0)'); ctx.fillStyle = mwGrad; ctx.fillRect(0, 0, w, h); // Far layer — tiny, slow-twinkling, pushed back with low alpha farStars.forEach(s => { const tw = 0.55 + 0.45*Math.sin(t*0.0012 + s.p); ctx.globalAlpha = s.a * tw; ctx.fillStyle = '#dcd6ff'; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI*2); ctx.fill(); }); // Near layer — MK-class colored, with per-star twinkle rate + scintillation. stars.forEach(s => { const rate = 1 + (s.p % 1.3); const tw = 0.55 + 0.45 * Math.sin(t*0.002*rate + s.p) * (0.85 + 0.15 * Math.sin(t*0.008 + s.p*13.7)); ctx.globalAlpha = Math.max(0, s.a * tw); ctx.fillStyle = s.c; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI*2); ctx.fill(); // Diffraction cross on the brightest stars only if (s.flare) { const sp = s.r * 4 * tw; ctx.globalAlpha = s.a * tw * 0.55; ctx.strokeStyle = s.c; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(s.x - sp, s.y); ctx.lineTo(s.x + sp, s.y); ctx.moveTo(s.x, s.y - sp); ctx.lineTo(s.x, s.y + sp); ctx.stroke(); } }); ctx.globalAlpha = 1; // Shooting stars — spawn occasionally, fade along their path. if (Math.random() < 0.004 * animIntensity && shootingStars.length < 2) { shootingStars.push({ x: Math.random()*w, y: Math.random()*h*0.6, vx: (4 + Math.random()*3), vy: (1 + Math.random()*1.5), life: 1.0, }); } shootingStars = shootingStars.filter(m => m.life > 0); shootingStars.forEach(m => { const tailLen = 60; const grad = ctx.createLinearGradient(m.x, m.y, m.x - m.vx*tailLen/5, m.y - m.vy*tailLen/5); grad.addColorStop(0, `rgba(255,240,220,${m.life})`); grad.addColorStop(1, 'rgba(255,240,220,0)'); ctx.strokeStyle = grad; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(m.x, m.y); ctx.lineTo(m.x - m.vx*tailLen/5, m.y - m.vy*tailLen/5); ctx.stroke(); m.x += m.vx * animIntensity; m.y += m.vy * animIntensity; m.life -= 0.015; }); const pl = stateRef.current.planets; // Compute family assignments once per frame — cheap. const famOf = pl.map(classify); const famIdx = pl.map(() => 0); const famCount = Array(FAMILY_COUNT).fill(0); pl.forEach((_, i) => { famIdx[i] = famCount[famOf[i]]++; }); // Draw the five family orbital bands — dotted rings. for (let f = 0; f < FAMILY_COUNT; f++) { if (famCount[f] === 0) continue; const r = orbitRadius(f, 0, famCount[f], w, h); ctx.strokeStyle = 'rgba(180,160,220,0.15)'; ctx.setLineDash([2, 6]); ctx.lineWidth = 1; ctx.beginPath(); ctx.ellipse(cx, cy, r, r*0.55, 0, 0, Math.PI*2); ctx.stroke(); } ctx.setLineDash([]); const starR = 20 + 1.5*Math.sin(t*0.002); const starGrad = ctx.createRadialGradient(cx, cy, 4, cx, cy, starR*4); starGrad.addColorStop(0, 'rgba(255,240,180,1)'); starGrad.addColorStop(0.3, 'rgba(255,200,120,0.6)'); starGrad.addColorStop(1, 'rgba(255,200,120,0)'); ctx.fillStyle = starGrad; ctx.beginPath(); ctx.arc(cx, cy, starR*4, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#fff7d6'; ctx.beginPath(); ctx.arc(cx, cy, 12, 0, Math.PI*2); ctx.fill(); pl.forEach((p, i) => { const fam = famOf[i]; const fi = famIdx[i]; const fc = famCount[fam]; const r = orbitRadius(fam, fi, fc, w, h); // Phase distributes worlds of the same family evenly around the ring, // with a small per-family orientation offset so families don't align. const famOffset = fam * 0.6; const period = 40 + fam*14 + (p.orbitAU||1)*4; const phase = famOffset + (fc > 1 ? (fi / fc) * Math.PI*2 : i * 1.37); const ang = phase + (t/1000) * (Math.PI*2/period) * animIntensity; const x = cx + Math.cos(ang)*r; const y = cy + Math.sin(ang)*r * 0.55; const pal = window.PLANETALIN.derivePalette(p.atmosphere || []); const pr = 9 + Math.min(14, (p.rings?.enabled?3:0) + (p.moons?.length||0)*0.9 + Math.log2(2+(p.orbitAU||1))*2); // Outer glow — uses derived glow color const g = ctx.createRadialGradient(x, y, 1, x, y, pr*3); g.addColorStop(0, pal.glow + 'cc'); g.addColorStop(1, pal.glow + '00'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, pr*3, 0, Math.PI*2); ctx.fill(); // Rings (behind the orb, using cloud color) if (p.rings?.enabled) { ctx.strokeStyle = pal.cloud + 'aa'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.ellipse(x, y, pr*1.8, pr*0.5, 0, 0, Math.PI*2); ctx.stroke(); } // Mini-planet body: radial gradient from cloud (lit side) to deep (shadow side) const bodyGrad = ctx.createRadialGradient( x - pr*0.35, y - pr*0.35, pr*0.1, x, y, pr*1.05 ); bodyGrad.addColorStop(0, pal.cloud); bodyGrad.addColorStop(0.55, pal.atmos); bodyGrad.addColorStop(1, pal.deep); ctx.fillStyle = bodyGrad; ctx.beginPath(); ctx.arc(x, y, pr, 0, Math.PI*2); ctx.fill(); // Highlight speck ctx.fillStyle = pal.cloud + 'cc'; ctx.beginPath(); ctx.arc(x-pr*0.4, y-pr*0.4, pr*0.18, 0, Math.PI*2); ctx.fill(); // Tiny creator seal tick (only if planet has a seal) if (p.seal) { ctx.save(); ctx.fillStyle = `oklch(65% 0.20 ${p.seal.hue})`; ctx.beginPath(); ctx.arc(x + pr*0.75, y - pr*0.75, 2.5, 0, Math.PI*2); ctx.fill(); ctx.restore(); } const isH = stateRef.current.hovered === i; if (isH) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(x, y, pr+6, 0, Math.PI*2); ctx.stroke(); ctx.fillStyle = '#fff'; ctx.font = '13px "Space Grotesk", sans-serif'; ctx.textAlign = 'center'; ctx.fillText(p.name, x, y - pr - 14); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.fillText(p.id, x, y - pr - 28); } p.__x = x; p.__y = y; p.__r = pr; }); } raf = requestAnimationFrame(draw); function onMove(ev) { const rect = canvas.getBoundingClientRect(); const mx = ev.clientX - rect.left, my = ev.clientY - rect.top; let hit = null; stateRef.current.planets.forEach((p, i) => { const dx = mx-p.__x, dy = my-p.__y; if (dx*dx+dy*dy < (p.__r+8)*(p.__r+8)) hit = i; }); if (hit !== stateRef.current.hovered) { stateRef.current.hovered = hit; setHovered(hit); canvas.style.cursor = hit !== null ? 'pointer' : 'default'; } } function onClick() { const i = stateRef.current.hovered; if (i == null) return; const p = stateRef.current.planets[i]; window.PLANETALIN.go(`planet/${p.id}`); } canvas.addEventListener('mousemove', onMove); canvas.addEventListener('click', onClick); canvas.addEventListener('mouseleave', () => { stateRef.current.hovered = null; setHovered(null); }); return () => { alive = false; cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('mousemove', onMove); canvas.removeEventListener('click', onClick); }; }, [starfieldDensity, animIntensity]); const count = planets.length; const hoveredPlanet = hovered != null ? planets[hovered] : null; return (
CATALOGUE · {count} {count===1?'world':'worlds'}

Your Constellation

{count === 0 ? 'An empty sky is also a sky. Shape your first world.' : 'Every world here bears your seal. Time passes; they drift on.'}

{count === 0 && (
No worlds yet.
The star is lit and waiting.
)} {hoveredPlanet && (
click to open · {hoveredPlanet.id}
)}
{count > 0 && (
{planets.map((p,i) => { const pal = window.PLANETALIN.derivePalette(p.atmosphere || []); return ( ); })}
)}
); } window.PLANETALINConstellation = { Constellation };