Spaces:
Sleeping
Sleeping
| 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 ( | |
| <svg width={W} height={H} className="sparkline"> | |
| <line x1="0" y1={zeroY.toFixed(1)} x2={W} y2={zeroY.toFixed(1)} className="spark-zero" /> | |
| <polyline points={pts} className="spark-line" /> | |
| <circle cx={W} cy={H - ((history[history.length - 1] - min) / range) * (H - 4) - 2} | |
| r="2.5" className="spark-dot" /> | |
| </svg> | |
| ); | |
| } | |
| // βββ 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 ( | |
| <div className="globe-shell"> | |
| <div className="globe-backdrop" /> | |
| <div className="globe-frame"> | |
| <Globe | |
| ref={globeRef} | |
| width={globeSize} | |
| height={globeSize} | |
| backgroundColor="rgba(4,11,20,0)" | |
| backgroundImageUrl={STARFIELD} | |
| globeImageUrl={EARTH_TEXTURE} | |
| bumpImageUrl={EARTH_BUMP} | |
| showAtmosphere | |
| atmosphereColor="#7ec8ff" | |
| atmosphereAltitude={0.18} | |
| animateIn={false} | |
| waitForGlobeReady={false} | |
| pathsData={pathData} | |
| pathPoints="points" | |
| pathPointLat="lat" | |
| pathPointLng="lng" | |
| pathPointAlt="alt" | |
| pathColor="color" | |
| pathTransitionDuration={STEP_INTERVAL_MS * 0.8} | |
| pathStroke={path => { | |
| const id = String(path.id); | |
| if (id.startsWith("orbit-")) return 0.6; | |
| if (id.startsWith("trail-")) return 1.5; | |
| return 1.2; | |
| }} | |
| pathDashLength={path => { | |
| const id = String(path.id); | |
| return (id.startsWith("orbit-") || id.startsWith("trail-")) ? 0 : 0.05; | |
| }} | |
| pathDashGap={path => { | |
| const id = String(path.id); | |
| return (id.startsWith("orbit-") || id.startsWith("trail-")) ? 0 : 0.1; | |
| }} | |
| pathDashAnimateTime={path => { | |
| const id = String(path.id); | |
| return (id.startsWith("deliver-") && path.active) ? 1600 : 0; | |
| }} | |
| htmlElementsData={markerNodes} | |
| htmlLat="lat" | |
| htmlLng="lng" | |
| htmlAltitude="altitude" | |
| htmlElement={p => { | |
| const el = document.createElement("div"); | |
| el.className = "globe-marker platform"; | |
| el.style.setProperty("--marker-color", p.color); | |
| el.style.setProperty("--marker-size", `${p.size}rem`); | |
| el.innerHTML = ` | |
| <span></span> | |
| <div class="marker-tip"> | |
| <div class="tip-title">Platform ${p.id}</div> | |
| <div class="tip-row"><span>Action</span> | |
| <strong class="tip-action ${p.last_action ?? 'idle'}">${p.last_action ?? "idle"}</strong></div> | |
| <div class="tip-row"><span>Energy</span><strong>${p.energy?.toFixed(0) ?? "?"}%</strong></div> | |
| <div class="tip-row"><span>Materials</span><strong>${p.material_stock?.toFixed(0) ?? "?"}</strong></div> | |
| <div class="tip-row"><span>Components</span><strong>${p.component_stock?.toFixed(0) ?? "?"}</strong></div> | |
| <div class="tip-row"><span>Products</span><strong>${p.product_stock ?? 0}</strong></div> | |
| <div class="tip-row"><span>Alt</span><strong>${p.altitude_km?.toFixed(0) ?? "?"}km</strong></div> | |
| </div>`; | |
| return el; | |
| }} | |
| /> | |
| </div> | |
| <div className="globe-overlay"> | |
| <div> | |
| <p className="eyebrow">Orbital Manufacturing Console</p> | |
| <h1>Live platform tracking</h1> | |
| </div> | |
| <div className="overlay-metrics"> | |
| <div> | |
| <span>Platforms</span> | |
| <strong>{snapshot?.platforms?.length ?? 0}</strong> | |
| </div> | |
| <div> | |
| <span>Delivering</span> | |
| <strong> | |
| {snapshot?.platforms?.filter(p => p.last_action === "deliver").length ?? 0} | |
| </strong> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // βββ agent stats βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function AgentStats({ snapshot }) { | |
| const metrics = snapshot?.metrics ?? {}; | |
| const history = snapshot?.reward_history ?? []; | |
| const score = snapshot?.mission_score ?? 0; | |
| const step = snapshot?.step_count ?? 0; | |
| const maxStep = snapshot?.max_steps ?? 1; | |
| const pct = Math.min(100, Math.round((step / maxStep) * 100)); | |
| const lastReward = history.length ? history[history.length - 1] : 0; | |
| const avgReward = history.length | |
| ? (history.reduce((a, b) => a + b, 0) / history.length).toFixed(2) | |
| : "0.00"; | |
| const posSteps = history.filter(r => r > 0).length; | |
| const efficiency = history.length ? Math.round((posSteps / history.length) * 100) : 0; | |
| return ( | |
| <div className="card agent-card"> | |
| <div className="card-head"> | |
| <div><p className="eyebrow">RL Agent</p><h3>Performance</h3></div> | |
| <Gauge size={15} className="icon-muted" /> | |
| </div> | |
| <div className="score-row"> | |
| <div className="score-arc"> | |
| <svg viewBox="0 0 60 38" width="90" height="56"> | |
| <path d="M 5 35 A 25 25 0 0 1 55 35" fill="none" | |
| stroke="rgba(100,140,200,0.15)" strokeWidth="5" strokeLinecap="round" /> | |
| <path d="M 5 35 A 25 25 0 0 1 55 35" fill="none" | |
| stroke={score > 0.7 ? "#7cf7c9" : score > 0.4 ? "#ffb86b" : "#ff8f8f"} | |
| strokeWidth="5" strokeLinecap="round" | |
| strokeDasharray={`${score * 78.5} 78.5`} /> | |
| <text x="30" y="34" textAnchor="middle" fill="#e8f4ff" fontSize="10" fontWeight="700"> | |
| {Math.round(score * 100)}% | |
| </text> | |
| </svg> | |
| <span className="score-label">Mission Score</span> | |
| </div> | |
| <div className="agent-kpis"> | |
| <div className="kpi"> | |
| <span>Avg reward/step</span> | |
| <strong className={Number(avgReward) >= 0 ? "pos" : "neg"}> | |
| {Number(avgReward) >= 0 ? "+" : ""}{avgReward} | |
| </strong> | |
| </div> | |
| <div className="kpi"><span>Positive steps</span><strong>{efficiency}%</strong></div> | |
| <div className="kpi"> | |
| <span>Last step</span> | |
| <strong className={lastReward >= 0 ? "pos" : "neg"}> | |
| {lastReward >= 0 ? "+" : ""}{lastReward.toFixed(2)} | |
| </strong> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="spark-wrap"> | |
| <span className="spark-label">Step rewards</span> | |
| <RewardSparkline history={history} /> | |
| </div> | |
| <div className="ep-progress"> | |
| <div className="ep-head"> | |
| <span>Episode {step}/{maxStep}</span> | |
| <span>{pct}% complete</span> | |
| </div> | |
| <div className="ep-bar"><div className="ep-fill" style={{ width: `${pct}%` }} /></div> | |
| </div> | |
| <div className="agent-pills"> | |
| <span className="ap">Produced: {metrics.production_runs ?? 0}</span> | |
| <span className="ap">Assembled: {metrics.assemblies_completed ?? 0}</span> | |
| <span className="ap">Delivered: {metrics.deliveries_completed ?? 0}</span> | |
| <span className="ap">On-time: {metrics.on_time_deliveries ?? 0}</span> | |
| <span className="ap warn">Invalid: {metrics.invalid_actions ?? 0}</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // βββ platform fleet ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function PlatformFleet({ snapshot }) { | |
| const platforms = snapshot?.platforms ?? []; | |
| return ( | |
| <div className="card fleet-card"> | |
| <div className="card-head"> | |
| <div><p className="eyebrow">Fleet</p><h3>Platforms</h3></div> | |
| <Factory size={15} className="icon-muted" /> | |
| </div> | |
| <div className="sat-list"> | |
| <AnimatePresence> | |
| {platforms.map(p => ( | |
| <motion.div key={p.id} className="sat-row" layout> | |
| <div className="sat-head"> | |
| <span className="sat-name">Platform {p.id}</span> | |
| <span className={`act-badge ${p.last_action ?? "idle"}`}> | |
| {p.last_action ?? "idle"} | |
| </span> | |
| </div> | |
| <div className="bar-row"> | |
| <Zap size={10} className="bar-icon" /> | |
| <div className="bar-track"> | |
| <div className="bar-fill battery" | |
| style={{ width: `${p.energy}%`, "--bar-color": energyColor(p.energy) }} /> | |
| </div> | |
| <span className="bar-val">{p.energy.toFixed(0)}%</span> | |
| </div> | |
| <div className="bar-row"> | |
| <Wrench size={10} className="bar-icon" /> | |
| <div className="bar-track"> | |
| <div className="bar-fill mat" style={{ width: `${p.material_stock}%` }} /> | |
| </div> | |
| <span className="bar-val">{p.material_stock.toFixed(0)}</span> | |
| </div> | |
| <div className="bar-row"> | |
| <Activity size={10} className="bar-icon" /> | |
| <div className="bar-track"> | |
| <div className="bar-fill comp" style={{ width: `${p.component_stock}%` }} /> | |
| </div> | |
| <span className="bar-val">{p.component_stock.toFixed(0)}</span> | |
| </div> | |
| <div className="bar-row"> | |
| <Package size={10} className="bar-icon" /> | |
| <div className="bar-track"> | |
| <div className="bar-fill prod" | |
| style={{ width: `${(p.product_stock / 10) * 100}%` }} /> | |
| </div> | |
| <span className="bar-val">{p.product_stock}</span> | |
| </div> | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // βββ delivery orders βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function DeliveryOrders({ snapshot }) { | |
| const windows = snapshot?.delivery_windows ?? []; | |
| const orders = snapshot?.pending_orders ?? []; | |
| const step = snapshot?.step_count ?? 0; | |
| return ( | |
| <div className="card mission-card"> | |
| <div className="card-head"> | |
| <div><p className="eyebrow">Logistics</p><h3>Delivery Orders</h3></div> | |
| <Truck size={16} className="icon-muted" /> | |
| </div> | |
| <div className="task-list"> | |
| {windows.slice(0, 8).map(w => { | |
| const urgency = w.deadline - step; | |
| const isUrgent = urgency <= 10; | |
| return ( | |
| <div key={w.order_id} className="task-row"> | |
| <div className="task-icon"> | |
| <Circle size={13} className="icon-pending" /> | |
| </div> | |
| <div className="task-body"> | |
| <span className="task-id">Order #{w.order_id} β {w.product_type}</span> | |
| <span className="task-desc"> | |
| Deadline: step {w.deadline} ({urgency > 0 ? `${urgency} left` : "overdue"}) | |
| </span> | |
| </div> | |
| <span className={`prio-badge ${isUrgent ? "prio-3" : urgency <= 25 ? "prio-2" : "prio-1"}`}> | |
| {isUrgent ? "URGENT" : `${urgency}s`} | |
| </span> | |
| </div> | |
| ); | |
| })} | |
| {windows.length === 0 && ( | |
| <p className="empty-text"> | |
| <CheckCircle2 size={14} style={{ display: "inline", marginRight: 5 }} /> | |
| All deliveries complete | |
| </p> | |
| )} | |
| </div> | |
| <div className="task-summary"> | |
| <span><CheckCircle2 size={11} /> {(snapshot?.metrics?.deliveries_completed ?? 0)} delivered</span> | |
| <span><Package size={11} /> {orders.length} pending orders</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // βββ solar conditions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function SolarConditions({ snapshot }) { | |
| const solar = snapshot?.solar_conditions ?? {}; | |
| return ( | |
| <div className="card"> | |
| <div className="card-head"> | |
| <div><p className="eyebrow">Power</p><h3>Solar Conditions</h3></div> | |
| <Sun size={15} className="icon-muted" /> | |
| </div> | |
| <div className="weather-row"> | |
| {Object.entries(solar).map(([zone, irr]) => ( | |
| <div key={zone} className="weather-pill" | |
| style={{ "--cloud-pct": `${Math.round(Number(irr) * 100)}%` }}> | |
| <span className="wregion">{zone.replace(/_/g, " ")}</span> | |
| <span className="wval">{Math.round(Number(irr) * 100)}%</span> | |
| <div className="wbar"><div className="wfill solar" /></div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // βββ main app βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function App() { | |
| const { snapshot, loading, playing, taskName, error, setPlaying, resetDemo, stepDemo } = | |
| useDemoData(); | |
| const [globeSize, setGlobeSize] = useState(540); | |
| const panelRef = useRef(null); | |
| useEffect(() => { | |
| function resize() { | |
| if (!panelRef.current) return; | |
| const { width, height } = panelRef.current.getBoundingClientRect(); | |
| setGlobeSize(Math.floor(Math.min(width, height, 620))); | |
| } | |
| resize(); | |
| window.addEventListener("resize", resize); | |
| return () => window.removeEventListener("resize", resize); | |
| }, []); | |
| const score = snapshot?.mission_score ?? 0; | |
| const scoreStr = `${Math.round(score * 100)}%`; | |
| const scoreClass = score > 0.7 ? "good" : score > 0.4 ? "warn" : "bad"; | |
| return ( | |
| <div className="app-shell"> | |
| <div className="ambient ambient-a" /> | |
| <div className="ambient ambient-b" /> | |
| {/* ββ header ββ */} | |
| <header className="topbar"> | |
| <div className="brand"> | |
| <div className="brand-mark"><Factory size={16} /></div> | |
| <div> | |
| <p className="eyebrow">OpenEnv Β· RL Benchmark</p> | |
| <h2>Space Manufacturing Control</h2> | |
| </div> | |
| </div> | |
| <div className="task-switcher"> | |
| {TASKS.map(t => ( | |
| <button key={t} type="button" | |
| className={t === taskName ? "task-pill active" : "task-pill"} | |
| onClick={() => resetDemo(t)}> | |
| {t} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="header-right"> | |
| <div className={`score-badge ${scoreClass}`}> | |
| <span>Mission</span> | |
| <strong>{scoreStr}</strong> | |
| </div> | |
| <div className="header-controls"> | |
| <button type="button" className="action-button primary sm" | |
| onClick={() => setPlaying(!playing)}> | |
| {snapshot?.done ? "Done" : playing ? "Pause" : "Play"} | |
| </button> | |
| <button type="button" className="action-button sm" onClick={stepDemo} | |
| disabled={loading || snapshot?.done || | |
| (snapshot?.step_count ?? 0) >= (snapshot?.max_steps ?? 0)}> | |
| Step | |
| </button> | |
| <button type="button" className="ghost-button sm" onClick={() => resetDemo(taskName)}> | |
| <RefreshCcw size={12} /> Reset | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| {error && <div className="error-banner">{error}</div>} | |
| {/* ββ main grid ββ */} | |
| <main className="main-grid"> | |
| {/* Globe panel */} | |
| <section className="col-globe" ref={panelRef}> | |
| {loading | |
| ? <div className="loading-state">Loading orbital telemetryβ¦</div> | |
| : <GlobeView snapshot={snapshot} globeSize={globeSize} />} | |
| </section> | |
| {/* Middle column */} | |
| <section className="col-mid"> | |
| <AgentStats snapshot={snapshot} /> | |
| <DeliveryOrders snapshot={snapshot} /> | |
| <SolarConditions snapshot={snapshot} /> | |
| </section> | |
| {/* Right column */} | |
| <section className="col-right"> | |
| <PlatformFleet snapshot={snapshot} /> | |
| </section> | |
| </main> | |
| </div> | |
| ); | |
| } | |