// Planet viewer: read-only display of a sealed planet. Shows 3D render, // ID, spec, share URL, and "fork this planet" action. const { useState: useStateV, useEffect: useEffectV, useRef: useRefV, useMemo: useMemoV } = React; function PlanetClock({ rotationHours }) { const [tick, setTick] = useStateV(0); useEffectV(() => { const id = setInterval(() => setTick(t => t+1), 500); return () => clearInterval(id); }, []); // 1 real minute = 1 planet day. So planet hour = real second × (24/60) × (24/rotationHours) // Simpler: planet has a day that takes 60s scaled by rotationHours/24 const now = Date.now()/1000; // seconds const dayLengthReal = 60 * ((rotationHours||24)/24); const planetT = (now % dayLengthReal) / dayLengthReal; // 0..1 of a day const hour = Math.floor(planetT * (rotationHours||24)); const min = Math.floor((planetT * (rotationHours||24) - hour) * 60); return (
Local time
{String(hour).padStart(2,'0')}:{String(min).padStart(2,'0')}
day is {rotationHours}h · 60s real
); } function ViewerPanel({ planet, permalink, starfieldDensity, animIntensity }) { const canvasRef = useRefV(null); const sceneRef = useRefV(null); const [zoomLevel, setZoomLevel] = useStateV(1); const [exportBusy, setExportBusy] = useStateV(false); useEffectV(() => { if (!canvasRef.current) return; const scene = window.PLANETALIN.createPlanetScene(canvasRef.current, { cameraDistance: 3.6, starfieldDensity, animIntensity, }); sceneRef.current = scene; scene.setSpec(planet); // Wheel zoom const canvas = canvasRef.current; function onWheel(e) { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; scene.zoomBy(delta); setZoomLevel(scene.getZoom()); } canvas.addEventListener('wheel', onWheel, { passive: false }); // Pinch zoom (touch) let pinchStartDist = 0; let pinchStartZoom = 1; function dist(t1, t2) { const dx = t1.clientX - t2.clientX, dy = t1.clientY - t2.clientY; return Math.hypot(dx, dy); } function onTouchStart(e) { if (e.touches.length === 2) { pinchStartDist = dist(e.touches[0], e.touches[1]); pinchStartZoom = scene.getZoom(); } } function onTouchMove(e) { if (e.touches.length === 2 && pinchStartDist > 0) { e.preventDefault(); const d = dist(e.touches[0], e.touches[1]); scene.setZoom(pinchStartZoom * (d / pinchStartDist)); setZoomLevel(scene.getZoom()); } } canvas.addEventListener('touchstart', onTouchStart, { passive: false }); canvas.addEventListener('touchmove', onTouchMove, { passive: false }); return () => { canvas.removeEventListener('wheel', onWheel); canvas.removeEventListener('touchstart', onTouchStart); canvas.removeEventListener('touchmove', onTouchMove); scene.dispose(); }; }, [planet.id]); useEffectV(() => { if (sceneRef.current) { sceneRef.current.update({ starfieldDensity, animIntensity }); } }, [starfieldDensity, animIntensity]); function zoomIn() { if (!sceneRef.current) return; sceneRef.current.zoomBy(1.2); setZoomLevel(sceneRef.current.getZoom()); } function zoomOut() { if (!sceneRef.current) return; sceneRef.current.zoomBy(1/1.2); setZoomLevel(sceneRef.current.getZoom()); } function zoomReset() { if (!sceneRef.current) return; sceneRef.current.setZoom(1); setZoomLevel(1); } const star = (window.PLANETALIN_STARS||[]).find(s => s.id === planet.star); const shareUrl = useMemoV(() => window.PLANETALIN.permalinkFor(planet), [planet]); const [copied, setCopied] = useStateV(false); function copyUrl() { navigator.clipboard?.writeText(shareUrl).catch(()=>{}); setCopied(true); setTimeout(() => setCopied(false), 1400); } async function exportPng() { if (!sceneRef.current) return; setExportBusy(true); try { // Render a large version of the planet card (1200×1500) as a poster. const card = await renderPlanetCard(planet, sceneRef.current); const a = document.createElement('a'); a.href = card; a.download = `planetalin-${planet.name || planet.id}.png`; a.click(); } finally { setExportBusy(false); } } function exportJson() { const blob = new Blob([JSON.stringify(planet, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `planetalin-${planet.name || planet.id}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function saveToMyCatalogue() { const id = window.PLANETALIN.importCataloguePlanet(planet); if (id) window.PLANETALIN.go(`planet/${id}`); } return (
CATALOGUE
{planet.id}
SEALED · PERMANENT · SEALED · PERMANENT ·
{/* Zoom controls */}
{Math.round(zoomLevel * 100)}%
); } window.PLANETALINViewer = { ViewerPanel }; // --- Poster PNG renderer -------------------------------------------------- // Composes a shareable 1200×1500 card: big 3D render on top, metadata below. // Called from the viewer's "Poster PNG" button. async function renderPlanetCard(planet, scene) { const W = 1200, H = 1500; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); // Background — deep violet, matches the product ground const bg = ctx.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, '#0f0822'); bg.addColorStop(1, '#1a0e38'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); // Subtle starfield for (let i = 0; i < 160; i++) { const x = Math.random() * W; const y = Math.random() * H; const r = Math.random() * 1.3; ctx.fillStyle = `rgba(255,255,255,${0.2 + Math.random()*0.6})`; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill(); } // Render the planet at 1000×1000 into the scene, then drop it into the card const planetImg = new Image(); const planetDataUrl = scene.capture(1000, 1000); await new Promise((res, rej) => { planetImg.onload = res; planetImg.onerror = rej; planetImg.src = planetDataUrl; }); const planetW = 900, planetH = 900; ctx.drawImage(planetImg, (W - planetW)/2, 70, planetW, planetH); // Title ctx.fillStyle = '#f0e5ff'; ctx.textAlign = 'center'; ctx.font = '300 92px "Cormorant Garamond", serif'; ctx.fillText(planet.name || '—', W/2, 1080); // Subtitle ctx.fillStyle = 'rgba(240,229,255,0.6)'; ctx.font = '400 22px "Space Grotesk", sans-serif'; ctx.fillText(`discovered by ${planet.discoverer || '—'}`, W/2, 1115); // Catalogue id + seal ctx.fillStyle = 'rgba(240,229,255,0.4)'; ctx.font = '300 14px "JetBrains Mono", monospace'; ctx.fillText(`${planet.id} · sealed ${new Date(planet.createdAt||Date.now()).toISOString().slice(0,10)}`, W/2, 1145); // Atmosphere bar const barX = 140, barY = 1190, barW = W - 280, barH = 24; let cursor = barX; (planet.atmosphere||[]).forEach(a => { const el = window.PLANETALIN_ELEMENTS.find(e => e.sym === a.sym); const segW = (a.pct/100) * barW; ctx.fillStyle = el?.color || '#888'; ctx.fillRect(cursor, barY, segW, barH); cursor += segW; }); // Bar label ctx.fillStyle = 'rgba(240,229,255,0.5)'; ctx.font = '400 11px "JetBrains Mono", monospace'; ctx.textAlign = 'left'; ctx.fillText('ATMOSPHERE', barX, barY - 8); // Facts row const facts = [ ['Surface', planet.surface], ['Star', (planet.star||'') + '-type'], ['Day', (planet.rotationHours||0) + 'h'], ['Orbit', (planet.orbitAU||0).toFixed(2) + ' AU'], ['Moons', String((planet.moons||[]).length)], ['Rings', planet.rings?.enabled ? `${planet.rings.count} bands` : 'none'], ]; const factY = 1280; const factCellW = (W - 280) / facts.length; facts.forEach(([lbl, val], i) => { const fx = barX + i * factCellW + factCellW/2; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(240,229,255,0.45)'; ctx.font = '400 11px "JetBrains Mono", monospace'; ctx.fillText(lbl.toUpperCase(), fx, factY); ctx.fillStyle = '#f0e5ff'; ctx.font = '400 22px "Space Grotesk", sans-serif'; ctx.fillText(String(val), fx, factY + 30); }); // Seal footer if (planet.seal) { const sy = H - 90; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(240,229,255,0.35)'; ctx.font = '400 11px "JetBrains Mono", monospace'; ctx.fillText('SEALED BY', W/2, sy); ctx.fillStyle = '#f0e5ff'; ctx.font = 'italic 300 28px "Cormorant Garamond", serif'; ctx.fillText(`${planet.seal.glyph} ${planet.seal.name}`, W/2, sy + 34); } else { ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(240,229,255,0.3)'; ctx.font = '400 11px "JetBrains Mono", monospace'; ctx.fillText('PLANETALIN · CONSTELLATION LAB', W/2, H - 50); } return canvas.toDataURL('image/png'); }