Spaces:
Sleeping
Sleeping
| import { useEffect, useRef, useState } from "react"; | |
| import Globe from "react-globe.gl"; | |
| import { motion } from "framer-motion"; | |
| import { | |
| Activity, | |
| Gauge, | |
| Orbit, | |
| Radio, | |
| RefreshCcw, | |
| Satellite, | |
| Signal, | |
| } 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}`; | |
| } | |
| 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 response = await fetch(apiUrl("/api/ui/demo")); | |
| if (!response.ok) { | |
| throw new Error(`Snapshot request failed with ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| setSnapshot(data); | |
| setTaskName(data.task_name ?? "medium"); | |
| setError(""); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "Unable to load UI snapshot"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function resetDemo(nextTask) { | |
| setLoading(true); | |
| try { | |
| const response = await fetch(apiUrl(`/api/ui/demo/reset?task_name=${encodeURIComponent(nextTask)}`), { | |
| method: "POST", | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Reset failed with ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| setSnapshot(data); | |
| setTaskName(data.task_name ?? nextTask); | |
| setError(""); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "Unable to reset demo"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function stepDemo() { | |
| if (steppingRef.current || loading) { | |
| return; | |
| } | |
| if (snapshot?.done || (snapshot?.step_count ?? 0) >= (snapshot?.max_steps ?? Number.POSITIVE_INFINITY)) { | |
| setPlaying(false); | |
| return; | |
| } | |
| steppingRef.current = true; | |
| try { | |
| const response = await fetch(apiUrl("/api/ui/demo/step"), { method: "POST" }); | |
| if (!response.ok) { | |
| throw new Error(`Step failed with ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| setSnapshot(data); | |
| if (data.done || (data.step_count ?? 0) >= (data.max_steps ?? Number.POSITIVE_INFINITY)) { | |
| setPlaying(false); | |
| } | |
| setError(""); | |
| } catch (err) { | |
| setPlaying(false); | |
| setError(err instanceof Error ? err.message : "Unable to advance demo"); | |
| } finally { | |
| steppingRef.current = false; | |
| } | |
| } | |
| useEffect(() => { | |
| loadSnapshot(); | |
| }, []); | |
| useEffect(() => { | |
| if (!playing || loading || snapshot?.done || (snapshot?.step_count ?? 0) >= (snapshot?.max_steps ?? Number.POSITIVE_INFINITY)) { | |
| return undefined; | |
| } | |
| const timer = window.setInterval(() => { | |
| stepDemo(); | |
| }, STEP_INTERVAL_MS); | |
| return () => window.clearInterval(timer); | |
| }, [playing, loading, snapshot]); | |
| return { | |
| snapshot, | |
| loading, | |
| playing, | |
| taskName, | |
| error, | |
| setPlaying, | |
| resetDemo, | |
| stepDemo, | |
| }; | |
| } | |
| function GlobeView({ snapshot }) { | |
| const globeRef = useRef(null); | |
| const [view, setView] = useState({ lat: 14, lng: 12, altitude: 2.35 }); | |
| const pathStoreRef = useRef([]); | |
| const [pathData, setPathData] = useState([]); | |
| useEffect(() => { | |
| if (!globeRef.current) { | |
| return; | |
| } | |
| const controls = globeRef.current.controls(); | |
| controls.autoRotate = false; | |
| controls.enablePan = false; | |
| controls.enableZoom = false; | |
| controls.minDistance = 220; | |
| controls.maxDistance = 320; | |
| controls.minPolarAngle = Math.PI * 0.42; | |
| controls.maxPolarAngle = Math.PI * 0.58; | |
| const handleChange = () => { | |
| const pov = globeRef.current?.pointOfView(); | |
| if (!pov) { | |
| return; | |
| } | |
| const clamped = { | |
| lat: Math.max(4, Math.min(24, pov.lat)), | |
| lng: pov.lng, | |
| altitude: 2.35, | |
| }; | |
| if ( | |
| Math.abs(clamped.lat - pov.lat) > 0.01 || | |
| Math.abs(clamped.altitude - pov.altitude) > 0.01 | |
| ) { | |
| globeRef.current.pointOfView(clamped, 0); | |
| } | |
| }; | |
| controls.addEventListener("change", handleChange); | |
| return () => { | |
| controls.removeEventListener("change", handleChange); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (!globeRef.current) { | |
| return; | |
| } | |
| globeRef.current.pointOfView(view, 0); | |
| }, [view]); | |
| useEffect(() => { | |
| const satellites = snapshot?.satellites ?? []; | |
| const stepCount = snapshot?.step_count ?? 0; | |
| if (!satellites.length) { | |
| pathStoreRef.current = []; | |
| setPathData([]); | |
| return; | |
| } | |
| if (stepCount === 0) { | |
| const nextPaths = []; | |
| for (const satellite of satellites) { | |
| const altitude = 0.11 + Math.min(satellite.altitude_km / 8000, 0.1); | |
| const orbitPoints = (satellite.route ?? []).map((node) => ({ | |
| lat: node.latitude, | |
| lng: node.longitude, | |
| alt: altitude, | |
| })); | |
| if (orbitPoints.length > 1) { | |
| nextPaths.push({ | |
| id: `orbit-${satellite.id}`, | |
| color: satelliteColor(satellite.last_action), | |
| points: orbitPoints, | |
| }); | |
| } | |
| nextPaths.push({ | |
| id: `trail-${satellite.id}`, | |
| color: satelliteColor(satellite.last_action), | |
| points: [ | |
| { | |
| lat: satellite.latitude, | |
| lng: satellite.longitude, | |
| alt: altitude, | |
| }, | |
| ], | |
| }); | |
| } | |
| pathStoreRef.current = nextPaths; | |
| } else { | |
| const pathMap = new Map(pathStoreRef.current.map((path) => [path.id, path])); | |
| for (const satellite of satellites) { | |
| const orbitId = `orbit-${satellite.id}`; | |
| const trailId = `trail-${satellite.id}`; | |
| const color = satelliteColor(satellite.last_action); | |
| const altitude = 0.11 + Math.min(satellite.altitude_km / 8000, 0.1); | |
| const nextPoint = { | |
| lat: satellite.latitude, | |
| lng: satellite.longitude, | |
| alt: altitude, | |
| }; | |
| const orbitPath = pathMap.get(orbitId); | |
| if (orbitPath) { | |
| orbitPath.color = color; | |
| } | |
| const trailPath = pathMap.get(trailId); | |
| if (!trailPath) { | |
| pathMap.set(trailId, { | |
| id: trailId, | |
| color, | |
| points: [nextPoint], | |
| }); | |
| continue; | |
| } | |
| trailPath.color = color; | |
| const lastPoint = trailPath.points[trailPath.points.length - 1]; | |
| const hasSamePoint = | |
| lastPoint && | |
| Math.abs(lastPoint.lat - nextPoint.lat) < 0.0001 && | |
| Math.abs(lastPoint.lng - nextPoint.lng) < 0.0001; | |
| if (!hasSamePoint) { | |
| trailPath.points.push(nextPoint); | |
| if (trailPath.points.length > 160) { | |
| trailPath.points.shift(); | |
| } | |
| } | |
| } | |
| pathStoreRef.current = [...pathMap.values()]; | |
| } | |
| const pathMap = new Map(pathStoreRef.current.map((path) => [path.id, path])); | |
| const activeTransferPaths = new Set(); | |
| for (const transfer of snapshot?.transfers ?? []) { | |
| if (transfer.kind !== "downlink" && transfer.kind !== "capture") { | |
| continue; | |
| } | |
| const pathId = `${transfer.kind}-${transfer.satellite_id}`; | |
| activeTransferPaths.add(pathId); | |
| const points = [ | |
| { | |
| lat: transfer.from.latitude, | |
| lng: transfer.from.longitude, | |
| alt: transfer.kind === "downlink" ? 0.13 : 0.15, | |
| }, | |
| { | |
| lat: transfer.to.latitude, | |
| lng: transfer.to.longitude, | |
| alt: transfer.kind === "downlink" ? 0.015 : 0.012, | |
| }, | |
| ]; | |
| const existingPath = pathMap.get(pathId); | |
| if (existingPath) { | |
| existingPath.points.splice(0, existingPath.points.length, ...points); | |
| existingPath.visible = true; | |
| existingPath.color = transfer.kind === "downlink" ? "#7cf7c9" : "#ffb86b"; | |
| } else { | |
| pathMap.set(pathId, { | |
| id: pathId, | |
| color: transfer.kind === "downlink" ? "#7cf7c9" : "#ffb86b", | |
| points, | |
| visible: true, | |
| }); | |
| } | |
| } | |
| for (const path of pathMap.values()) { | |
| if ( | |
| (String(path.id).startsWith("downlink-") || String(path.id).startsWith("capture-")) && | |
| !activeTransferPaths.has(path.id) | |
| ) { | |
| path.visible = false; | |
| } | |
| } | |
| pathStoreRef.current = [...pathMap.values()]; | |
| setPathData(pathStoreRef.current.filter((path) => path.visible !== false)); | |
| }, [snapshot]); | |
| const satellites = (snapshot?.satellites ?? []).map((satellite) => ({ | |
| ...satellite, | |
| lat: satellite.latitude, | |
| lng: satellite.longitude, | |
| altitude: 0.1 + Math.min(satellite.altitude_km / 8000, 0.1), | |
| type: "satellite", | |
| color: satelliteColor(satellite.last_action), | |
| size: 0.45, | |
| label: `Sat ${satellite.id} · ${satellite.last_action}`, | |
| })); | |
| const groundStations = (snapshot?.ground_stations ?? []).map((station) => ({ | |
| ...station, | |
| lat: station.latitude, | |
| lng: station.longitude, | |
| altitude: 0.01, | |
| type: "station", | |
| color: "#8cffef", | |
| size: 0.34, | |
| label: `${station.name} · ${station.capacity.toFixed(1)} Gbps`, | |
| })); | |
| const captureRegions = (snapshot?.capture_regions ?? []).map((region) => ({ | |
| ...region, | |
| lat: region.latitude, | |
| lng: region.longitude, | |
| altitude: 0.009, | |
| type: "region", | |
| color: regionColor(region.cloud_cover), | |
| size: 0.28, | |
| label: `${region.name} · Cloud ${Math.round(Number(region.cloud_cover) * 100)}%`, | |
| })); | |
| const markerNodes = [...groundStations, ...captureRegions, ...satellites]; | |
| return ( | |
| <div className="globe-shell"> | |
| <div className="globe-backdrop" /> | |
| <div className="globe-frame"> | |
| <Globe | |
| ref={globeRef} | |
| width={760} | |
| height={760} | |
| 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.82} | |
| pathStroke={(path) => { | |
| if (String(path.id).startsWith("orbit-")) { | |
| return 0.7; | |
| } | |
| if (String(path.id).startsWith("trail-")) { | |
| return 1.55; | |
| } | |
| if (String(path.id).startsWith("capture-")) { | |
| return 1.0; | |
| } | |
| return 1.15; | |
| }} | |
| pathDashLength={(path) => { | |
| if (String(path.id).startsWith("orbit-")) { | |
| return 0; | |
| } | |
| if (String(path.id).startsWith("trail-")) { | |
| return 0; | |
| } | |
| if (String(path.id).startsWith("capture-")) { | |
| return 0.1; | |
| } | |
| return 0.22; | |
| }} | |
| pathDashGap={(path) => { | |
| if (String(path.id).startsWith("downlink-")) { | |
| return 0.14; | |
| } | |
| if (String(path.id).startsWith("capture-")) { | |
| return 0.2; | |
| } | |
| return 0; | |
| }} | |
| pathDashAnimateTime={0} | |
| htmlElementsData={markerNodes} | |
| htmlLat="lat" | |
| htmlLng="lng" | |
| htmlAltitude="altitude" | |
| htmlElement={(marker) => { | |
| const element = document.createElement("div"); | |
| if (marker.type === "satellite") { | |
| element.className = "globe-marker satellite"; | |
| } else if (marker.type === "region") { | |
| element.className = "globe-marker region"; | |
| } else { | |
| element.className = "globe-marker station"; | |
| } | |
| element.style.setProperty("--marker-color", marker.color); | |
| element.style.setProperty("--marker-size", `${marker.size}rem`); | |
| element.setAttribute("title", marker.label); | |
| element.innerHTML = `<span></span>`; | |
| return element; | |
| }} | |
| /> | |
| </div> | |
| <div className="globe-overlay"> | |
| <div> | |
| <p className="eyebrow">Orbital Flow Console</p> | |
| <h1>Live tracking across satellites and ground stations.</h1> | |
| </div> | |
| <div className="overlay-metrics"> | |
| <div> | |
| <span>Active satellites</span> | |
| <strong>{snapshot?.satellites?.length ?? 0}</strong> | |
| </div> | |
| <div> | |
| <span>Ground links</span> | |
| <strong>{snapshot?.transfers?.filter((item) => item.kind === "downlink").length ?? 0}</strong> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function satelliteColor(action) { | |
| if (action === "capture") { | |
| return "#ffb86b"; | |
| } | |
| if (action === "downlink") { | |
| return "#7cf7c9"; | |
| } | |
| if (action === "maintain") { | |
| return "#9caaff"; | |
| } | |
| return "#95a2bb"; | |
| } | |
| function regionColor(cloudCover) { | |
| const cloud = Number(cloudCover); | |
| if (cloud <= 0.3) { | |
| return "#88ffd1"; | |
| } | |
| if (cloud <= 0.6) { | |
| return "#ffc778"; | |
| } | |
| return "#ff8f8f"; | |
| } | |
| function App() { | |
| const { snapshot, loading, playing, taskName, error, setPlaying, resetDemo, stepDemo } = useDemoData(); | |
| const weatherSummary = !snapshot?.weather_conditions | |
| ? [] | |
| : Object.entries(snapshot.weather_conditions).map(([region, cloudCover]) => ({ | |
| region, | |
| cloudCover, | |
| })); | |
| return ( | |
| <div className="app-shell"> | |
| <div className="ambient ambient-a" /> | |
| <div className="ambient ambient-b" /> | |
| <header className="topbar"> | |
| <div className="brand"> | |
| <div className="brand-mark"> | |
| <Orbit size={18} /> | |
| </div> | |
| <div> | |
| <p className="eyebrow">OpenEnv Showcase</p> | |
| <h2>Satellite mission tracking</h2> | |
| </div> | |
| </div> | |
| <div className="task-switcher"> | |
| {TASKS.map((task) => ( | |
| <button | |
| key={task} | |
| type="button" | |
| className={task === taskName ? "task-pill active" : "task-pill"} | |
| onClick={() => resetDemo(task)} | |
| > | |
| {task} | |
| </button> | |
| ))} | |
| </div> | |
| </header> | |
| <main className="layout"> | |
| <section className="panel panel-main"> | |
| {loading ? <div className="loading-state">Loading orbital telemetry…</div> : <GlobeView snapshot={snapshot} />} | |
| </section> | |
| <section className="panel panel-side"> | |
| <div className="card controls-card"> | |
| <div className="card-head"> | |
| <div> | |
| <p className="eyebrow">Mission Controls</p> | |
| <h3>Playback</h3> | |
| </div> | |
| <Signal size={18} /> | |
| </div> | |
| <div className="controls-row"> | |
| <button type="button" className="action-button primary" onClick={() => setPlaying(!playing)}> | |
| {snapshot?.done ? "Playback complete" : playing ? "Pause loop" : "Resume loop"} | |
| </button> | |
| <button | |
| type="button" | |
| className="action-button" | |
| onClick={() => stepDemo()} | |
| disabled={loading || snapshot?.done || (snapshot?.step_count ?? 0) >= (snapshot?.max_steps ?? 0)} | |
| > | |
| Step once | |
| </button> | |
| </div> | |
| <button type="button" className="ghost-button" onClick={() => resetDemo(taskName)}> | |
| <RefreshCcw size={14} /> | |
| Reinitialize constellation | |
| </button> | |
| {error ? <p className="error-text">{error}</p> : null} | |
| </div> | |
| <div className="stats-grid"> | |
| <MetricCard icon={<Satellite size={18} />} label="Step" value={`${Math.min(snapshot?.step_count ?? 0, snapshot?.max_steps ?? 0)}/${snapshot?.max_steps ?? 0}`} /> | |
| <MetricCard icon={<Orbit size={18} />} label="Total reward" value={(snapshot?.total_reward ?? 0).toFixed(1)} /> | |
| <MetricCard icon={<Activity size={18} />} label="Last reward" value={(snapshot?.last_reward ?? 0).toFixed(1)} /> | |
| <MetricCard icon={<Gauge size={18} />} label="Tasks done" value={String(snapshot?.metrics?.tasks_completed ?? 0)} /> | |
| <MetricCard icon={<Radio size={18} />} label="Downlink units" value={(snapshot?.metrics?.downlink_units ?? 0).toFixed(1)} /> | |
| </div> | |
| <div className="card"> | |
| <div className="card-head"> | |
| <div> | |
| <p className="eyebrow">Fleet Status</p> | |
| <h3>Satellites</h3> | |
| </div> | |
| </div> | |
| <div className="list"> | |
| {(snapshot?.satellites ?? []).map((satellite) => ( | |
| <motion.div key={satellite.id} className="list-item" layout> | |
| <div className="list-title"> | |
| <span>Sat {satellite.id}</span> | |
| <span className={`status-badge ${satellite.last_action}`}>{satellite.last_action}</span> | |
| </div> | |
| <div className="meter-row"> | |
| <label>Battery</label> | |
| <progress value={satellite.battery} max="100" /> | |
| <span>{satellite.battery.toFixed(0)}%</span> | |
| </div> | |
| <div className="meter-row"> | |
| <label>Storage</label> | |
| <progress value={satellite.storage} max="100" /> | |
| <span>{satellite.storage.toFixed(0)}%</span> | |
| </div> | |
| <div className="meta-row"> | |
| <span>{satellite.altitude_km.toFixed(0)} km altitude</span> | |
| <span>{satellite.longitude.toFixed(1)}° lon</span> | |
| </div> | |
| </motion.div> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="card"> | |
| <div className="card-head"> | |
| <div> | |
| <p className="eyebrow">Network</p> | |
| <h3>Ground stations & transfers</h3> | |
| </div> | |
| </div> | |
| <div className="list condensed"> | |
| {(snapshot?.ground_stations ?? []).map((station) => ( | |
| <div key={station.id} className="compact-row"> | |
| <div> | |
| <strong>{station.name}</strong> | |
| <span>{station.latitude.toFixed(1)}°, {station.longitude.toFixed(1)}°</span> | |
| </div> | |
| <span>{station.capacity.toFixed(1)} Gbps</span> | |
| </div> | |
| ))} | |
| {(snapshot?.transfers ?? []).map((transfer) => ( | |
| <div key={transfer.id} className="compact-row active-transfer"> | |
| <div> | |
| <strong>{transfer.kind === "downlink" ? "Downlink beam" : "Capture sweep"}</strong> | |
| <span> | |
| Sat {transfer.satellite_id} · {transfer.amount.toFixed(1)} units | |
| {transfer.kind === "downlink" && transfer.ground_station_id !== undefined | |
| ? ` · GS ${transfer.ground_station_id}` | |
| : ""} | |
| {transfer.kind === "capture" && transfer.region ? ` · ${transfer.region}` : ""} | |
| </span> | |
| </div> | |
| <span>{transfer.throughput.toFixed(1)} MB/s</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="card"> | |
| <div className="card-head"> | |
| <div> | |
| <p className="eyebrow">Conditions</p> | |
| <h3>Weather & queue</h3> | |
| </div> | |
| </div> | |
| <div className="weather-grid"> | |
| {weatherSummary.map((item) => ( | |
| <div key={item.region} className="weather-card"> | |
| <span>{item.region}</span> | |
| <strong>{Math.round(Number(item.cloudCover) * 100)}%</strong> | |
| <small>cloud cover</small> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="task-list"> | |
| {(snapshot?.pending_tasks ?? []).slice(0, 5).map((task) => ( | |
| <div key={task.id} className="task-item"> | |
| <span>{task.id}</span> | |
| <span> | |
| {task.type.replaceAll("_", " ")} | |
| {task.type === "image_capture" && task.region ? ` · ${task.region}` : ""} | |
| {task.type === "data_downlink" && task.station !== undefined ? ` · GS ${task.station}` : ""} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| function MetricCard({ icon, label, value }) { | |
| return ( | |
| <div className="metric-card"> | |
| <div className="metric-icon">{icon}</div> | |
| <span>{label}</span> | |
| <strong>{value}</strong> | |
| </div> | |
| ); | |
| } | |
| export default App; | |