| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>OpenEnv Mission Control</title> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| --bg-color: #0b0f19; |
| --panel-bg: rgba(15, 23, 42, 0.75); |
| --panel-border: rgba(56, 189, 248, 0.2); |
| --text-main: #f8fafc; |
| --text-muted: #94a3b8; |
| --accent-blue: #38bdf8; |
| --accent-green: #10b981; |
| --accent-red: #ef4444; |
| --accent-yellow: #f59e0b; |
| } |
| |
| * { box-sizing: border-box; } |
| |
| body { |
| margin: 0; |
| padding: 20px; |
| background-color: var(--bg-color); |
| background-image: |
| radial-gradient(circle at 15% 50%, rgba(56, 189, 248, 0.05), transparent 25%), |
| radial-gradient(circle at 85% 30%, rgba(16, 185, 129, 0.05), transparent 25%); |
| color: var(--text-main); |
| font-family: 'Inter', sans-serif; |
| min-height: 100vh; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| } |
| |
| header { |
| width: 100%; |
| max-width: 1200px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 24px; |
| padding-bottom: 16px; |
| border-bottom: 1px solid var(--panel-border); |
| } |
| |
| h1 { |
| margin: 0; |
| font-size: 24px; |
| font-weight: 800; |
| letter-spacing: 1px; |
| text-transform: uppercase; |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| h1 span { color: var(--accent-blue); } |
| |
| .status-badge { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| background: rgba(16, 185, 129, 0.1); |
| color: var(--accent-green); |
| padding: 8px 16px; |
| border-radius: 20px; |
| font-weight: 600; |
| font-size: 14px; |
| border: 1px solid rgba(16, 185, 129, 0.3); |
| transition: all 0.3s ease; |
| } |
| |
| .status-badge.searching { |
| background: rgba(245, 158, 11, 0.1); |
| color: var(--accent-yellow); |
| border-color: rgba(245, 158, 11, 0.3); |
| } |
| |
| .pulse { |
| width: 8px; |
| height: 8px; |
| background-color: currentColor; |
| border-radius: 50%; |
| animation: pulse 1.5s infinite; |
| } |
| |
| @keyframes pulse { |
| 0% { box-shadow: 0 0 0 0 currentColor; } |
| 70% { box-shadow: 0 0 0 6px rgba(0,0,0,0); } |
| 100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); } |
| } |
| |
| .main-container { |
| display: flex; |
| gap: 32px; |
| width: 100%; |
| max-width: 1200px; |
| flex-wrap: wrap; |
| justify-content: center; |
| } |
| |
| .sidebar { |
| flex: 1; |
| min-width: 320px; |
| max-width: 400px; |
| display: flex; |
| flex-direction: column; |
| gap: 24px; |
| } |
| |
| .panel { |
| background: var(--panel-bg); |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| border: 1px solid var(--panel-border); |
| border-radius: 16px; |
| padding: 24px; |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); |
| } |
| |
| .panel-title { |
| font-size: 14px; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| letter-spacing: 1.5px; |
| margin-top: 0; |
| margin-bottom: 16px; |
| font-weight: 600; |
| } |
| |
| .telemetry-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 16px; |
| } |
| |
| .telemetry-card { |
| background: rgba(0, 0, 0, 0.2); |
| padding: 12px; |
| border-radius: 8px; |
| border: 1px solid rgba(255, 255, 255, 0.05); |
| } |
| |
| .t-label { |
| font-size: 11px; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| margin-bottom: 4px; |
| } |
| |
| .t-value { |
| font-family: 'Roboto Mono', monospace; |
| font-size: 18px; |
| font-weight: 700; |
| color: var(--text-main); |
| } |
| |
| .t-value.green { color: var(--accent-green); } |
| .t-value.blue { color: var(--accent-blue); } |
| .t-value.red { color: var(--accent-red); } |
| |
| .control-group { |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| select, button { |
| font-family: 'Inter', sans-serif; |
| font-size: 14px; |
| padding: 12px 16px; |
| border-radius: 8px; |
| outline: none; |
| transition: all 0.2s; |
| } |
| |
| select { |
| background: rgba(0, 0, 0, 0.3); |
| color: var(--text-main); |
| border: 1px solid var(--panel-border); |
| appearance: none; |
| cursor: pointer; |
| } |
| |
| select:focus { border-color: var(--accent-blue); } |
| |
| button { |
| background: linear-gradient(135deg, var(--accent-blue) 0%, #2563eb 100%); |
| color: #fff; |
| border: none; |
| font-weight: 600; |
| cursor: pointer; |
| text-transform: uppercase; |
| letter-spacing: 1px; |
| box-shadow: 0 4px 12px rgba(56, 189, 248, 0.3); |
| } |
| |
| button:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 6px 16px rgba(56, 189, 248, 0.5); |
| } |
| |
| button:active { transform: translateY(0); } |
| |
| .keyboard-hint { |
| margin-top: 16px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| background: rgba(0, 0, 0, 0.2); |
| padding: 12px; |
| border-radius: 8px; |
| font-size: 12px; |
| color: var(--text-muted); |
| } |
| |
| .keys { |
| display: flex; |
| gap: 4px; |
| } |
| |
| .key { |
| background: rgba(255, 255, 255, 0.1); |
| border: 1px solid rgba(255, 255, 255, 0.2); |
| border-radius: 4px; |
| padding: 2px 6px; |
| font-family: 'Roboto Mono', monospace; |
| color: #fff; |
| } |
| |
| .radar-container { |
| flex: 2; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| position: relative; |
| } |
| |
| canvas { |
| background: radial-gradient(circle at center, #020617 0%, #0b0f19 100%); |
| border-radius: 50%; |
| box-shadow: 0 0 40px rgba(56, 189, 248, 0.15), |
| inset 0 0 60px rgba(56, 189, 248, 0.1); |
| border: 2px solid var(--panel-border); |
| max-width: 100%; |
| height: auto; |
| } |
| |
| .radar-overlay { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| pointer-events: none; |
| width: 600px; |
| height: 600px; |
| max-width: 100%; |
| border-radius: 50%; |
| background: conic-gradient( |
| from 0deg, |
| transparent 70%, |
| rgba(56, 189, 248, 0.1) 80%, |
| rgba(56, 189, 248, 0.4) 100% |
| ); |
| animation: scan 4s linear infinite; |
| } |
| |
| @keyframes scan { |
| from { transform: translate(-50%, -50%) rotate(0deg); } |
| to { transform: translate(-50%, -50%) rotate(360deg); } |
| } |
| |
| .ep-id-display { |
| font-family: 'Roboto Mono', monospace; |
| font-size: 11px; |
| color: var(--text-muted); |
| margin-top: 8px; |
| word-break: break-all; |
| } |
| |
| |
| #missionOverlay { |
| display: none; |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 600px; |
| height: 600px; |
| max-width: 100%; |
| background: rgba(11, 15, 25, 0.85); |
| backdrop-filter: blur(4px); |
| border-radius: 50%; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| z-index: 10; |
| } |
| |
| #missionOverlay.active { display: flex; } |
| #overlayTitle { font-size: 32px; font-weight: 800; margin: 0 0 10px 0; text-transform: uppercase; } |
| #overlayReason { font-size: 16px; color: var(--text-muted); text-align: center; max-width: 70%; white-space: pre-line;} |
| </style> |
| </head> |
| <body> |
|
|
| <header> |
| <h1><span>🪐</span> OpenEnv Mission Control</h1> |
| <div id="statusBadge" class="status-badge searching"> |
| <div class="pulse"></div> |
| <span id="statusText">Waiting for active episode...</span> |
| </div> |
| </header> |
|
|
| <div class="main-container"> |
| |
| <div class="sidebar"> |
| |
| |
| <div class="panel"> |
| <h2 class="panel-title">Interactive Judge Demo</h2> |
| <div class="control-group"> |
| <select id="taskSelect"> |
| <option value="easy">Easy — Flat Plains Transit</option> |
| <option value="medium">Medium — Crater Avoidance</option> |
| <option value="hard">Hard — Battery Sprint</option> |
| </select> |
| <button id="startBtn" onclick="startManualMission()">Launch Mission</button> |
| </div> |
| <div class="keyboard-hint"> |
| <span>Manual Override Controls:</span> |
| <div class="keys"> |
| <span class="key">↑</span> |
| <span class="key">↓</span> |
| <span class="key">←</span> |
| <span class="key">→</span> |
| </div> |
| </div> |
| <div class="ep-id-display" id="epIdDisplay">Episode: NONE</div> |
| </div> |
|
|
| |
| <div class="panel"> |
| <h2 class="panel-title">Live Telemetry</h2> |
| <div class="telemetry-grid"> |
| <div class="telemetry-card"> |
| <div class="t-label">Battery Level</div> |
| <div class="t-value green" id="t-battery">100.0%</div> |
| </div> |
| <div class="telemetry-card"> |
| <div class="t-label">Target Dist</div> |
| <div class="t-value blue" id="t-dist">-- m</div> |
| </div> |
| <div class="telemetry-card"> |
| <div class="t-label">Speed</div> |
| <div class="t-value" id="t-speed">0.0 m/s</div> |
| </div> |
| <div class="telemetry-card"> |
| <div class="t-label">Nearest Obs</div> |
| <div class="t-value red" id="t-obs">-- m</div> |
| </div> |
| <div class="telemetry-card"> |
| <div class="t-label">Steps</div> |
| <div class="t-value" id="t-steps">0</div> |
| </div> |
| <div class="telemetry-card"> |
| <div class="t-label">Score</div> |
| <div class="t-value" id="t-score">0.000</div> |
| </div> |
| </div> |
| </div> |
|
|
| </div> |
|
|
| |
| <div class="radar-container"> |
| <canvas id="radar" width="600" height="600"></canvas> |
| <div class="radar-overlay"></div> |
| <div id="missionOverlay"> |
| <h2 id="overlayTitle">Mission Over</h2> |
| <p id="overlayReason">Waypoint reached.</p> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| |
| const canvas = document.getElementById('radar'); |
| const ctx = canvas.getContext('2d'); |
| const statusBadge = document.getElementById('statusBadge'); |
| const statusText = document.getElementById('statusText'); |
| const epIdDisplay = document.getElementById('epIdDisplay'); |
| const overlay = document.getElementById('missionOverlay'); |
| const overlayTitle = document.getElementById('overlayTitle'); |
| const overlayReason = document.getElementById('overlayReason'); |
| |
| |
| let currentEpisodeId = null; |
| let isManualMode = false; |
| let latestObs = null; |
| let autoSyncInterval = null; |
| let manualLoopInterval = null; |
| let pollingInterval = null; |
| |
| const keys = { ArrowUp: false, ArrowDown: false, ArrowLeft: false, ArrowRight: false }; |
| |
| |
| async function init() { |
| |
| try { |
| const res = await fetch('/tasks'); |
| if(res.ok) { |
| const tasks = await res.json(); |
| const select = document.getElementById('taskSelect'); |
| select.innerHTML = ''; |
| tasks.forEach(t => { |
| const opt = document.createElement('option'); |
| opt.value = t.id; |
| opt.innerText = `${t.display_name} (Diff: ${t.difficulty})`; |
| select.appendChild(opt); |
| }); |
| } |
| } catch(e) { console.error("Could not fetch tasks"); } |
| |
| |
| startAutoSync(); |
| |
| |
| requestAnimationFrame(renderLoop); |
| } |
| |
| |
| |
| |
| function startAutoSync() { |
| if(autoSyncInterval) clearInterval(autoSyncInterval); |
| autoSyncInterval = setInterval(async () => { |
| if(isManualMode) return; |
| try { |
| const res = await fetch('/latest_episode'); |
| if(res.ok) { |
| const data = await res.json(); |
| if(data.episode_id && data.episode_id !== currentEpisodeId) { |
| currentEpisodeId = data.episode_id; |
| updateStatus(`Synced: ${currentEpisodeId.substring(0,8)}...`, true); |
| epIdDisplay.innerText = `Episode: ${currentEpisodeId}`; |
| startPollingState(); |
| } |
| } |
| } catch(e) {} |
| }, 2000); |
| } |
| |
| function startPollingState() { |
| if(pollingInterval) clearInterval(pollingInterval); |
| pollingInterval = setInterval(async () => { |
| if(isManualMode || !currentEpisodeId) return; |
| try { |
| const res = await fetch(`/state?episode_id=${currentEpisodeId}`); |
| if(res.ok) { |
| latestObs = await res.json(); |
| updateTelemetry(latestObs, 0); |
| } |
| } catch(e) {} |
| }, 100); |
| } |
| |
| |
| |
| |
| async function startManualMission() { |
| |
| isManualMode = true; |
| if(pollingInterval) clearInterval(pollingInterval); |
| if(manualLoopInterval) clearInterval(manualLoopInterval); |
| overlay.classList.remove('active'); |
| |
| const taskId = document.getElementById('taskSelect').value; |
| updateStatus(`Initializing Manual Override...`, false); |
| |
| try { |
| const res = await fetch('/reset', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ task_id: taskId }) |
| }); |
| if(res.ok) { |
| const data = await res.json(); |
| currentEpisodeId = data.episode_id; |
| latestObs = data.obs; |
| |
| updateStatus(`Manual Control Active`, true); |
| epIdDisplay.innerText = `Episode: ${currentEpisodeId}`; |
| updateTelemetry(latestObs, 0); |
| |
| |
| manualLoopInterval = setInterval(manualStep, 150); |
| } |
| } catch(e) { |
| updateStatus("Connection Failed", false); |
| } |
| } |
| |
| async function manualStep() { |
| if(!isManualMode || !currentEpisodeId) return; |
| |
| |
| let thrust = 0.0; |
| let brake = 0; |
| let steering = 0.0; |
| |
| if(keys.ArrowUp) thrust = 1.0; |
| if(keys.ArrowDown) brake = 1; |
| if(keys.ArrowLeft) steering = -1.0; |
| if(keys.ArrowRight) steering = 1.0; |
| |
| const action = { |
| thrust: thrust, |
| steering: steering, |
| brake: brake, |
| vertical_thruster: 0.0 |
| }; |
| |
| try { |
| const res = await fetch(`/step?episode_id=${currentEpisodeId}`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(action) |
| }); |
| |
| if(res.ok) { |
| const data = await res.json(); |
| latestObs = data.obs; |
| updateTelemetry(latestObs, data.info.total_reward || 0); |
| |
| if(data.done || data.truncated) { |
| endManualMission(data.info); |
| } |
| } |
| } catch(e) { |
| console.error("Step failed", e); |
| } |
| } |
| |
| function endManualMission(info) { |
| clearInterval(manualLoopInterval); |
| isManualMode = false; |
| updateStatus("Mission Over", false); |
| |
| overlay.classList.add('active'); |
| let color = '#f8fafc'; |
| |
| if(info.termination_reason === 'waypoint_reached') { |
| overlayTitle.innerText = "MISSION SUCCESS"; |
| overlayTitle.style.color = 'var(--accent-green)'; |
| color = 'var(--accent-green)'; |
| } else if (info.termination_reason === 'battery_dead') { |
| overlayTitle.innerText = "BATTERY DEPLETED"; |
| overlayTitle.style.color = 'var(--accent-red)'; |
| color = 'var(--accent-red)'; |
| } else { |
| overlayTitle.innerText = "MISSION TERMINATED"; |
| overlayTitle.style.color = 'var(--accent-yellow)'; |
| color = 'var(--accent-yellow)'; |
| } |
| |
| overlayReason.innerText = `Reason: ${info.termination_reason}\nCollisions: ${info.collision_count}\nScore: ${info.total_reward.toFixed(3)}`; |
| |
| |
| startAutoSync(); |
| } |
| |
| |
| |
| |
| function updateStatus(text, isGood) { |
| statusText.innerText = text; |
| if(isGood) { |
| statusBadge.classList.remove('searching'); |
| } else { |
| statusBadge.classList.add('searching'); |
| } |
| } |
| |
| function updateTelemetry(obs, score) { |
| if(!obs) return; |
| const batEl = document.getElementById('t-battery'); |
| const batPct = (obs.battery_level * 100).toFixed(1); |
| batEl.innerText = batPct + '%'; |
| if(obs.battery_level < 0.2) batEl.className = 't-value red'; |
| else if(obs.battery_level < 0.5) batEl.className = 't-value yellow'; |
| else batEl.className = 't-value green'; |
| |
| document.getElementById('t-dist').innerText = obs.target_distance.toFixed(1) + ' m'; |
| |
| const speed = Math.hypot(obs.rover_velocity.x, obs.rover_velocity.y).toFixed(2); |
| document.getElementById('t-speed').innerText = speed + ' m/s'; |
| |
| const obsEl = document.getElementById('t-obs'); |
| obsEl.innerText = (obs.nearest_obstacle_distance === 50.0 ? '--' : obs.nearest_obstacle_distance.toFixed(1)) + ' m'; |
| if(obs.nearest_obstacle_distance < 5.0) obsEl.className = 't-value red'; |
| else obsEl.className = 't-value'; |
| |
| document.getElementById('t-steps').innerText = `${Math.floor(obs.steps_taken)}`; |
| document.getElementById('t-score').innerText = score.toFixed(3); |
| } |
| |
| function renderLoop() { |
| drawRadar(); |
| requestAnimationFrame(renderLoop); |
| } |
| |
| function drawRadar() { |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| const cx = canvas.width / 2; |
| const cy = canvas.height / 2; |
| const scale = 0.8; |
| |
| |
| ctx.strokeStyle = 'rgba(56, 189, 248, 0.15)'; |
| ctx.lineWidth = 1; |
| for(let i=1; i<=3; i++) { |
| ctx.beginPath(); |
| ctx.arc(cx, cy, i * 100 * scale, 0, Math.PI*2); |
| ctx.stroke(); |
| } |
| |
| |
| ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, canvas.height); ctx.stroke(); |
| ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(canvas.width, cy); ctx.stroke(); |
| |
| if(!latestObs) return; |
| |
| |
| const tx = cx + latestObs.target_position.x * scale; |
| const ty = cy - latestObs.target_position.y * scale; |
| ctx.fillStyle = '#10b981'; |
| ctx.shadowBlur = 15; ctx.shadowColor = '#10b981'; |
| ctx.beginPath(); ctx.arc(tx, ty, 6, 0, Math.PI*2); ctx.fill(); |
| ctx.shadowBlur = 0; |
| |
| |
| const rx = cx + latestObs.rover_position.x * scale; |
| const ry = cy - latestObs.rover_position.y * scale; |
| |
| |
| ctx.fillStyle = '#ef4444'; |
| ctx.shadowBlur = 10; ctx.shadowColor = '#ef4444'; |
| latestObs.obstacle_map.forEach(obsData => { |
| if(obsData.dist_norm < 1.0) { |
| const ox = rx + (obsData.dx_norm * 50) * scale; |
| const oy = ry - (obsData.dy_norm * 50) * scale; |
| ctx.beginPath(); ctx.arc(ox, oy, 4, 0, Math.PI*2); ctx.fill(); |
| } |
| }); |
| ctx.shadowBlur = 0; |
| |
| |
| ctx.save(); |
| ctx.translate(rx, ry); |
| ctx.rotate(-latestObs.rover_heading); |
| ctx.fillStyle = '#38bdf8'; |
| ctx.shadowBlur = 15; ctx.shadowColor = '#38bdf8'; |
| ctx.beginPath(); |
| ctx.moveTo(14, 0); |
| ctx.lineTo(-10, 10); |
| ctx.lineTo(-6, 0); |
| ctx.lineTo(-10, -10); |
| ctx.fill(); |
| ctx.restore(); |
| |
| |
| ctx.save(); |
| ctx.translate(rx, ry); |
| ctx.rotate(-latestObs.rover_heading); |
| ctx.fillStyle = 'rgba(56, 189, 248, 0.05)'; |
| ctx.beginPath(); |
| ctx.moveTo(0,0); |
| ctx.arc(0, 0, 50 * scale, -Math.PI/4, Math.PI/4); |
| ctx.fill(); |
| ctx.restore(); |
| } |
| |
| |
| window.addEventListener('keydown', (e) => { |
| if(keys.hasOwnProperty(e.key)) { |
| keys[e.key] = true; |
| if(isManualMode) { |
| if(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { |
| e.preventDefault(); |
| } |
| } |
| } |
| }); |
| window.addEventListener('keyup', (e) => { |
| if(keys.hasOwnProperty(e.key)) { |
| keys[e.key] = false; |
| } |
| }); |
| |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|