| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "use strict"; |
|
|
| |
| function pickStat(obj, ...keys) { |
| if (!obj) return undefined; |
| for (const k of keys) { |
| if (Object.prototype.hasOwnProperty.call(obj, k) && obj[k] != null) { |
| return obj[k]; |
| } |
| } |
| return undefined; |
| } |
|
|
| |
| |
| |
| |
| function normalizeEpisodeStats(obs) { |
| const st = obs?.stats ?? {}; |
| const cellsBurned = pickStat(st, "cells_burned", "cellsBurned") ?? 0; |
| const popLost = pickStat(st, "population_lost", "populationLost") ?? 0; |
| const totalPop = pickStat(st, "total_population", "totalPopulation") ?? 0; |
|
|
| let areaSaved = pickStat(st, "area_saved_pct", "areaSavedPct"); |
| let civSafe = pickStat(st, "civilians_saved_pct", "civiliansSavedPct"); |
|
|
| if (areaSaved == null && obs?.grid?.length) { |
| let burnable = 0; |
| let burnedVis = 0; |
| for (const row of obs.grid) { |
| for (const cell of row) { |
| const f = cell.fuel_type; |
| if (!f || f === "water" || f === "road") continue; |
| if (cell.fire_state === "unknown") continue; |
| burnable++; |
| if (cell.fire_state === "burned_out") burnedVis++; |
| } |
| } |
| if (burnable > 0) { |
| areaSaved = Math.round(1000 * (burnable - burnedVis) / burnable) / 10; |
| } |
| } |
|
|
| if (civSafe == null && totalPop > 0) { |
| civSafe = Math.round(1000 * (totalPop - popLost) / totalPop) / 10; |
| } else if (civSafe == null && popLost === 0) { |
| civSafe = 100.0; |
| } |
|
|
| const containment = pickStat(st, "containment_pct", "containmentPct"); |
| if (areaSaved == null && containment != null) { |
| areaSaved = containment; |
| } |
|
|
| return { |
| areaSaved, |
| civSafe, |
| cellsBurned, |
| popLost, |
| totalPop, |
| currentStep: pickStat(st, "current_step", "currentStep"), |
| raw: st, |
| }; |
| } |
|
|
| |
| const sim = { |
| obs: null, |
| cumulativeReward: 0, |
| lastStepReward: 0, |
| done: false, |
| groundTruthData: null, |
| agentMode: "heuristic", |
| tier: "easy", |
| seed: 42, |
| playing: false, |
| speed: 600, |
| playTimer: null, |
| cellSize: 0, |
| }; |
|
|
| |
| const canvas = document.getElementById("grid-canvas"); |
| const ctx = canvas.getContext("2d"); |
| const canvasWrap = document.getElementById("canvas-wrap"); |
|
|
| |
| function cellColor(cell) { |
| const fs = cell.fire_state; |
| const intensity = cell.fire_intensity ?? 0; |
|
|
| if (fs === "unknown") return "rgba(0,0,0,0.82)"; |
|
|
| if (fs === "burning") { |
| const sat = 0.4 + 0.6 * intensity; |
| const g = Math.round((1.0 - sat * 0.8) * 255); |
| return `rgb(255,${g},0)`; |
| } |
| if (fs === "ember") return "#e55c00"; |
| if (fs === "burned_out") return "#3f3530"; |
| if (fs === "firebreak") return "#8c5a28"; |
| if (fs === "suppressed") return "#88cc88"; |
|
|
| |
| const fuel = cell.fuel_type ?? "grass"; |
| switch (fuel) { |
| case "water": return "#4d80e6"; |
| case "road": return "#b0b0b0"; |
| case "timber": return "#1a7a1a"; |
| case "shrub": return "#7fba33"; |
| case "urban": return "#ccbfb2"; |
| default: return "#a8d95e"; |
| } |
| } |
|
|
| |
| function renderCanvas(obs, groundTruth = null) { |
| if (!obs || !obs.grid || obs.grid.length === 0) return; |
|
|
| const rows = obs.grid.length; |
| const cols = obs.grid[0].length; |
|
|
| |
| const panelW = canvasWrap.parentElement.clientWidth - 24; |
| const panelH = canvasWrap.parentElement.clientHeight - 24; |
| const cs = Math.max(4, Math.floor(Math.min(panelW / cols, panelH / rows))); |
| sim.cellSize = cs; |
|
|
| if (canvas.width !== cs * cols || canvas.height !== cs * rows) { |
| canvas.width = cs * cols; |
| canvas.height = cs * rows; |
| } |
|
|
| |
| const gtGrid = groundTruth?.grid ?? null; |
|
|
| for (let r = 0; r < rows; r++) { |
| for (let c = 0; c < cols; c++) { |
| let cell = obs.grid[r][c]; |
|
|
| |
| if (gtGrid && cell.fire_state === "unknown") { |
| cell = { ...cell, ...gtGrid[r][c], _gt_overlay: true }; |
| } |
|
|
| const color = cellColor(cell); |
| ctx.fillStyle = color; |
| ctx.fillRect(c * cs, r * cs, cs, cs); |
|
|
| |
| if (cell._gt_overlay) { |
| ctx.fillStyle = "rgba(255,200,0,0.08)"; |
| ctx.fillRect(c * cs, r * cs, cs, cs); |
| } |
|
|
| |
| if (cell.is_populated) { |
| ctx.strokeStyle = "#58a6ff"; |
| ctx.lineWidth = Math.max(1, cs * 0.1); |
| ctx.strokeRect(c * cs + 0.5, r * cs + 0.5, cs - 1, cs - 1); |
| } |
|
|
| |
| if (cell.crew_present) { |
| ctx.fillStyle = "#00ff88"; |
| const r2 = Math.max(2, cs * 0.22); |
| ctx.beginPath(); |
| ctx.arc(c * cs + cs / 2, r * cs + cs / 2, r2, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| } |
| } |
|
|
| |
| const crews = obs.resources?.crews ?? []; |
| for (const crew of crews) { |
| if (!crew.is_deployed || !crew.is_active) continue; |
| const cx = crew.col * cs + cs / 2; |
| const cy = crew.row * cs + cs / 2; |
| const r2 = Math.max(3, cs * 0.28); |
|
|
| ctx.beginPath(); |
| ctx.arc(cx, cy, r2, 0, Math.PI * 2); |
| ctx.fillStyle = crew.is_active ? "lime" : "#f85149"; |
| ctx.fill(); |
| ctx.strokeStyle = "#000"; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
|
|
| if (cs >= 10) { |
| const label = crew.crew_id.replace("crew_", "c"); |
| ctx.fillStyle = "#fff"; |
| ctx.font = `bold ${Math.max(7, cs * 0.4)}px 'Courier New', monospace`; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| ctx.fillText(label, cx, cy); |
| } |
| } |
|
|
| |
| const burning = obs.stats?.cells_burning ?? 0; |
| if (burning > 0) { |
| canvasWrap.classList.add("fire-active"); |
| } else { |
| canvasWrap.classList.remove("fire-active"); |
| } |
|
|
| |
| const cur = obs.stats?.current_step ?? 0; |
| const max = obs.stats?.max_steps ?? 1; |
| document.getElementById("step-progress-fill").style.width = |
| `${Math.min(100, (cur / max) * 100)}%`; |
| } |
|
|
| |
| function updateStats(obs, cumulativeReward, lastStepReward) { |
| if (!obs?.stats) return; |
| const stats = obs.stats; |
|
|
| const cur = pickStat(stats, "current_step", "currentStep") ?? 0; |
| const max = pickStat(stats, "max_steps", "maxSteps") ?? 1; |
|
|
| setText("stat-step", `${cur} / ${max}`); |
|
|
| const n = normalizeEpisodeStats(obs); |
| setText( |
| "stat-land-saved-val", |
| n.areaSaved != null ? `${Number(n.areaSaved).toFixed(1)}%` : "β" |
| ); |
| setText( |
| "stat-civilians-safe-val", |
| n.civSafe != null ? `${Number(n.civSafe).toFixed(1)}%` : "β" |
| ); |
| setText("stat-cells-burned-val", n.cellsBurned); |
| setText("stat-burning-val", pickStat(stats, "cells_burning", "cellsBurning") ?? 0); |
| setText("stat-pop-threat-val", pickStat(stats, "population_threatened", "populationThreatened") ?? 0); |
| setText("stat-pop-lost-val", n.popLost); |
|
|
| |
| setText("reward-total", cumulativeReward.toFixed(3)); |
|
|
| |
| const deltaEl = document.getElementById("reward-delta"); |
| if (deltaEl) { |
| const sign = lastStepReward >= 0 ? "+" : ""; |
| deltaEl.textContent = `${sign}${lastStepReward.toFixed(3)} this step`; |
| deltaEl.className = "reward-delta " + (lastStepReward >= 0 ? "positive" : "negative"); |
| } |
| } |
|
|
| function setText(id, value) { |
| const el = document.getElementById(id); |
| if (el) el.textContent = value; |
| } |
|
|
| |
| function updateResources(resources) { |
| if (!resources) return; |
|
|
| const crewBody = document.getElementById("crew-tbody"); |
| if (crewBody) { |
| crewBody.innerHTML = ""; |
| for (const crew of (resources.crews ?? [])) { |
| const tr = document.createElement("tr"); |
| let cls = "crew-idle"; |
| let status = "STAGING"; |
| if (!crew.is_active) { cls = "crew-lost"; status = "LOST"; } |
| else if (crew.is_deployed) { cls = "crew-deployed"; status = `${crew.row},${crew.col}`; } |
| tr.className = cls; |
| tr.innerHTML = `<td>${crew.crew_id.replace("crew_","C")}</td><td>${status}</td>`; |
| crewBody.appendChild(tr); |
| } |
| } |
|
|
| const tankerBody = document.getElementById("tanker-tbody"); |
| if (tankerBody) { |
| tankerBody.innerHTML = ""; |
| for (const tanker of (resources.tankers ?? [])) { |
| const tr = document.createElement("tr"); |
| tr.className = "tanker-row"; |
| const cd = tanker.cooldown_remaining ?? 0; |
| const maxCd = 5; |
| const pct = cd === 0 ? 0 : (cd / maxCd) * 100; |
| const readyClass = cd === 0 ? "tanker-ready" : "tanker-charging"; |
| const readyLabel = cd === 0 ? "READY" : `CD:${cd}`; |
| tr.innerHTML = ` |
| <td>${tanker.tanker_id.replace("tanker_","T")}</td> |
| <td class="${readyClass}">${readyLabel}</td> |
| <td> |
| <div class="cooldown-bar-wrap"> |
| <div class="cooldown-bar-fill" style="width:${pct}%"></div> |
| </div> |
| </td>`; |
| tankerBody.appendChild(tr); |
| } |
| } |
|
|
| |
| const fb = resources.firebreak_budget ?? 0; |
| const rb = resources.recon_budget ?? 0; |
| setText("firebreak-budget", `FB: ${fb}`); |
| setText("recon-budget", `RC: ${rb}`); |
| } |
|
|
| |
| function updateWeather(weather) { |
| if (!weather) return; |
|
|
| const speed = weather.wind_speed_kmh ?? 0; |
| const dir = weather.wind_direction_deg ?? 0; |
| const hum = weather.humidity_pct ?? 0; |
| const rain = weather.rain_active ?? false; |
|
|
| setText("wind-speed-val", `${speed.toFixed(0)} km/h`); |
| setText("wind-dir-val", `${dir.toFixed(0)}Β°`); |
| setText("humidity-val", `${hum.toFixed(0)}%`); |
|
|
| |
| const needle = document.getElementById("wind-needle"); |
| if (needle) needle.style.transform = `translateX(-50%) translateY(-100%) rotate(${dir}deg)`; |
|
|
| const rainBadge = document.getElementById("rain-badge"); |
| if (rainBadge) rainBadge.classList.toggle("active", rain); |
| } |
|
|
| |
| let _lastEventSet = []; |
|
|
| function updateEvents(events) { |
| if (!events || events.length === 0) return; |
|
|
| const newEvents = events.filter(e => !_lastEventSet.includes(e)); |
| if (newEvents.length === 0) return; |
| _lastEventSet = events; |
|
|
| const log = document.getElementById("events-log"); |
| if (!log) return; |
|
|
| for (const evt of newEvents.slice().reverse()) { |
| const div = document.createElement("div"); |
| div.className = "event-entry"; |
| div.textContent = evt; |
| log.insertBefore(div, log.firstChild); |
| } |
|
|
| |
| while (log.children.length > 30) log.removeChild(log.lastChild); |
| } |
|
|
| |
| function updateActionLog(action) { |
| if (!action) return; |
| setText("last-action-type", action.action_type?.toUpperCase() ?? "β"); |
| const params = { ...action }; |
| delete params.action_type; |
| const paramStr = Object.entries(params) |
| .filter(([, v]) => v !== null && v !== undefined) |
| .map(([k, v]) => `${k}: ${v}`) |
| .join(" | ") || "β"; |
| setText("last-action-params", paramStr); |
| } |
|
|
| |
| async function showTerminal() { |
| const overlay = document.getElementById("terminal-overlay"); |
| if (!overlay) return; |
|
|
| const card = document.getElementById("terminal-card"); |
| if (!card) return; |
|
|
| const n = normalizeEpisodeStats(sim.obs); |
| const title = card.querySelector("h2"); |
|
|
| if (n.popLost === 0) { |
| title.textContent = "β
EPISODE COMPLETE"; |
| title.className = "win"; |
| } else { |
| title.textContent = "β EPISODE ENDED"; |
| title.className = "loss"; |
| } |
|
|
| const landStr = n.areaSaved != null ? `${Number(n.areaSaved).toFixed(1)}%` : "β"; |
| const civStr = n.civSafe != null ? `${Number(n.civSafe).toFixed(1)}%` : "β"; |
| setText("terminal-land-saved", landStr); |
| setText("terminal-civilians-safe", civStr); |
| setText("terminal-cells-burned", String(n.cellsBurned)); |
| setText("terminal-pop-lost", n.popLost); |
| setText("terminal-reward", sim.cumulativeReward.toFixed(3)); |
| setText("terminal-step", n.currentStep ?? "β"); |
|
|
| overlay.classList.add("show"); |
|
|
| |
| try { |
| const st = await apiGet("/state"); |
| if (st.error) return; |
| const tb = st.total_burnable ?? 0; |
| const burned = st.cells_burned ?? 0; |
| const landPct = tb > 0 ? Math.round(1000 * (tb - burned) / tb) / 10 : 100; |
| const tp = st.total_population ?? 0; |
| const lost = st.population_lost ?? 0; |
| const civPct = tp > 0 ? Math.round(1000 * (tp - lost) / tp) / 10 : 100; |
| setText("terminal-land-saved", `${landPct}%`); |
| setText("terminal-civilians-safe", `${civPct}%`); |
| setText("terminal-cells-burned", String(burned)); |
| setText("terminal-pop-lost", String(lost)); |
| setText("terminal-step", st.current_step ?? "β"); |
| } catch (e) { |
| console.warn("Could not refresh end-game stats from /state", e); |
| } |
| } |
|
|
| function hideTerminal() { |
| document.getElementById("terminal-overlay")?.classList.remove("show"); |
| } |
|
|
| |
| async function apiPost(path, body = null, params = {}) { |
| const url = new URL(path, window.location.origin); |
| for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); |
| const opts = { method: "POST" }; |
| if (body) { opts.body = JSON.stringify(body); opts.headers = { "Content-Type": "application/json" }; } |
| const res = await fetch(url, opts); |
| if (!res.ok) { |
| const err = await res.json().catch(() => ({ detail: res.statusText })); |
| throw new Error(err.detail ?? res.statusText); |
| } |
| return res.json(); |
| } |
|
|
| async function apiGet(path) { |
| const res = await fetch(path); |
| if (!res.ok) { |
| const err = await res.json().catch(() => ({ detail: res.statusText })); |
| throw new Error(err.detail ?? res.statusText); |
| } |
| return res.json(); |
| } |
|
|
| |
| function applyObservation(obs) { |
| sim.obs = obs; |
| renderCanvas(obs, sim.groundTruthData); |
| updateStats(obs, sim.cumulativeReward, sim.lastStepReward); |
| updateResources(obs.resources); |
| updateWeather(obs.weather); |
| updateEvents(obs.recent_events ?? []); |
| } |
|
|
| |
| async function doReset() { |
| stopPlay(); |
| hideTerminal(); |
| setStatus("Resettingβ¦"); |
| setControlsEnabled(false); |
|
|
| sim.cumulativeReward = 0; |
| sim.lastStepReward = 0; |
| sim.done = false; |
| sim.groundTruthData = null; |
| _lastEventSet = []; |
| document.getElementById("events-log").innerHTML = ""; |
| setText("last-action-type", "β"); |
| setText("last-action-params", "β"); |
|
|
| try { |
| |
| const obs = await apiPost("/reset", null, { |
| task_id: sim.tier, |
| seed: sim.seed, |
| }); |
| applyObservation(obs); |
| setStatus("Ready"); |
| } catch (e) { |
| setStatus(`Error: ${e.message}`); |
| console.error(e); |
| } finally { |
| setControlsEnabled(true); |
| } |
| } |
|
|
| |
| async function doAutoStep() { |
| if (sim.done) { stopPlay(); return; } |
| if (!sim.obs) { stopPlay(); return; } |
|
|
| try { |
| |
| const data = await apiPost("/auto_step", null, { |
| n: 1, |
| agent: sim.agentMode, |
| }); |
|
|
| for (const snap of data.steps) { |
| |
| sim.lastStepReward = snap.reward; |
| sim.cumulativeReward += snap.reward; |
| sim.done = snap.done; |
|
|
| applyObservation(snap.observation); |
| updateActionLog(snap.action_taken); |
|
|
| if (snap.done) { |
| stopPlay(); |
| await showTerminal(); |
| break; |
| } |
| } |
|
|
| |
| if (document.getElementById("gt-toggle")?.checked) { |
| refreshGroundTruth(); |
| } |
| } catch (e) { |
| setStatus(`Step error: ${e.message}`); |
| console.error(e); |
| stopPlay(); |
| } |
| } |
|
|
| |
| async function refreshGroundTruth() { |
| try { |
| const gt = await apiGet("/state/render"); |
| sim.groundTruthData = gt; |
| renderCanvas(sim.obs, gt); |
| } catch (e) { |
| console.warn("Ground truth fetch failed:", e.message); |
| } |
| } |
|
|
| |
| function startPlay() { |
| if (sim.playing || sim.done || !sim.obs) return; |
| sim.playing = true; |
| updatePlayButton(); |
| doAutoStep(); |
| sim.playTimer = setInterval(doAutoStep, sim.speed); |
| } |
|
|
| function stopPlay() { |
| if (sim.playTimer) { clearInterval(sim.playTimer); sim.playTimer = null; } |
| sim.playing = false; |
| updatePlayButton(); |
| } |
|
|
| function togglePlay() { |
| if (sim.playing) stopPlay(); else startPlay(); |
| } |
|
|
| function updatePlayButton() { |
| const btn = document.getElementById("btn-play"); |
| if (!btn) return; |
| btn.textContent = sim.playing ? "βΈ Pause" : "βΆ Play"; |
| btn.classList.toggle("playing", sim.playing); |
| } |
|
|
| |
| function setStatus(msg) { |
| const el = document.getElementById("status-text"); |
| if (el) el.textContent = msg; |
| } |
|
|
| function setControlsEnabled(enabled) { |
| ["btn-reset", "btn-play", "btn-step"].forEach(id => { |
| const el = document.getElementById(id); |
| if (el) el.disabled = !enabled; |
| }); |
| } |
|
|
| |
| const tooltip = document.getElementById("cell-tooltip"); |
|
|
| canvas.addEventListener("mousemove", (e) => { |
| if (!sim.obs || sim.cellSize === 0) return; |
| const rect = canvas.getBoundingClientRect(); |
| const scaleX = canvas.width / rect.width; |
| const scaleY = canvas.height / rect.height; |
| const px = (e.clientX - rect.left) * scaleX; |
| const py = (e.clientY - rect.top) * scaleY; |
| const col = Math.floor(px / sim.cellSize); |
| const row = Math.floor(py / sim.cellSize); |
|
|
| const grid = sim.obs.grid; |
| if (row < 0 || row >= grid.length || col < 0 || col >= grid[0].length) { |
| tooltip.style.display = "none"; |
| return; |
| } |
| const cell = grid[row][col]; |
|
|
| tooltip.textContent = |
| `(${row},${col}) ${cell.fire_state}` + |
| (cell.fuel_type ? ` Β· ${cell.fuel_type}` : "") + |
| (cell.is_populated ? " Β· π pop" : "") + |
| (cell.fire_intensity ? ` Β· int:${cell.fire_intensity.toFixed(2)}` : ""); |
|
|
| const wrapRect = canvasWrap.getBoundingClientRect(); |
| tooltip.style.left = `${e.clientX - wrapRect.left + 10}px`; |
| tooltip.style.top = `${e.clientY - wrapRect.top + 10}px`; |
| tooltip.style.display = "block"; |
| }); |
|
|
| canvas.addEventListener("mouseleave", () => { tooltip.style.display = "none"; }); |
|
|
| |
| document.addEventListener("DOMContentLoaded", () => { |
|
|
| document.getElementById("btn-reset")?.addEventListener("click", doReset); |
|
|
| document.getElementById("btn-play")?.addEventListener("click", togglePlay); |
|
|
| document.getElementById("btn-step")?.addEventListener("click", async () => { |
| if (sim.done || !sim.obs) return; |
| stopPlay(); |
| await doAutoStep(); |
| }); |
|
|
| |
| document.getElementById("tier-select")?.addEventListener("change", (e) => { |
| sim.tier = e.target.value; |
| }); |
|
|
| |
| document.getElementById("seed-input")?.addEventListener("change", (e) => { |
| sim.seed = parseInt(e.target.value, 10) || 42; |
| }); |
|
|
| |
| document.getElementById("agent-select")?.addEventListener("change", (e) => { |
| sim.agentMode = e.target.value; |
| |
| if (sim.playing) stopPlay(); |
| }); |
|
|
| |
| document.getElementById("speed-slider")?.addEventListener("input", (e) => { |
| sim.speed = parseInt(e.target.value, 10); |
| setText("speed-label", `${sim.speed}ms`); |
| if (sim.playing) { |
| clearInterval(sim.playTimer); |
| sim.playTimer = setInterval(doAutoStep, sim.speed); |
| } |
| }); |
|
|
| |
| document.getElementById("gt-toggle")?.addEventListener("change", async (e) => { |
| if (e.target.checked) { |
| await refreshGroundTruth(); |
| } else { |
| sim.groundTruthData = null; |
| renderCanvas(sim.obs, null); |
| } |
| }); |
|
|
| |
| document.getElementById("btn-play-again")?.addEventListener("click", doReset); |
|
|
| |
| doReset(); |
| }); |
|
|
| |
| window.addEventListener("resize", () => { |
| if (sim.obs) renderCanvas(sim.obs, sim.groundTruthData); |
| }); |
|
|