// 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 (
{/* 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');
}