Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useCallback, useRef } from 'react'; | |
| import './App.css'; | |
| import type { Observation, Door, ApiReport, SceneResponse } from './types'; | |
| import Map2D from './components/Map2D'; | |
| import HUD from './components/HUD'; | |
| import ControlPanel from './components/ControlPanel'; | |
| import APIReport from './components/APIReport'; | |
| const DOOR_CLOSED = 3; | |
| const OBSTACLE = 5; | |
| interface EventEntry { | |
| step: number; | |
| text: string; | |
| reward: number; | |
| isAlarm?: boolean; | |
| } | |
| function App() { | |
| const [observation, setObservation] = useState<Observation | null>(null); | |
| const [sceneData, setSceneData] = useState<SceneResponse | null>(null); | |
| const [isAutoWait, setIsAutoWait] = useState(false); | |
| const [isPolling, setIsPolling] = useState(true); | |
| const [status, setStatus] = useState('Idle — waiting for connection'); | |
| const [isError, setIsError] = useState(false); | |
| const [apiReport, setApiReport] = useState<ApiReport | null>(null); | |
| const [agentMoveCount, setAgentMoveCount] = useState(0); | |
| const [agentMoveFlash, setAgentMoveFlash] = useState(0); | |
| const [eventLog, setEventLog] = useState<EventEntry[]>([]); | |
| const prevAgentPos = useRef({ x: -1, y: -1 }); | |
| const autoWaitTimer = useRef<number | null>(null); | |
| const pollTimer = useRef<number | null>(null); | |
| const logEndRef = useRef<HTMLDivElement | null>(null); | |
| /* scroll event log to bottom */ | |
| useEffect(() => { | |
| logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, [eventLog]); | |
| const setStatusMsg = (msg: string, error = false) => { | |
| setStatus(msg); | |
| setIsError(error); | |
| }; | |
| const pushLog = (text: string, step: number, reward: number, isAlarm = false) => { | |
| setEventLog(prev => [...prev.slice(-49), { step, text, reward, isAlarm }]); | |
| }; | |
| const applyObservation = useCallback((obs: Observation) => { | |
| const newX = obs.map_state.agent_x; | |
| const newY = obs.map_state.agent_y; | |
| if (prevAgentPos.current.x !== -1 && | |
| (newX !== prevAgentPos.current.x || newY !== prevAgentPos.current.y)) { | |
| setAgentMoveFlash(18); | |
| setAgentMoveCount(c => c + 1); | |
| } | |
| prevAgentPos.current = { x: newX, y: newY }; | |
| setObservation(obs); | |
| }, []); | |
| const updateReport = (kind: string, request: unknown, response: any) => { | |
| const mapState = response?.observation?.map_state || response?.map_state || response?.graph; | |
| const template = mapState?.template_name || response?.labels?.episode?.template || 'unknown'; | |
| const step = mapState?.step_count ?? response?.observation?.elapsed_steps ?? response?.labels?.episode?.step ?? '-'; | |
| const reward = Number(response?.reward ?? 0).toFixed(3); | |
| const done = Boolean(response?.done); | |
| setApiReport({ | |
| call_type: kind, | |
| request, | |
| response, | |
| meta: `${kind.toUpperCase()} | template=${template} | step=${step} | reward=${reward} | done=${done}`, | |
| }); | |
| }; | |
| const apiCall = async (path: string, payload: unknown) => { | |
| const res = await fetch(path, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload || {}), | |
| }); | |
| if (!res.ok) { | |
| const text = await res.text(); | |
| throw new Error(`${res.status} ${res.statusText}: ${text}`); | |
| } | |
| return res.json(); | |
| }; | |
| const resetLive = async (difficulty = 'medium') => { | |
| try { | |
| setStatusMsg('Initiating Reset...'); | |
| const payload = { difficulty }; | |
| const data = await apiCall('/reset', payload); | |
| const obs: Observation = data.observation; | |
| if (data.observation?.map_state) { | |
| obs.metadata = { | |
| fire_sources: data.observation.fire_sources_count ?? 0, | |
| fire_spread_rate:data.observation.fire_spread_rate ?? 0, | |
| humidity: data.observation.humidity ?? 0, | |
| difficulty, | |
| }; | |
| } | |
| applyObservation(obs); | |
| updateReport('reset', payload, data); | |
| setStatusMsg(`Ready. Reward: ${Number(data.reward || 0).toFixed(2)}`); | |
| pushLog('Episode reset. Assess surroundings.', obs.map_state.step_count, data.reward ?? 0); | |
| } catch (err: any) { | |
| setStatusMsg(`Reset Failed: ${err.message}`, true); | |
| } | |
| }; | |
| const resetUntilDoors = async () => { | |
| try { | |
| setStatusMsg('Searching for layout with doors...'); | |
| for (let i = 1; i <= 8; i++) { | |
| const payload = { difficulty: 'medium' }; | |
| const data = await apiCall('/reset', payload); | |
| const doorCount = Object.keys(data?.observation?.map_state?.door_registry || {}).length; | |
| if (doorCount > 0) { | |
| const obs: Observation = data.observation; | |
| obs.metadata = { | |
| fire_sources: data.observation.fire_sources_count ?? 0, | |
| fire_spread_rate:data.observation.fire_spread_rate ?? 0, | |
| humidity: data.observation.humidity ?? 0, | |
| difficulty: 'medium', | |
| }; | |
| applyObservation(obs); | |
| updateReport('reset', payload, data); | |
| setStatusMsg(`System Ready — ${doorCount} door(s) detected.`); | |
| pushLog(`Layout found (${doorCount} doors). Doors detected.`, obs.map_state.step_count, 0); | |
| return; | |
| } | |
| } | |
| setStatusMsg('Optimal layout not found after 8 attempts.', true); | |
| } catch (err: any) { | |
| setStatusMsg(`Search Failed: ${err.message}`, true); | |
| } | |
| }; | |
| const runAction = async (actionObj: unknown, label: string) => { | |
| try { | |
| setStatusMsg(`Action: ${label}`); | |
| const payload = { action: actionObj }; | |
| const data = await apiCall('/step', payload); | |
| const obs: Observation = data.observation; | |
| obs.metadata = observation?.metadata; | |
| applyObservation(obs); | |
| updateReport('step', payload, data); | |
| const rwd = Number(data.reward || 0); | |
| setStatusMsg(`Executed. Reward: ${rwd.toFixed(2)}`); | |
| pushLog( | |
| obs.last_action_feedback || label, | |
| obs.map_state.step_count, | |
| rwd, | |
| rwd < -0.5, | |
| ); | |
| if (data.done) setIsAutoWait(false); | |
| } catch (err: any) { | |
| setStatusMsg(`Error: ${err.message}`, true); | |
| } | |
| }; | |
| const fetchAndApplyScene = useCallback(async () => { | |
| try { | |
| const res = await fetch('/scene'); | |
| if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); | |
| const scene: SceneResponse = await res.json(); | |
| setSceneData(scene); | |
| updateReport('scene', {}, scene); | |
| const { labels, graph } = scene; | |
| const cell_grid: number[] = []; | |
| const fire_grid: number[] = []; | |
| const smoke_grid: number[] = []; | |
| for (let y = 0; y < graph.height; y++) { | |
| for (let x = 0; x < graph.width; x++) { | |
| const [type, fire, smoke] = graph.grid[y][x]; | |
| cell_grid.push(type); | |
| fire_grid.push(fire); | |
| smoke_grid.push(smoke); | |
| } | |
| } | |
| const visible_cells: [number, number][] = []; | |
| for (let y = 0; y < graph.height; y++) { | |
| for (let x = 0; x < graph.width; x++) { | |
| if (graph.grid[y][x][4] === 1.0) visible_cells.push([x, y]); | |
| } | |
| } | |
| const pseudoObs: Observation = { | |
| map_state: { | |
| cell_grid, fire_grid, smoke_grid, | |
| agent_x: labels.agent.x, | |
| agent_y: labels.agent.y, | |
| visible_cells, | |
| door_registry: labels.map.door_registry, | |
| exit_positions: labels.map.exit_positions, | |
| step_count: labels.episode.step, | |
| max_steps: labels.episode.max_steps, | |
| grid_w: graph.width, | |
| grid_h: graph.height, | |
| template_name: labels.episode.template, | |
| }, | |
| agent_health: labels.agent.health, | |
| location_label: labels.agent.location, | |
| smoke_level: labels.agent.smoke_level, | |
| wind_dir: labels.episode.wind_dir, | |
| fire_visible: labels.agent.fire_visible, | |
| fire_direction: labels.agent.fire_direction, | |
| last_action_feedback: labels.agent.last_action_feedback, | |
| narrative: '', | |
| metadata: { | |
| fire_sources: labels.episode.fire_sources, | |
| fire_spread_rate:labels.episode.fire_spread_rate, | |
| humidity: labels.episode.humidity, | |
| difficulty: labels.episode.difficulty, | |
| }, | |
| }; | |
| applyObservation(pseudoObs); | |
| } catch (err: any) { | |
| setStatusMsg(`Sync Error: ${err.message}`, true); | |
| } | |
| }, [applyObservation, observation?.metadata]); | |
| useEffect(() => { | |
| if (isAutoWait) { | |
| autoWaitTimer.current = window.setInterval(() => runAction({ action: 'wait' }, 'AUTO WAIT'), 900); | |
| } else { | |
| if (autoWaitTimer.current) clearInterval(autoWaitTimer.current); | |
| } | |
| return () => { if (autoWaitTimer.current) clearInterval(autoWaitTimer.current); }; | |
| }, [isAutoWait]); | |
| useEffect(() => { | |
| if (isPolling) { | |
| fetchAndApplyScene(); | |
| pollTimer.current = window.setInterval(fetchAndApplyScene, 500); | |
| } else { | |
| if (pollTimer.current) clearInterval(pollTimer.current); | |
| } | |
| return () => { if (pollTimer.current) clearInterval(pollTimer.current); }; | |
| }, [isPolling, fetchAndApplyScene]); | |
| useEffect(() => { | |
| if (agentMoveFlash > 0) { | |
| const timer = setTimeout(() => setAgentMoveFlash(f => f - 1), 50); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [agentMoveFlash]); | |
| const setup = async () => { | |
| setIsPolling(false); | |
| setAgentMoveCount(0); | |
| setAgentMoveFlash(0); | |
| setEventLog([]); | |
| prevAgentPos.current = { x: -1, y: -1 }; | |
| await resetLive(); | |
| setIsPolling(true); | |
| }; | |
| /* Derived state */ | |
| const doors: Door[] = Object.entries(observation?.map_state.door_registry || {}) | |
| .map(([id, [x, y]]) => { | |
| const ct = observation?.map_state.cell_grid[y * (observation?.map_state.grid_w ?? 16) + x]; | |
| let state: 'open' | 'closed' | 'failed' = 'open'; | |
| if (ct === DOOR_CLOSED) state = 'closed'; | |
| if (ct === OBSTACLE) state = 'failed'; | |
| return { id, x, y, state }; | |
| }) | |
| .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })); | |
| const fireCells = observation?.map_state.fire_grid.filter(v => v > 0.05).length ?? 0; | |
| const exploredPct = observation | |
| ? Math.round((new Set(observation.map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`)).size | |
| / observation.map_state.cell_grid.length) * 100) | |
| : 0; | |
| const hp = Math.round(observation?.agent_health ?? 0); | |
| const hpColor = hp >= 60 ? 'var(--green)' : hp >= 30 ? 'var(--amber)' : 'var(--red)'; | |
| const isOnline = isPolling && !isError; | |
| const epId = sceneData?.labels.episode.id?.slice(0, 8) ?? '—'; | |
| return ( | |
| <div className="shell"> | |
| {/* ── Topbar ── */} | |
| <header className="topbar"> | |
| <div className="brand"> | |
| <div className="brand-icon">🔥</div> | |
| <span className="brand-name">Pyre</span> | |
| <span className="brand-sep">/</span> | |
| <span className="brand-sub">Crisis Navigation</span> | |
| </div> | |
| <div className="topbar-right"> | |
| <div className="topbar-ep"> | |
| Episode <span className="ep-id">{epId}</span> | |
| </div> | |
| <span className={`live-chip ${isError ? 'error' : isOnline ? 'online' : 'offline'}`}> | |
| {isError ? 'Error' : isOnline ? 'Live' : 'Idle'} | |
| </span> | |
| </div> | |
| </header> | |
| {/* ── Body ── */} | |
| <div className="content"> | |
| {/* ── Left: Canvas Zone ── */} | |
| <div className="canvas-zone"> | |
| <div className="canvas-frame"> | |
| <Map2D observation={observation} agentMoveFlash={agentMoveFlash} /> | |
| <HUD observation={observation} agentMoveCount={agentMoveCount} /> | |
| </div> | |
| {/* Legend */} | |
| <div className="legend"> | |
| {[ | |
| { color:'#5e5850', label:'Wall' }, | |
| { color:'#3a3530', label:'Obstacle' }, | |
| { color:'#e6f4ec', label:'Exit' }, | |
| { color:'#7c5c3c', label:'Door' }, | |
| { color:'#f97316', label:'Fire' }, | |
| { color:'rgba(72,82,96,0.7)', label:'Smoke' }, | |
| { color:'#3b82f6', label:'Agent' }, | |
| { color:'rgba(2,132,199,0.6)', label:'Trail'}, | |
| ].map(({ color, label }) => ( | |
| <div key={label} className="legend-item"> | |
| <div className="leg-swatch" style={{ background: color, border:'1px solid rgba(0,0,0,0.1)' }} /> | |
| {label} | |
| </div> | |
| ))} | |
| </div> | |
| {/* Field Report */} | |
| <div className="dialog"> | |
| <span className="dialog-who">Field Report</span> | |
| {observation?.last_action_feedback || 'Establishing link to field systems...'} | |
| </div> | |
| </div> | |
| {/* ── Right: Side Panel ── */} | |
| <aside className="side"> | |
| {/* Controls */} | |
| <ControlPanel | |
| onAction={runAction} | |
| onReset={resetLive} | |
| onResetDoors={resetUntilDoors} | |
| onSetup={setup} | |
| doors={doors} | |
| isAutoWait={isAutoWait} | |
| toggleAutoWait={() => setIsAutoWait(!isAutoWait)} | |
| isPolling={isPolling} | |
| togglePolling={() => setIsPolling(!isPolling)} | |
| status={status} | |
| isError={isError} | |
| /> | |
| {/* Agent Biometrics */} | |
| <div className="side-sec"> | |
| <div className="sec-hd">Agent Biometrics</div> | |
| <div className="sg"> | |
| <div className="sc"> | |
| <div className="sc-l">Health</div> | |
| <div className="sc-v" style={{ color: hpColor }}>{hp}%</div> | |
| </div> | |
| <div className="sc"> | |
| <div className="sc-l">Status</div> | |
| <div className="sc-v" style={{ color: hpColor }}> | |
| {sceneData?.labels.agent.health_status ?? 'NOMINAL'} | |
| </div> | |
| </div> | |
| <div className="sc"> | |
| <div className="sc-l">Position</div> | |
| <div className="sc-v blue"> | |
| ({observation?.map_state.agent_x ?? '—'},{observation?.map_state.agent_y ?? '—'}) | |
| </div> | |
| </div> | |
| <div className="sc"> | |
| <div className="sc-l">Sector</div> | |
| <div className="sc-v">{observation?.location_label ?? 'Unknown'}</div> | |
| </div> | |
| </div> | |
| <div className="bar-w"> | |
| <div className="bar-lbl"> | |
| <span>System Integrity</span> | |
| <span>{hp}%</span> | |
| </div> | |
| <div className="bar-bg"> | |
| <div className="bar-fill" style={{ width: `${hp}%`, background: hpColor }} /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Environment */} | |
| <div className="side-sec"> | |
| <div className="sec-hd">Environment</div> | |
| <div className="sg"> | |
| <div className="sc"> | |
| <div className="sc-l">Hazard Cells</div> | |
| <div className="sc-v fire">{fireCells}</div> | |
| </div> | |
| <div className="sc"> | |
| <div className="sc-l">Explored</div> | |
| <div className="sc-v blue">{exploredPct}%</div> | |
| </div> | |
| <div className="sc"> | |
| <div className="sc-l">Wind</div> | |
| <div className="sc-v">{observation?.wind_dir ?? 'CALM'}</div> | |
| </div> | |
| <div className="sc"> | |
| <div className="sc-l">Humidity</div> | |
| <div className="sc-v amber"> | |
| {Math.round((observation?.metadata?.humidity ?? 0) * 100)}% | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Event Log */} | |
| <div className="side-sec" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}> | |
| <div className="sec-hd"> | |
| Event Log | |
| <span style={{ fontFamily: 'var(--mono)', fontSize: '9px', color: 'var(--t3)' }}> | |
| {eventLog.length} events | |
| </span> | |
| </div> | |
| <div className="elog"> | |
| {eventLog.length === 0 && ( | |
| <div style={{ color: 'var(--t3)', fontFamily: 'var(--mono)', fontSize: '10px', padding: '4px' }}> | |
| No events yet… | |
| </div> | |
| )} | |
| {eventLog.map((e, i) => ( | |
| <div key={i} className={`erow ${e.isAlarm ? 'alarm' : ''}`}> | |
| <span className="estep">T{e.step}</span> | |
| <span className="etext">{e.text}</span> | |
| <span className={`erwd ${e.reward >= 0 ? 'p' : 'n'}`}> | |
| {e.reward >= 0 ? '+' : ''}{e.reward.toFixed(2)} | |
| </span> | |
| </div> | |
| ))} | |
| <div ref={logEndRef} /> | |
| </div> | |
| </div> | |
| {/* API Report */} | |
| <div className="side-sec"> | |
| <div className="sec-hd">Network Activity</div> | |
| <APIReport report={apiReport} onCopyReset={() => {}} onCopyStep={() => {}} onCopyScene={() => {}} /> | |
| </div> | |
| </aside> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default App; | |