import { useEffect, useMemo, useRef, useState } from "react"; import Globe from "react-globe.gl"; import { motion, AnimatePresence } from "framer-motion"; import { Activity, CheckCircle2, Circle, Factory, Gauge, Package, RefreshCcw, Sun, Truck, Wrench, Zap, } from "lucide-react"; const TASKS = ["easy", "medium", "hard"]; const STEP_INTERVAL_MS = 2200; const explicitApiBase = import.meta.env.VITE_API_BASE?.replace(/\/$/, ""); const isLocalPreview = typeof window !== "undefined" && ["localhost", "127.0.0.1"].includes(window.location.hostname) && window.location.port === "4173"; const API_BASE = explicitApiBase || (isLocalPreview ? "http://127.0.0.1:8000" : ""); const EARTH_TEXTURE = "https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg"; const EARTH_BUMP = "https://unpkg.com/three-globe/example/img/earth-topology.png"; const STARFIELD = "https://unpkg.com/three-globe/example/img/night-sky.png"; function apiUrl(path) { return `${API_BASE}${path}`; } // ─── data hook ─────────────────────────────────────────────────────────────── function useDemoData() { const [snapshot, setSnapshot] = useState(null); const [loading, setLoading] = useState(true); const [playing, setPlaying] = useState(true); const [taskName, setTaskName] = useState("medium"); const [error, setError] = useState(""); const steppingRef = useRef(false); async function loadSnapshot() { try { const res = await fetch(apiUrl("/api/ui/demo")); if (!res.ok) throw new Error(`Snapshot ${res.status}`); const data = await res.json(); setSnapshot(data); setTaskName(data.task_name ?? "medium"); setError(""); } catch (err) { setError(err instanceof Error ? err.message : "Unable to load snapshot"); } finally { setLoading(false); } } async function resetDemo(nextTask) { setLoading(true); try { const res = await fetch( apiUrl(`/api/ui/demo/reset?task_name=${encodeURIComponent(nextTask)}`), { method: "POST" } ); if (!res.ok) throw new Error(`Reset ${res.status}`); const data = await res.json(); setSnapshot(data); setTaskName(data.task_name ?? nextTask); setPlaying(true); setError(""); } catch (err) { setError(err instanceof Error ? err.message : "Unable to reset"); } finally { setLoading(false); } } async function stepDemo() { if (steppingRef.current || loading) return; if (snapshot?.done || (snapshot?.step_count ?? 0) >= (snapshot?.max_steps ?? Infinity)) { setPlaying(false); return; } steppingRef.current = true; try { const res = await fetch(apiUrl("/api/ui/demo/step"), { method: "POST" }); if (!res.ok) throw new Error(`Step ${res.status}`); const data = await res.json(); setSnapshot(data); if (data.done || (data.step_count ?? 0) >= (data.max_steps ?? Infinity)) setPlaying(false); setError(""); } catch (err) { setPlaying(false); setError(err instanceof Error ? err.message : "Unable to step"); } finally { steppingRef.current = false; } } useEffect(() => { loadSnapshot(); }, []); useEffect(() => { if (!playing || loading || snapshot?.done || (snapshot?.step_count ?? 0) >= (snapshot?.max_steps ?? Infinity)) return; const timer = window.setInterval(stepDemo, STEP_INTERVAL_MS); return () => window.clearInterval(timer); }, [playing, loading, snapshot]); return { snapshot, loading, playing, taskName, error, setPlaying, resetDemo, stepDemo }; } // ─── colour helpers ────────────────────────────────────────────────────────── function platformColor(action) { switch (action) { case "produce": return "#ffb86b"; case "assemble": return "#9caaff"; case "deliver": return "#7cf7c9"; case "recharge": return "#59b8ff"; default: return "#95a2bb"; } } function energyColor(v) { if (v < 20) return "#ff7171"; if (v < 40) return "#ffb86b"; return "#7cf7c9"; } // ─── reward sparkline ──────────────────────────────────────────────────────── function RewardSparkline({ history }) { if (!history || history.length < 2) return null; const W = 160, H = 36; const min = Math.min(...history); const max = Math.max(...history); const range = max - min || 1; const pts = history.map((v, i) => { const x = (i / (history.length - 1)) * W; const y = H - ((v - min) / range) * (H - 4) - 2; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(" "); const zeroY = H - ((0 - min) / range) * (H - 4) - 2; return ( ); } // ─── Globe view ─────────────────────────────────────────────────────────────── function GlobeView({ snapshot, globeSize }) { const globeRef = useRef(null); const pathStoreRef = useRef([]); const snapAnimRef = useRef(null); const [pathData, setPathData] = useState([]); // Lock camera controls + snap back to equator after user interaction useEffect(() => { if (!globeRef.current) return; const controls = globeRef.current.controls(); controls.autoRotate = true; controls.autoRotateSpeed = 0.6; controls.enablePan = false; controls.enableZoom = false; controls.minDistance = 210; controls.maxDistance = 310; controls.minPolarAngle = Math.PI * 0.25; controls.maxPolarAngle = Math.PI * 0.75; const snapToEquator = () => { if (snapAnimRef.current) cancelAnimationFrame(snapAnimRef.current); const camera = globeRef.current.camera(); const tick = () => { const tx = controls.target.x, ty = controls.target.y, tz = controls.target.z; const dx = camera.position.x - tx; const dy = camera.position.y - ty; const dz = camera.position.z - tz; const r = Math.sqrt(dx * dx + dy * dy + dz * dz); const phi = Math.atan2(Math.sqrt(dx * dx + dz * dz), dy); // current polar angle const diff = Math.PI / 2 - phi; // distance from equator if (Math.abs(diff) < 0.002) { controls.update(); return; } const newPhi = phi + diff * 0.08; // ease toward equator const theta = Math.atan2(dz, dx); const sinPhi = Math.sin(newPhi); const cosPhi = Math.cos(newPhi); camera.position.set( tx + r * sinPhi * Math.cos(theta), ty + r * cosPhi, tz + r * sinPhi * Math.sin(theta), ); controls.update(); snapAnimRef.current = requestAnimationFrame(tick); }; snapAnimRef.current = requestAnimationFrame(tick); }; // Cancel snap if user grabs the globe again const cancelSnap = () => { if (snapAnimRef.current) cancelAnimationFrame(snapAnimRef.current); }; controls.addEventListener('end', snapToEquator); controls.addEventListener('start', cancelSnap); return () => { controls.removeEventListener('end', snapToEquator); controls.removeEventListener('start', cancelSnap); if (snapAnimRef.current) cancelAnimationFrame(snapAnimRef.current); }; }, []); // Build/update path data from snapshot useEffect(() => { const platforms = snapshot?.platforms ?? []; const stepCount = snapshot?.step_count ?? 0; if (!platforms.length) { pathStoreRef.current = []; setPathData([]); return; } if (stepCount === 0) { // Fresh reset — build all paths from scratch const next = []; for (const p of platforms) { const alt = 0.11 + Math.min(p.altitude_km / 8000, 0.1); const color = platformColor(p.last_action); // Full orbit ring const orbitPts = (p.route ?? []).map(n => ({ lat: n.latitude, lng: n.longitude, alt })); if (orbitPts.length > 1) next.push({ id: `orbit-${p.id}`, color, points: orbitPts }); // Moving trail next.push({ id: `trail-${p.id}`, color, points: [{ lat: p.latitude, lng: p.longitude, alt }] }); // Collapsed delivery arc (pre-create so it never pops in) next.push({ id: `deliver-${p.id}`, color: "#7cf7c9", points: [{ lat: p.latitude, lng: p.longitude, alt }, { lat: p.latitude, lng: p.longitude, alt: 0.01 }], active: false }); } pathStoreRef.current = next; } else { const pathMap = new Map(pathStoreRef.current.map(p => [p.id, p])); for (const p of platforms) { const alt = 0.11 + Math.min(p.altitude_km / 8000, 0.1); const color = platformColor(p.last_action); const pt = { lat: p.latitude, lng: p.longitude, alt }; // Update orbit ring colour const orbit = pathMap.get(`orbit-${p.id}`); if (orbit) orbit.color = color; // Extend trail const trail = pathMap.get(`trail-${p.id}`); if (trail) { trail.color = color; const last = trail.points[trail.points.length - 1]; const moved = !last || Math.abs(last.lat - pt.lat) > 0.0001 || Math.abs(last.lng - pt.lng) > 0.0001; if (moved) { trail.points.push(pt); if (trail.points.length > 120) trail.points.shift(); } } // Delivery arc — show when action is "deliver" const arc = pathMap.get(`deliver-${p.id}`); if (arc) { if (p.last_action === "deliver") { // Point to a notional ground station below arc.points = [{ lat: p.latitude, lng: p.longitude, alt }, { lat: 0, lng: p.longitude, alt: 0.01 }]; arc.color = "#7cf7c9"; arc.active = true; } else if (arc.active) { const start = arc.points[0] ?? pt; arc.points = [start, { ...start, alt: start.alt - 0.001 }]; arc.active = false; } } } pathStoreRef.current = [...pathMap.values()]; } setPathData([...pathStoreRef.current]); }, [snapshot]); // HTML marker nodes for platforms const markerNodes = useMemo(() => { return (snapshot?.platforms ?? []).map(p => ({ ...p, lat: p.latitude, lng: p.longitude, altitude: 0.1 + Math.min(p.altitude_km / 8000, 0.1), color: platformColor(p.last_action), size: 0.44, })); }, [snapshot]); return (
Orbital Manufacturing Console
RL Agent
Fleet
Logistics
Power
OpenEnv · RL Benchmark