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(null); const [sceneData, setSceneData] = useState(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(null); const [agentMoveCount, setAgentMoveCount] = useState(0); const [agentMoveFlash, setAgentMoveFlash] = useState(0); const [eventLog, setEventLog] = useState([]); const prevAgentPos = useRef({ x: -1, y: -1 }); const autoWaitTimer = useRef(null); const pollTimer = useRef(null); const logEndRef = useRef(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 (
{/* ── Topbar ── */}
🔥
Pyre / Crisis Navigation
Episode {epId}
{isError ? 'Error' : isOnline ? 'Live' : 'Idle'}
{/* ── Body ── */}
{/* ── Left: Canvas Zone ── */}
{/* 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 }) => (
{label}
))}
{/* Field Report */}
Field Report {observation?.last_action_feedback || 'Establishing link to field systems...'}
{/* ── Right: Side Panel ── */}
); } export default App;