| |
| |
| |
| |
| |
| |
| |
| "use strict"; |
|
|
| |
|
|
| const API_BASE = "http://localhost:8080"; |
|
|
| const POLICY_META = { |
| no_intervention: { label: "No Intervention", color: 0x94a3b8, cssVar: "--color-no-intervention" }, |
| fixed: { label: "Fixed Cycle", color: 0xf59e0b, cssVar: "--color-fixed" }, |
| random: { label: "Random Phase", color: 0x10b981, cssVar: "--color-random" }, |
| learned: { label: "DQN (Learned)", color: 0x5b6cf9, cssVar: "--color-learned" }, |
| dqn_heuristic: { label: "DQN + Heuristic", color: 0x06b6d4, cssVar: "--color-dqn-heuristic" }, |
| llm_dqn: { label: "DQN + LLM", color: 0xec4899, cssVar: "--color-llm-dqn" }, |
| }; |
|
|
| const ALL_POLICY_KEYS = Object.keys(POLICY_META); |
|
|
| const BACKGROUND_COLOR = 0xf5f0e8; |
| const LANE_COLOR = 0x8a7f72; |
| const LANE_BORDER_COLOR = 0x4a4035; |
| const LANE_INNER_COLOR = 0x4a4035; |
| const LANE_BORDER_WIDTH = 1; |
| const LANE_DASH = 10; |
| const LANE_GAP = 12; |
| const ROTATE = 90; |
| const DISTRICT_BORDER_COLOR = 0x34495e; |
| const GATEWAY_NODE_COLOR = 0xf39c12; |
| const GATEWAY_EDGE_COLOR = 0xe67e22; |
| const ENTRY_EDGE_COLOR = 0x22c55e; |
| const EXIT_EDGE_COLOR = 0xef4444; |
| const DISTRICT_PALETTE = [ |
| 0x1f77b4, 0xff7f0e, 0x2ca02c, 0xd62728, 0x9467bd, 0x8c564b, |
| 0xe377c2, 0x7f7f7f, 0xbcbd22, 0x17becf, 0x393b79, 0x637939 |
| ]; |
| const TRAFFIC_LIGHT_WIDTH = 3; |
| const MAX_TRAFFIC_LIGHT_NUM = 2000; |
| const NUM_CAR_POOL = 5000; |
| const SIM_WINDOW_SECONDS = 180; |
| const DEFAULT_CITY_ID = "city_0001"; |
| const DEFAULT_SCENARIO = "normal"; |
| const STORAGE_KEYS = { |
| uiState: "trafficVisualizer.uiState.v1", |
| recentRuns: "trafficVisualizer.recentRuns.v1", |
| }; |
| const MAX_RECENT_RUNS = 12; |
| const CAR_LENGTH = 5; |
| const CAR_WIDTH = 2; |
| const LIGHT_RED = 0xdb635e; |
| const LIGHT_GREEN = 0x85ee00; |
| const CAR_COLORS = [0xf2bfd7, 0xb7ebe4, 0xdbebb7, 0xf5ddb5, 0xd4b5f5]; |
|
|
| |
|
|
| let activeCityId = null; |
| let activeScenario = null; |
| let sharedRoadnetData = null; |
| let activeDistrictMap = null; |
| let activeReplayPayloads = []; |
| let activePanels = []; |
| let viewLayout = 1; |
| let paused = false; |
| let globalStep = 0; |
| let totalSteps = 0; |
| let frameElapsed = 0; |
| let replaySpeed = 0.5; |
| let animFrameId = null; |
|
|
| |
|
|
| const panelsContainer = document.getElementById("panels-container"); |
| const welcomeOverlay = document.getElementById("welcome-overlay"); |
| const playbackBar = document.getElementById("playback-bar"); |
| const summaryBar = document.getElementById("summary-bar"); |
| const pauseBtn = document.getElementById("pause-btn"); |
| const scrubber = document.getElementById("scrubber"); |
| const stepDisplay = document.getElementById("step-display"); |
| const speedLabel = document.getElementById("speed-label"); |
| const speedDown = document.getElementById("speed-down"); |
| const speedUp = document.getElementById("speed-up"); |
| |
| const progressSection = document.getElementById("progress-section"); |
| const debugLogEl = document.getElementById("debug-log"); |
| const recentRunsList = document.getElementById("recent-runs-list"); |
| const runBtn = document.getElementById("run-btn"); |
| const citySelect = document.getElementById("city-select"); |
| const scenarioSelect = document.getElementById("scenario-select"); |
| const forceRerunCheckbox = document.getElementById("force-rerun-checkbox"); |
|
|
| let savedUiState = loadSavedUiState(); |
| let suppressCityAutoDefault = false; |
|
|
| |
|
|
| (async function init() { |
| debugLog(`init start; sim_window=${SIM_WINDOW_SECONDS}s`); |
| setupDropZone(); |
| setupPolicyCheckboxes(); |
| setupViewModeButtons(); |
| setupPlaybackControls(); |
| setupSidebarResize(); |
| restoreSavedPolicyState(); |
| restoreSavedViewLayout(); |
| renderRecentRuns(); |
| await loadCityList(); |
| restoreSavedUiSelections(); |
| debugLog("init complete"); |
| })(); |
|
|
| |
|
|
| async function loadCityList() { |
| try { |
| const resp = await fetch(`${API_BASE}/cities`); |
| const data = await resp.json(); |
| debugLog(`loaded cities: ${(data.cities || []).join(", ")}`); |
| populateCitySelect(data.cities || []); |
| } catch (_) { |
| |
| debugLog("failed to load city list"); |
| } |
| } |
|
|
| function populateCitySelect(cities) { |
| citySelect.innerHTML = '<option value="">-- select city --</option>'; |
| for (const c of cities) { |
| const opt = document.createElement("option"); |
| opt.value = c; |
| opt.textContent = c; |
| citySelect.appendChild(opt); |
| } |
| if (!suppressCityAutoDefault && cities.includes(DEFAULT_CITY_ID)) { |
| citySelect.value = DEFAULT_CITY_ID; |
| citySelect.dispatchEvent(new Event("change")); |
| } |
| } |
|
|
| citySelect.addEventListener("change", async () => { |
| const city = citySelect.value; |
| scenarioSelect.innerHTML = '<option value="">-- loading --</option>'; |
| scenarioSelect.disabled = true; |
| persistUiState(); |
| if (!city) return; |
| try { |
| const resp = await fetch(`${API_BASE}/cities/${city}/scenarios`); |
| const data = await resp.json(); |
| debugLog(`loaded scenarios for ${city}: ${(data.scenarios || []).join(", ")}`); |
| populateScenarioSelect(data.scenarios || []); |
| } catch (_) { |
| scenarioSelect.innerHTML = '<option value="">-- error --</option>'; |
| debugLog(`failed to load scenarios for ${city}`); |
| } |
| }); |
|
|
| scenarioSelect.addEventListener("change", persistUiState); |
| forceRerunCheckbox?.addEventListener("change", persistUiState); |
|
|
| function populateScenarioSelect(scenarios) { |
| scenarioSelect.innerHTML = '<option value="">-- select scenario --</option>'; |
| for (const s of scenarios) { |
| const opt = document.createElement("option"); |
| opt.value = s; |
| opt.textContent = s; |
| scenarioSelect.appendChild(opt); |
| } |
| scenarioSelect.disabled = false; |
| if (savedUiState && savedUiState.city === citySelect.value && scenarios.includes(savedUiState.scenario)) { |
| scenarioSelect.value = savedUiState.scenario; |
| savedUiState = { ...savedUiState, scenario: null }; |
| } else if (scenarios.includes(DEFAULT_SCENARIO)) { |
| scenarioSelect.value = DEFAULT_SCENARIO; |
| } |
| persistUiState(); |
| } |
|
|
| |
|
|
| function setupDropZone() { |
| const zone = document.getElementById("roadnet-drop-zone"); |
| const input = document.getElementById("roadnet-file-input"); |
|
|
| zone.addEventListener("click", () => input.click()); |
|
|
| zone.addEventListener("dragover", (e) => { |
| e.preventDefault(); |
| zone.classList.add("drag-over"); |
| }); |
| zone.addEventListener("dragleave", () => zone.classList.remove("drag-over")); |
| zone.addEventListener("drop", (e) => { |
| e.preventDefault(); |
| zone.classList.remove("drag-over"); |
| if (e.dataTransfer.files[0]) handleRoadnetFile(e.dataTransfer.files[0]); |
| }); |
|
|
| input.addEventListener("change", () => { |
| if (input.files[0]) handleRoadnetFile(input.files[0]); |
| }); |
| } |
|
|
| async function handleRoadnetFile(file) { |
| const zone = document.getElementById("roadnet-drop-zone"); |
| zone.querySelector(".drop-filename").textContent = file.name; |
|
|
| const formData = new FormData(); |
| formData.append("file", file); |
|
|
| try { |
| const resp = await fetch(`${API_BASE}/upload-roadnet`, { method: "POST", body: formData }); |
| const data = await resp.json(); |
|
|
| if (data.matched && data.city_id) { |
| |
| citySelect.value = data.city_id; |
| populateScenarioSelect(data.scenarios); |
| showToast(`Matched city: ${data.city_id}`); |
| } else if (data.all_cities && data.all_cities.length > 0) { |
| populateCitySelect(data.all_cities); |
| showToast("No exact match – please select city manually.", "warn"); |
| } |
| } catch (err) { |
| showToast("Upload failed: " + err.message, "error"); |
| } |
| } |
|
|
| |
|
|
| function setupPolicyCheckboxes() { |
| const list = document.getElementById("policy-list"); |
|
|
| for (const key of ALL_POLICY_KEYS) { |
| const meta = POLICY_META[key]; |
| const item = document.createElement("label"); |
| item.className = "policy-item" + (meta.comingSoon ? " disabled" : ""); |
|
|
| const cb = document.createElement("input"); |
| cb.type = "checkbox"; |
| cb.value = key; |
| cb.id = `cb-${key}`; |
| cb.checked = !meta.comingSoon; |
| cb.disabled = !!meta.comingSoon; |
|
|
| const dot = document.createElement("span"); |
| dot.className = "policy-badge"; |
| dot.style.background = `#${meta.color.toString(16).padStart(6, "0")}`; |
|
|
| const label = document.createElement("span"); |
| label.className = "policy-label"; |
| label.textContent = meta.label; |
|
|
| item.appendChild(cb); |
| item.appendChild(dot); |
| item.appendChild(label); |
|
|
| if (meta.comingSoon) { |
| const tag = document.createElement("span"); |
| tag.className = "policy-tag"; |
| tag.textContent = "soon"; |
| item.appendChild(tag); |
| } |
|
|
| cb.addEventListener("change", persistUiState); |
|
|
| list.appendChild(item); |
| } |
| } |
|
|
| function getSelectedPolicies() { |
| return ALL_POLICY_KEYS.filter((key) => { |
| const cb = document.getElementById(`cb-${key}`); |
| return cb && cb.checked && !POLICY_META[key].comingSoon; |
| }); |
| } |
|
|
| |
|
|
| function setupViewModeButtons() { |
| document.querySelectorAll(".view-mode-btn").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| document.querySelectorAll(".view-mode-btn").forEach((b) => b.classList.remove("active")); |
| btn.classList.add("active"); |
| viewLayout = parseInt(btn.dataset.panels, 10); |
| persistUiState(); |
| if (activePanels.length > 0) renderPanelGrid(); |
| }); |
| }); |
| } |
|
|
| |
|
|
| runBtn.addEventListener("click", async () => { |
| const city = citySelect.value; |
| const scenario = scenarioSelect.value; |
| const policies = getSelectedPolicies(); |
|
|
| if (!city || !scenario) { |
| showToast("Select a city and scenario first.", "warn"); |
| return; |
| } |
| if (policies.length === 0) { |
| showToast("Select at least one policy.", "warn"); |
| return; |
| } |
|
|
| activeCityId = city; |
| activeScenario = scenario; |
| persistUiState(); |
| debugLog(`run requested city=${city} scenario=${scenario} policies=${policies.join(", ")}`); |
|
|
| runBtn.disabled = true; |
| initProgress(policies); |
|
|
| |
| |
| let cachedPolicies = new Set(); |
| try { |
| const cacheResp = await fetch(`${API_BASE}/metrics/${city}/${scenario}`); |
| if (cacheResp.ok) { |
| const cacheData = await cacheResp.json(); |
| for (const key of policies) { |
| if (cacheData.metrics && cacheData.metrics[key]?.replay_available) cachedPolicies.add(key); |
| } |
| debugLog(`cache probe found replay for: ${Array.from(cachedPolicies).join(", ") || "none"}`); |
| } |
| } catch (_) { } |
|
|
| policies.forEach((p) => { |
| if (cachedPolicies.has(p)) markPolicyCached(p); |
| else markPolicyRunning(p); |
| }); |
|
|
| let allResults; |
| try { |
| const resp = await fetch(`${API_BASE}/run-simulations`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| city_id: city, |
| scenario_name: scenario, |
| policies, |
| force: document.getElementById("force-rerun-checkbox")?.checked ?? false, |
| }), |
| }); |
| if (!resp.ok) { |
| const errData = await resp.json(); |
| throw new Error(errData.detail || resp.statusText); |
| } |
| const data = await resp.json(); |
| debugLog(`batch run response received: ${data.results?.length || 0} policies`); |
| const resultsByPolicy = new Map((data.results || []).map((result) => [result.policy_name, result])); |
| allResults = policies.map((policy) => { |
| const result = resultsByPolicy.get(policy) || { |
| policy_name: policy, |
| metrics: { error: "Missing policy result from server." }, |
| replay_available: false, |
| roadnet_log_available: false, |
| elapsed_ms: null, |
| }; |
| const elapsedMs = Number(result.elapsed_ms ?? 0); |
| debugLog( |
| `policy=${policy} replay=${!!result.replay_available} roadnet=${!!result.roadnet_log_available} ` + |
| `elapsed=${(elapsedMs / 1000).toFixed(1)}s` |
| ); |
| markPolicyDone(policy, elapsedMs, result.replay_available ? "done" : "error"); |
| return result; |
| }); |
| } catch (err) { |
| debugLog(`batch run failed: ${err.message}`); |
| allResults = policies.map((policy) => { |
| markPolicyDone(policy, 0, "error"); |
| return { |
| policy_name: policy, |
| metrics: { error: err.message }, |
| replay_available: false, |
| roadnet_log_available: false, |
| elapsed_ms: 0, |
| }; |
| }); |
| } |
|
|
| const firstSuccess = allResults.find((r) => r.roadnet_log_available); |
| if (!firstSuccess) { |
| showToast("No successful policy runs.", "error"); |
| runBtn.disabled = false; |
| return; |
| } |
|
|
| try { |
| await fetchAndStartVisualization(city, scenario, allResults); |
| saveRecentRun({ |
| city, |
| scenario, |
| policies, |
| durationsMsByPolicy: Object.fromEntries( |
| allResults.map((result) => [ |
| result.policy_name, |
| result.elapsed_ms ?? 0, |
| ]) |
| ), |
| replayAvailableByPolicy: Object.fromEntries( |
| allResults.map((result) => [ |
| result.policy_name, |
| !!result.replay_available, |
| ]) |
| ), |
| metricsByPolicy: Object.fromEntries( |
| allResults.map((result) => [ |
| result.policy_name, |
| result.metrics || {}, |
| ]) |
| ), |
| viewLayout, |
| }); |
| } catch (err) { |
| debugLog(`visualization failed: ${err.message}`); |
| showToast("Error: " + err.message, "error"); |
| } finally { |
| runBtn.disabled = false; |
| } |
| }); |
|
|
| |
|
|
| async function fetchAndStartVisualization(city, scenario, policyResults) { |
| debugLog(`viz start city=${city} scenario=${scenario}`); |
| stopAnimation(); |
| destroyAllPanels(); |
|
|
| |
| const firstPolicy = policyResults.find((r) => r.roadnet_log_available); |
| const roadnetResp = await fetch( |
| `${API_BASE}/roadnet-log/${city}/${scenario}/${firstPolicy.policy_name}` |
| ); |
| if (!roadnetResp.ok) throw new Error("Failed to load roadnet log."); |
| sharedRoadnetData = await roadnetResp.json(); |
| debugLog(`roadnet loaded from policy=${firstPolicy.policy_name}`); |
| activeDistrictMap = null; |
| try { |
| const districtResp = await fetch(`${API_BASE}/cities/${city}/district-map`); |
| if (districtResp.ok) activeDistrictMap = await districtResp.json(); |
| debugLog(`district map ${activeDistrictMap ? "loaded" : "missing"}`); |
| } catch (_) { |
| activeDistrictMap = null; |
| debugLog("district map fetch failed"); |
| } |
| const rn = sharedRoadnetData.static || sharedRoadnetData; |
| console.log("[viz] roadnet loaded — nodes:", (rn.node || rn.nodes || []).length, "edges:", (rn.edge || rn.edges || []).length); |
|
|
| |
| |
| const replayPayloads = []; |
| for (const result of policyResults.filter((r) => r.replay_available)) { |
| debugLog(`fetching replay for ${result.policy_name}`); |
| const replayResp = await fetch( |
| `${API_BASE}/replay/${city}/${scenario}/${result.policy_name}` |
| ); |
| if (!replayResp.ok) { |
| console.warn("[viz] replay fetch failed for", result.policy_name, replayResp.status); |
| continue; |
| } |
| const text = await replayResp.text(); |
| |
| const lines = text.split("\n").filter((l) => l.trim().length > 0); |
| console.log(`[viz] replay loaded — policy: ${result.policy_name}, steps: ${lines.length}`); |
| debugLog(`replay loaded for ${result.policy_name}: ${lines.length} steps`); |
| replayPayloads.push({ policy_name: result.policy_name, metrics: result.metrics, lines }); |
| } |
| console.log("[viz] replay payloads ready:", replayPayloads.map((p) => `${p.policy_name}(${p.lines.length}steps)`)); |
| if (!replayPayloads.length) { |
| throw new Error("No replay data available for the selected policies."); |
| } |
| debugLog(`viz payloads ready: ${replayPayloads.map((p) => `${p.policy_name}(${p.lines.length})`).join(", ")}`); |
|
|
| totalSteps = Math.min(...replayPayloads.map((p) => p.lines.length)); |
| globalStep = 0; |
| frameElapsed = 0; |
| paused = false; |
|
|
| scrubber.max = Math.max(0, totalSteps - 1); |
| scrubber.value = 0; |
|
|
| |
| activeReplayPayloads = replayPayloads; |
| renderSummaryCards(policyResults); |
| renderPanelGrid(); |
| debugLog(`rendered ${Math.min(activeReplayPayloads.length, viewLayout)} panel(s)`); |
|
|
| welcomeOverlay.classList.add("hidden"); |
| startAnimation(); |
| } |
|
|
| function loadSavedUiState() { |
| try { |
| return JSON.parse(localStorage.getItem(STORAGE_KEYS.uiState) || "null"); |
| } catch (_) { |
| return null; |
| } |
| } |
|
|
| function persistUiState() { |
| const payload = { |
| city: citySelect.value || null, |
| scenario: scenarioSelect.value || null, |
| policies: getSelectedPolicies(), |
| viewLayout, |
| forceRerun: !!forceRerunCheckbox?.checked, |
| }; |
| localStorage.setItem(STORAGE_KEYS.uiState, JSON.stringify(payload)); |
| } |
|
|
| function restoreSavedPolicyState() { |
| if (!savedUiState || !Array.isArray(savedUiState.policies)) return; |
| for (const key of ALL_POLICY_KEYS) { |
| const cb = document.getElementById(`cb-${key}`); |
| if (!cb || cb.disabled) continue; |
| cb.checked = savedUiState.policies.includes(key); |
| } |
| if (forceRerunCheckbox && typeof savedUiState.forceRerun === "boolean") { |
| forceRerunCheckbox.checked = savedUiState.forceRerun; |
| } |
| } |
|
|
| function restoreSavedViewLayout() { |
| if (!savedUiState || !savedUiState.viewLayout) return; |
| const btn = document.querySelector(`.view-mode-btn[data-panels="${savedUiState.viewLayout}"]`); |
| if (btn) btn.click(); |
| } |
|
|
| function restoreSavedUiSelections() { |
| if (!savedUiState || !savedUiState.city) return; |
| if (!Array.from(citySelect.options).some((opt) => opt.value === savedUiState.city)) return; |
| suppressCityAutoDefault = true; |
| citySelect.value = savedUiState.city; |
| citySelect.dispatchEvent(new Event("change")); |
| } |
|
|
| function loadRecentRuns() { |
| try { |
| const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.recentRuns) || "[]"); |
| return Array.isArray(parsed) ? parsed : []; |
| } catch (_) { |
| return []; |
| } |
| } |
|
|
| function saveRecentRun(run) { |
| const recentRuns = loadRecentRuns(); |
| const normalized = { |
| id: `${run.city}__${run.scenario}__${Date.now()}`, |
| city: run.city, |
| scenario: run.scenario, |
| policies: run.policies, |
| durationsMsByPolicy: run.durationsMsByPolicy || {}, |
| replayAvailableByPolicy: run.replayAvailableByPolicy || {}, |
| metricsByPolicy: run.metricsByPolicy || {}, |
| viewLayout: run.viewLayout || 1, |
| savedAt: new Date().toISOString(), |
| }; |
| const nextRuns = [normalized, ...recentRuns].slice(0, MAX_RECENT_RUNS); |
| localStorage.setItem(STORAGE_KEYS.recentRuns, JSON.stringify(nextRuns)); |
| renderRecentRuns(); |
| } |
|
|
| function renderRecentRuns() { |
| if (!recentRunsList) return; |
| const recentRuns = loadRecentRuns(); |
| recentRunsList.innerHTML = ""; |
| if (!recentRuns.length) { |
| recentRunsList.innerHTML = '<div class="recent-run-empty">No saved runs yet.</div>'; |
| return; |
| } |
| for (const run of recentRuns) { |
| const card = document.createElement("div"); |
| card.className = "recent-run-card"; |
| const totalMs = Object.values(run.durationsMsByPolicy || {}).reduce((sum, value) => sum + Number(value || 0), 0); |
| const policiesHtml = (run.policies || []) |
| .map((policy) => { |
| const meta = POLICY_META[policy]; |
| const label = meta ? meta.label : policy; |
| const seconds = Number(run.durationsMsByPolicy?.[policy] || 0) / 1000; |
| return `<span class="recent-run-chip">${label} · ${seconds.toFixed(1)}s</span>`; |
| }) |
| .join(""); |
| card.innerHTML = ` |
| <div class="recent-run-top"> |
| <div class="recent-run-title">${run.city} / ${run.scenario}</div> |
| <div class="recent-run-time">${formatSavedAt(run.savedAt)}</div> |
| </div> |
| <div class="recent-run-meta">Total runtime: ${(totalMs / 1000).toFixed(1)}s</div> |
| <div class="recent-run-policies">${policiesHtml}</div> |
| <div class="recent-run-actions"> |
| <button class="recent-run-btn" data-action="restore">Restore</button> |
| <button class="recent-run-btn" data-action="open-cache">Open Cached</button> |
| </div> |
| `; |
| card.querySelector('[data-action="restore"]').addEventListener("click", () => restoreRecentRun(run, false)); |
| card.querySelector('[data-action="open-cache"]').addEventListener("click", () => restoreRecentRun(run, true)); |
| recentRunsList.appendChild(card); |
| } |
| } |
|
|
| async function restoreRecentRun(run, openCached) { |
| savedUiState = { |
| city: run.city, |
| scenario: run.scenario, |
| policies: run.policies || [], |
| viewLayout: run.viewLayout || 1, |
| forceRerun: false, |
| }; |
| restoreSavedPolicyState(); |
| restoreSavedViewLayout(); |
| suppressCityAutoDefault = true; |
| if (Array.from(citySelect.options).some((opt) => opt.value === run.city)) { |
| citySelect.value = run.city; |
| await citySelect.dispatchEvent(new Event("change")); |
| } |
| persistUiState(); |
| if (!openCached) return; |
| try { |
| const metricsResp = await fetch(`${API_BASE}/metrics/${run.city}/${run.scenario}`); |
| if (!metricsResp.ok) throw new Error("Cached metrics not found."); |
| const metricsData = await metricsResp.json(); |
| const policyResults = (run.policies || []) |
| .filter((policy) => metricsData.metrics && metricsData.metrics[policy]?.replay_available) |
| .map((policy) => ({ |
| policy_name: policy, |
| metrics: metricsData.metrics[policy], |
| replay_available: !!metricsData.metrics[policy]?.replay_available, |
| roadnet_log_available: !!metricsData.metrics[policy]?.roadnet_log_available, |
| elapsed_ms: Number(run.durationsMsByPolicy?.[policy] || 0), |
| })); |
| if (!policyResults.length) { |
| showToast("No cached run data found for this selection.", "warn"); |
| return; |
| } |
| await fetchAndStartVisualization(run.city, run.scenario, policyResults); |
| } catch (err) { |
| showToast(`Failed to open cached run: ${err.message}`, "error"); |
| } |
| } |
|
|
| function formatSavedAt(savedAt) { |
| if (!savedAt) return "saved"; |
| const date = new Date(savedAt); |
| if (Number.isNaN(date.getTime())) return "saved"; |
| return date.toLocaleString([], { |
| month: "short", |
| day: "numeric", |
| hour: "numeric", |
| minute: "2-digit", |
| }); |
| } |
|
|
| |
|
|
| function renderPanelGrid() { |
| panelsContainer.className = `layout-${viewLayout}`; |
| destroyAllPanels(); |
|
|
| if (!activeReplayPayloads.length) return; |
|
|
| stopAnimation(); |
| const toShow = activeReplayPayloads.slice(0, viewLayout); |
| for (const payload of toShow) { |
| const panel = new SimPanel(payload); |
| panel.init(sharedRoadnetData, activeDistrictMap); |
| panelsContainer.appendChild(panel.element); |
| activePanels.push(panel); |
| } |
| globalStep = 0; |
| frameElapsed = 0; |
| scrubber.value = 0; |
| startAnimation(); |
| } |
|
|
| function destroyAllPanels() { |
| for (const p of activePanels) p.destroy(); |
| activePanels = []; |
| panelsContainer.innerHTML = ""; |
| } |
|
|
| function renderSummaryCards(policyResults) { |
| if (!summaryBar) return; |
| summaryBar.innerHTML = ""; |
| if (!policyResults || !policyResults.length) { |
| summaryBar.innerHTML = '<div class="summary-empty">Run a simulation to compare policy metrics.</div>'; |
| return; |
| } |
|
|
| const baseline = policyResults.find((result) => result.policy_name === "learned") || policyResults[0]; |
| for (const result of policyResults) { |
| const meta = POLICY_META[result.policy_name] || { label: result.policy_name, color: 0x888888 }; |
| const metrics = result.metrics || {}; |
| const card = document.createElement("div"); |
| card.className = "summary-card"; |
| card.style.setProperty("--summary-color", `#${meta.color.toString(16).padStart(6, "0")}`); |
|
|
| const queue = pickMetric(metrics, ["mean_waiting_vehicles", "avg_queue"]); |
| const throughput = pickMetric(metrics, ["throughput"]); |
| const travelTime = pickMetric(metrics, ["average_travel_time", "travel_time"]); |
| const totalReturn = pickMetric(metrics, ["total_episode_return", "total_return", "episode_return"]); |
| const activePct = pickMetric(metrics, ["percent_steps_with_active_guidance"]); |
|
|
| const queueDelta = baseline === result ? null : diffMetric(queue, pickMetric(baseline.metrics || {}, ["mean_waiting_vehicles", "avg_queue"]), true); |
| const throughputDelta = baseline === result ? null : diffMetric(throughput, pickMetric(baseline.metrics || {}, ["throughput"]), false); |
|
|
| card.innerHTML = ` |
| <div class="summary-card-header"> |
| <div class="summary-card-title">${meta.label}</div> |
| <div class="summary-card-subtitle">${result.policy_name}</div> |
| </div> |
| <div class="summary-grid"> |
| <div> |
| <div class="summary-stat-label">Return</div> |
| <div class="summary-stat-value">${formatMetric(totalReturn, 2)}</div> |
| </div> |
| <div> |
| <div class="summary-stat-label">Queue</div> |
| <div class="summary-stat-value">${formatMetric(queue, 1)}</div> |
| ${queueDelta ? `<div class="summary-delta ${queueDelta.className}">${queueDelta.label}</div>` : ""} |
| </div> |
| <div> |
| <div class="summary-stat-label">Throughput</div> |
| <div class="summary-stat-value">${formatMetric(throughput, 0)}</div> |
| ${throughputDelta ? `<div class="summary-delta ${throughputDelta.className}">${throughputDelta.label}</div>` : ""} |
| </div> |
| <div> |
| <div class="summary-stat-label">Travel Time</div> |
| <div class="summary-stat-value">${formatMetric(travelTime, 1)}</div> |
| </div> |
| <div> |
| <div class="summary-stat-label">Guidance Active</div> |
| <div class="summary-stat-value">${activePct == null ? "—" : `${(activePct * 100).toFixed(0)}%`}</div> |
| </div> |
| <div> |
| <div class="summary-stat-label">Runtime</div> |
| <div class="summary-stat-value">${formatDuration(result.elapsed_ms)}</div> |
| </div> |
| </div> |
| `; |
| summaryBar.appendChild(card); |
| } |
| } |
|
|
| |
|
|
| class SimPanel { |
| constructor({ policy_name, metrics, lines }) { |
| this.policyName = policy_name; |
| this.metrics = metrics || {}; |
| this.lines = lines; |
| this.app = null; |
| this.viewport = null; |
| this.nodes = {}; |
| this.edges = {}; |
| this.trafficLightsG = {}; |
| this.carPool = []; |
| this.carContainer = null; |
| this.trafficLightContainer = null; |
| this.turnSignalContainer = null; |
| this.element = null; |
| this.canvasWrapper = null; |
| this.renderer = null; |
| this.simulatorContainer = null; |
| this.overlayContainer = null; |
| this.legendEl = null; |
| this._ready = false; |
| } |
|
|
| get meta() { return POLICY_META[this.policyName] || { label: this.policyName, color: 0x888888 }; } |
|
|
| init(roadnetData, districtMap) { |
| this._buildElement(); |
| this._initPixi(roadnetData, districtMap); |
| } |
|
|
| _buildElement() { |
| const colorHex = "#" + this.meta.color.toString(16).padStart(6, "0"); |
|
|
| this.element = document.createElement("div"); |
| this.element.className = "sim-panel"; |
| this.element.style.borderTop = `3px solid ${colorHex}`; |
|
|
| |
| const header = document.createElement("div"); |
| header.className = "panel-header"; |
|
|
| |
| const labelBox = document.createElement("div"); |
| labelBox.className = "panel-label-box"; |
|
|
| const dot = document.createElement("span"); |
| dot.className = "panel-policy-dot"; |
| dot.style.background = colorHex; |
|
|
| const name = document.createElement("span"); |
| name.className = "panel-policy-name"; |
| name.textContent = this.meta.label; |
| name.style.color = colorHex; |
|
|
| labelBox.appendChild(dot); |
| labelBox.appendChild(name); |
| header.appendChild(labelBox); |
|
|
| |
| const zoomWrap = document.createElement("div"); |
| zoomWrap.className = "panel-zoom-controls"; |
| const zoomIn = document.createElement("button"); |
| const zoomOut = document.createElement("button"); |
| zoomIn.className = "panel-zoom-btn"; |
| zoomOut.className = "panel-zoom-btn"; |
| zoomIn.textContent = "+"; |
| zoomOut.textContent = "−"; |
| zoomIn.addEventListener("click", () => { if (this.viewport) this.viewport.zoomPercent(0.25, true); }); |
| zoomOut.addEventListener("click", () => { if (this.viewport) this.viewport.zoomPercent(-0.25, true); }); |
| zoomWrap.appendChild(zoomOut); |
| zoomWrap.appendChild(zoomIn); |
| header.appendChild(zoomWrap); |
|
|
| |
| this.canvasWrapper = document.createElement("div"); |
| this.canvasWrapper.className = "panel-canvas-wrapper"; |
|
|
| |
| const spinner = document.createElement("div"); |
| spinner.className = "panel-spinner"; |
| spinner.innerHTML = '<div class="spinner-ring"></div>'; |
| this.canvasWrapper.appendChild(spinner); |
|
|
| |
| const m = this.metrics; |
| const metricRows = [ |
| ["Avg Wait", m.mean_waiting_vehicles], |
| ["Throughput", m.throughput], |
| ["Travel Time", m.average_travel_time], |
| ]; |
| const overlay = document.createElement("div"); |
| overlay.className = "panel-metrics-overlay"; |
| for (const [label, val] of metricRows) { |
| const row = document.createElement("div"); |
| row.className = "pmo-row"; |
| row.innerHTML = `<span class="pmo-label">${label}</span><span class="pmo-value">${val !== undefined ? Number(val).toFixed(1) : "—"}</span>`; |
| overlay.appendChild(row); |
| } |
| this.canvasWrapper.appendChild(overlay); |
|
|
| this.legendEl = document.createElement("div"); |
| this.legendEl.className = "panel-map-legend hidden"; |
| this.legendEl.innerHTML = ` |
| <div class="panel-map-legend-title">Map</div> |
| <div class="panel-map-legend-row"><span class="panel-map-swatch districts"></span><span>Districts</span></div> |
| <div class="panel-map-legend-row"><span class="panel-map-swatch entry"></span><span>Entry roads</span></div> |
| <div class="panel-map-legend-row"><span class="panel-map-swatch exit"></span><span>Exit roads</span></div> |
| <div class="panel-map-legend-row"><span class="panel-map-swatch gateway"></span><span>Gateway nodes</span></div> |
| `; |
| this.canvasWrapper.appendChild(this.legendEl); |
|
|
| this.element.appendChild(header); |
| this.element.appendChild(this.canvasWrapper); |
| } |
|
|
| _initPixi(roadnetData, districtMap) { |
| const wrapper = this.canvasWrapper; |
|
|
| |
| requestAnimationFrame(() => { |
| const w = wrapper.offsetWidth || 800; |
| const h = wrapper.offsetHeight || 600; |
| console.log(`[viz] PIXI init — policy: ${this.policyName}, canvas: ${w}x${h}`); |
|
|
| this.app = new PIXI.Application({ |
| width: w, |
| height: h, |
| backgroundColor: BACKGROUND_COLOR, |
| antialias: false, |
| resolution: window.devicePixelRatio || 1, |
| autoDensity: true, |
| }); |
|
|
| wrapper.appendChild(this.app.view); |
|
|
| this.renderer = this.app.renderer; |
| this._drawRoadnet(roadnetData, districtMap); |
|
|
| |
| const spinner = wrapper.querySelector(".panel-spinner"); |
| if (spinner) spinner.classList.add("hidden"); |
| this._ready = true; |
| console.log(`[viz] panel ready — policy: ${this.policyName}`); |
| }); |
| } |
|
|
| _drawRoadnet(roadnetJson, districtMap) { |
| this.nodes = {}; |
| this.edges = {}; |
| this.trafficLightsG = {}; |
|
|
| const vp = new Viewport.Viewport({ |
| screenWidth: this.app.renderer.width, |
| screenHeight: this.app.renderer.height, |
| interaction: this.app.renderer.plugins.interaction, |
| }); |
| vp.drag().pinch().wheel().decelerate(); |
| this.app.stage.addChild(vp); |
| this.viewport = vp; |
|
|
| this.simulatorContainer = new PIXI.Container(); |
| vp.addChild(this.simulatorContainer); |
|
|
| const roadnet = roadnetJson.static || roadnetJson; |
| const nodeList = roadnet.node || roadnet.nodes || []; |
| const edgeList = roadnet.edge || roadnet.edges || []; |
| console.log(`[viz] _drawRoadnet — policy: ${this.policyName}, nodes: ${nodeList.length}, edges: ${edgeList.length}`); |
| for (const node of nodeList) { |
| |
| this.nodes[node.id] = { ...node, point: new Point(transCoord(node.point)) }; |
| } |
| for (const edge of edgeList) { |
| |
| this.edges[edge.id] = { |
| ...edge, |
| from: this.nodes[edge.from], |
| to: this.nodes[edge.to], |
| points: (edge.points || []).map((p) => new Point(transCoord(p))), |
| }; |
| } |
|
|
| this.trafficLightContainer = new PIXI.particles.ParticleContainer( |
| MAX_TRAFFIC_LIGHT_NUM, { tint: true } |
| ); |
|
|
| const mapGraphics = new PIXI.Graphics(); |
| this.simulatorContainer.addChild(mapGraphics); |
|
|
| for (const nodeId in this.nodes) { |
| if (!this.nodes[nodeId].virtual) drawNode(this.nodes[nodeId], mapGraphics); |
| } |
| for (const edgeId in this.edges) { |
| this._drawEdge(this.edges[edgeId], mapGraphics); |
| } |
|
|
| this._drawOverlayLayers(districtMap); |
| if (this.legendEl) { |
| this.legendEl.classList.toggle("hidden", !districtMap); |
| } |
|
|
| const bounds = this.simulatorContainer.getBounds(); |
| this.simulatorContainer.pivot.set( |
| bounds.x + bounds.width / 2, |
| bounds.y + bounds.height / 2 |
| ); |
| this.simulatorContainer.position.set( |
| this.renderer.width / 2, |
| this.renderer.height / 2 |
| ); |
| this.simulatorContainer.addChild(this.trafficLightContainer); |
|
|
| |
| const carG = new PIXI.Graphics(); |
| carG.lineStyle(0); |
| carG.beginFill(0xffffff, 0.8); |
| carG.drawRect(0, 0, CAR_LENGTH, CAR_WIDTH); |
| const carTexture = this.renderer.generateTexture(carG); |
|
|
| this.carContainer = new PIXI.particles.ParticleContainer( |
| NUM_CAR_POOL, { rotation: true, tint: true } |
| ); |
| this.turnSignalContainer = new PIXI.particles.ParticleContainer( |
| NUM_CAR_POOL, { rotation: true } |
| ); |
| this.simulatorContainer.addChild(this.carContainer); |
| this.simulatorContainer.addChild(this.turnSignalContainer); |
|
|
| this.carPool = []; |
| for (let i = 0; i < NUM_CAR_POOL; i++) { |
| const car = new PIXI.Sprite(carTexture); |
| car.anchor.set(1, 0.5); |
| this.carPool.push(car); |
| } |
| } |
|
|
| _drawEdge(edge, graphics) { |
| if (!edge.from || !edge.to || !edge.points || edge.points.length < 2) return; |
|
|
| const roadWidth = (edge.laneWidths || []).reduce((s, w) => s + w, 0); |
| let prevPointBOffset = null; |
|
|
| const lightG = new PIXI.Graphics(); |
| lightG.lineStyle(TRAFFIC_LIGHT_WIDTH, 0xffffff); |
| lightG.moveTo(0, 0); |
| lightG.lineTo(1, 0); |
| const lightTexture = this.renderer.generateTexture(lightG); |
|
|
| for (let i = 1; i < edge.points.length; i++) { |
| let pointA, pointAOffset, pointB, pointBOffset; |
|
|
| if (i === 1) { |
| pointA = edge.points[0].moveAlongDirectTo(edge.points[1], edge.from.virtual ? 0 : (edge.from.width || 0)); |
| pointAOffset = edge.points[0].directTo(edge.points[1]).rotate(ROTATE); |
| } else { |
| pointA = edge.points[i - 1]; |
| pointAOffset = prevPointBOffset; |
| } |
| if (i === edge.points.length - 1) { |
| pointB = edge.points[i].moveAlongDirectTo(edge.points[i - 1], edge.to.virtual ? 0 : (edge.to.width || 0)); |
| pointBOffset = edge.points[i - 1].directTo(edge.points[i]).rotate(ROTATE); |
| } else { |
| pointB = edge.points[i]; |
| pointBOffset = edge.points[i - 1].directTo(edge.points[i + 1]).rotate(ROTATE); |
| } |
| prevPointBOffset = pointBOffset; |
|
|
| |
| if (i === edge.points.length - 1 && !edge.to.virtual) { |
| const lights = []; |
| let prevOffset = 0; |
| let offset = 0; |
| const laneWidths = edge.laneWidths || []; |
| for (let lane = 0; lane < (edge.nLane || 0); lane++) { |
| offset += laneWidths[lane] || 0; |
| const light = new PIXI.Sprite(lightTexture); |
| light.anchor.set(0, 0.5); |
| light.scale.set(offset - prevOffset, 1); |
| const pt = pointB.moveAlong(pointBOffset, prevOffset); |
| light.position.set(pt.x, pt.y); |
| light.rotation = pointBOffset.getAngleInRadians(); |
| lights.push(light); |
| prevOffset = offset; |
| this.trafficLightContainer.addChild(light); |
| } |
| this.trafficLightsG[edge.id] = lights; |
| } |
|
|
| const pointA1 = pointA.moveAlong(pointAOffset, roadWidth); |
| const pointB1 = pointB.moveAlong(pointBOffset, roadWidth); |
|
|
| graphics.lineStyle(LANE_BORDER_WIDTH, LANE_BORDER_COLOR, 1); |
| graphics.moveTo(pointA.x, pointA.y); |
| graphics.lineTo(pointB.x, pointB.y); |
|
|
| graphics.lineStyle(0); |
| graphics.beginFill(LANE_COLOR); |
| graphics.drawPolygon([ |
| pointA.x, pointA.y, |
| pointB.x, pointB.y, |
| pointB1.x, pointB1.y, |
| pointA1.x, pointA1.y, |
| ]); |
| graphics.endFill(); |
| } |
| } |
|
|
| _drawOverlayLayers(districtMap) { |
| if (this.overlayContainer) { |
| this.overlayContainer.destroy(true); |
| this.overlayContainer = null; |
| } |
| if (!districtMap || !districtMap.intersection_to_district) return; |
|
|
| this.overlayContainer = new PIXI.Container(); |
| this.simulatorContainer.addChild(this.overlayContainer); |
|
|
| const overlayGraphics = new PIXI.Graphics(); |
| this.overlayContainer.addChild(overlayGraphics); |
|
|
| const intersectionToDistrict = districtMap.intersection_to_district || {}; |
| const gatewayNodeIds = new Set(districtMap.gateway_intersections || []); |
| const gatewayEdgeIds = new Set(districtMap.gateway_roads || []); |
| const entryRoadIds = new Set(); |
| const exitRoadIds = new Set(); |
| for (const district of districtMap.districts || []) { |
| for (const roadId of district.entry_roads || []) entryRoadIds.add(roadId); |
| for (const roadId of district.exit_roads || []) exitRoadIds.add(roadId); |
| } |
|
|
| const districtToPoints = {}; |
| for (const nodeId in this.nodes) { |
| const districtId = intersectionToDistrict[nodeId]; |
| if (!districtId) continue; |
| if (!districtToPoints[districtId]) districtToPoints[districtId] = []; |
| districtToPoints[districtId].push(this.nodes[nodeId].point); |
| } |
| drawDistrictRegionFills(overlayGraphics, districtToPoints); |
|
|
| for (const edgeId in this.edges) { |
| const edge = this.edges[edgeId]; |
| const fromDistrict = intersectionToDistrict[edge.from.id]; |
| const toDistrict = intersectionToDistrict[edge.to.id]; |
| if (fromDistrict && toDistrict) { |
| if (fromDistrict === toDistrict) { |
| drawEdgePolyline(overlayGraphics, edge, 1.2, getDistrictColor(fromDistrict), 0.35); |
| } else { |
| drawEdgePolyline(overlayGraphics, edge, 1.8, DISTRICT_BORDER_COLOR, 0.55); |
| } |
| } |
|
|
| const fromGateway = gatewayNodeIds.has(edge.from.id) || String(edge.from.id).startsWith("g_"); |
| const toGateway = gatewayNodeIds.has(edge.to.id) || String(edge.to.id).startsWith("g_"); |
| const looksGateway = gatewayEdgeIds.has(edgeId) || String(edgeId).includes("_g_") || String(edgeId).startsWith("r_g_"); |
| if (!fromGateway && !toGateway && !looksGateway) continue; |
|
|
| const isEntry = entryRoadIds.has(edgeId) || (fromGateway && !toGateway); |
| const isExit = exitRoadIds.has(edgeId) || (!fromGateway && toGateway); |
| const color = isEntry ? ENTRY_EDGE_COLOR : (isExit ? EXIT_EDGE_COLOR : GATEWAY_EDGE_COLOR); |
| const width = isEntry || isExit ? 5.2 : 4.0; |
| const alpha = isEntry || isExit ? 0.72 : 0.56; |
| drawEdgePolyline(overlayGraphics, edge, width, color, alpha); |
| } |
|
|
| for (const nodeId in this.nodes) { |
| const districtId = intersectionToDistrict[nodeId]; |
| if (districtId) { |
| const point = this.nodes[nodeId].point; |
| overlayGraphics.beginFill(getDistrictColor(districtId), 0.48); |
| overlayGraphics.drawCircle(point.x, point.y, 2.0); |
| overlayGraphics.endFill(); |
| } |
| if (gatewayNodeIds.has(nodeId) || String(nodeId).startsWith("g_")) { |
| const point = this.nodes[nodeId].point; |
| overlayGraphics.lineStyle(0); |
| overlayGraphics.beginFill(GATEWAY_NODE_COLOR, 0.22); |
| overlayGraphics.drawCircle(point.x, point.y, 26.0); |
| overlayGraphics.endFill(); |
| overlayGraphics.lineStyle(2.0, 0x1f2937, 0.95); |
| overlayGraphics.beginFill(GATEWAY_NODE_COLOR, 0.9); |
| overlayGraphics.drawCircle(point.x, point.y, 9.0); |
| overlayGraphics.endFill(); |
| overlayGraphics.lineStyle(0); |
| overlayGraphics.beginFill(0xffffff, 0.9); |
| overlayGraphics.drawCircle(point.x, point.y, 3.6); |
| overlayGraphics.endFill(); |
| } |
| } |
| } |
|
|
| drawStep(step) { |
| if (!this._ready || step >= this.lines.length) return; |
| if (step === 0 && !this._loggedFirstStep) { |
| this._loggedFirstStep = true; |
| console.log(`[viz] first drawStep — policy: ${this.policyName}, step 0 of ${this.lines.length}`); |
| } |
|
|
| const line = this.lines[step]; |
| const semiIdx = line.indexOf(";"); |
| if (semiIdx < 0) return; |
| const carPart = line.substring(0, semiIdx); |
| const tlPart = line.substring(semiIdx + 1); |
|
|
| |
| for (const tlEntry of tlPart.split(",")) { |
| const parts = tlEntry.trim().split(" "); |
| const edgeId = parts[0]; |
| const statuses = parts.slice(1); |
| const lights = this.trafficLightsG[edgeId]; |
| if (!lights) continue; |
| for (let j = 0; j < statuses.length && j < lights.length; j++) { |
| const s = statuses[j]; |
| if (s === "i") { lights[j].alpha = 0; continue; } |
| lights[j].alpha = 1; |
| lights[j].tint = s === "g" ? LIGHT_GREEN : LIGHT_RED; |
| } |
| } |
|
|
| |
| this.carContainer.removeChildren(); |
| const carEntries = carPart.split(",").filter((e) => e.trim().length > 0); |
| for (let i = 0; i < carEntries.length && i < this.carPool.length; i++) { |
| const parts = carEntries[i].trim().split(" "); |
| if (parts.length < 7) continue; |
| const [x, y, rot, _id, _lane, length, width] = parts; |
| const pos = transCoord([parseFloat(x), parseFloat(y)]); |
| const car = this.carPool[i]; |
| car.position.set(pos[0], pos[1]); |
| car.rotation = 2 * Math.PI - parseFloat(rot); |
| car.tint = CAR_COLORS[stringHash(_id || String(i)) % CAR_COLORS.length]; |
| car.width = parseFloat(length); |
| car.height = parseFloat(width); |
| this.carContainer.addChild(car); |
| } |
| } |
|
|
| destroy() { |
| this._ready = false; |
| if (this.app) { |
| this.app.destroy(true, { children: true }); |
| this.app = null; |
| } |
| } |
| } |
|
|
| |
|
|
| function startAnimation() { |
| if (animFrameId !== null) cancelAnimationFrame(animFrameId); |
| let lastTs = 0; |
|
|
| function loop(ts) { |
| animFrameId = requestAnimationFrame(loop); |
| const delta = Math.min((ts - lastTs) / 16.67, 3); |
| lastTs = ts; |
|
|
| if (!paused && activePanels.length > 0) { |
| for (const panel of activePanels) panel.drawStep(globalStep); |
|
|
| frameElapsed += delta; |
| const framesPerStep = Math.max(1, Math.round(1 / replaySpeed ** 2)); |
| if (frameElapsed >= framesPerStep) { |
| frameElapsed = 0; |
| globalStep = (globalStep + 1) % (totalSteps || 1); |
| syncScrubber(); |
| } |
| } |
| } |
|
|
| animFrameId = requestAnimationFrame(loop); |
| } |
|
|
| function stopAnimation() { |
| if (animFrameId !== null) { |
| cancelAnimationFrame(animFrameId); |
| animFrameId = null; |
| } |
| } |
|
|
| |
|
|
| function setupPlaybackControls() { |
| pauseBtn.addEventListener("click", () => { |
| paused = !paused; |
| pauseBtn.textContent = paused ? "Play" : "Pause"; |
| }); |
|
|
| scrubber.addEventListener("input", () => { |
| globalStep = parseInt(scrubber.value, 10); |
| frameElapsed = 0; |
| for (const p of activePanels) p.drawStep(globalStep); |
| stepDisplay.textContent = `${globalStep + 1} / ${totalSteps}`; |
| }); |
|
|
| speedDown.addEventListener("click", () => setSpeed(Math.max(0.1, replaySpeed - 0.1))); |
| speedUp.addEventListener("click", () => setSpeed(Math.min(2.0, replaySpeed + 0.1))); |
|
|
| document.addEventListener("keydown", (e) => { |
| if (e.code === "Space") { e.preventDefault(); pauseBtn.click(); } |
| else if (e.code === "ArrowRight") stepForward(); |
| else if (e.code === "ArrowLeft") stepBackward(); |
| }); |
| } |
|
|
| function setSpeed(s) { |
| replaySpeed = +s.toFixed(1); |
| speedLabel.textContent = `${replaySpeed.toFixed(1)}x`; |
| } |
|
|
| function stepForward() { globalStep = (globalStep + 1) % (totalSteps || 1); syncScrubber(); for (const p of activePanels) p.drawStep(globalStep); } |
| function stepBackward() { globalStep = ((globalStep - 1) + (totalSteps || 1)) % (totalSteps || 1); syncScrubber(); for (const p of activePanels) p.drawStep(globalStep); } |
|
|
| function syncScrubber() { |
| scrubber.value = globalStep; |
| stepDisplay.textContent = `${globalStep + 1} / ${totalSteps}`; |
| } |
|
|
| |
|
|
| const _progressIntervals = {}; |
|
|
| function initProgress(policies) { |
| progressSection.style.display = "block"; |
| progressSection.innerHTML = ""; |
|
|
| const h = document.createElement("h3"); |
| h.textContent = "Running simulations..."; |
| progressSection.appendChild(h); |
|
|
| for (const key of policies) { |
| const meta = POLICY_META[key] || { label: key, color: 0x888888 }; |
| const colorHex = "#" + meta.color.toString(16).padStart(6, "0"); |
|
|
| const row = document.createElement("div"); |
| row.className = "progress-policy-row"; |
|
|
| |
| const topLine = document.createElement("div"); |
| topLine.className = "progress-policy-top"; |
|
|
| const dot = document.createElement("span"); |
| dot.className = "progress-policy-dot"; |
| dot.style.background = colorHex; |
|
|
| const name = document.createElement("span"); |
| name.className = "progress-policy-name"; |
| name.textContent = meta.label; |
|
|
| const pct = document.createElement("span"); |
| pct.className = "progress-policy-pct"; |
| pct.id = `progress-pct-${key}`; |
| pct.textContent = "0%"; |
|
|
| topLine.appendChild(dot); |
| topLine.appendChild(name); |
| topLine.appendChild(pct); |
|
|
| |
| const track = document.createElement("div"); |
| track.className = "progress-policy-bar-track"; |
| const fill = document.createElement("div"); |
| fill.className = "progress-policy-bar-fill"; |
| fill.id = `progress-fill-${key}`; |
| fill.style.background = colorHex; |
| track.appendChild(fill); |
|
|
| row.appendChild(topLine); |
| row.appendChild(track); |
| progressSection.appendChild(row); |
| } |
| } |
|
|
| function markPolicyRunning(key) { |
| |
| let pct = 0; |
| _progressIntervals[key] = setInterval(() => { |
| pct = Math.min(88, pct + 0.8); |
| _setBar(key, pct); |
| }, 100); |
| } |
|
|
| function markPolicyCached(key) { |
| |
| _setBar(key, 100); |
| const pctEl = document.getElementById(`progress-pct-${key}`); |
| if (pctEl) { |
| pctEl.textContent = "✓ cached"; |
| pctEl.className = "progress-policy-pct done"; |
| } |
| } |
|
|
| function markPolicyDone(key, elapsedMs, status) { |
| clearInterval(_progressIntervals[key]); |
| delete _progressIntervals[key]; |
| _setBar(key, 100, status); |
| const pctEl = document.getElementById(`progress-pct-${key}`); |
| if (pctEl) { |
| const secs = (elapsedMs / 1000).toFixed(1); |
| pctEl.textContent = status === "done" ? `✓ ${secs}s` : `✗ ${secs}s`; |
| pctEl.className = `progress-policy-pct ${status}`; |
| } |
| } |
|
|
| function _setBar(key, pct, status) { |
| const fill = document.getElementById(`progress-fill-${key}`); |
| const label = document.getElementById(`progress-pct-${key}`); |
| if (fill) fill.style.width = `${pct.toFixed(1)}%`; |
| if (label && !status) label.textContent = `${Math.round(pct)}%`; |
| if (fill && status === "error") fill.style.background = "#ef4444"; |
| } |
|
|
| |
|
|
| |
|
|
| function setupSidebarResize() { |
| const sidebar = document.getElementById("sidebar"); |
| const handle = document.getElementById("sidebar-resize-handle"); |
| const MIN_W = 180, MAX_W = 480; |
|
|
| const saved = localStorage.getItem("sidebarWidth"); |
| if (saved) sidebar.style.width = parseInt(saved, 10) + "px"; |
|
|
| let dragging = false, startX = 0, startW = 0; |
|
|
| handle.addEventListener("mousedown", (e) => { |
| dragging = true; |
| startX = e.clientX; |
| startW = sidebar.offsetWidth; |
| handle.classList.add("dragging"); |
| document.body.style.cursor = "col-resize"; |
| document.body.style.userSelect = "none"; |
| e.preventDefault(); |
| }); |
|
|
| document.addEventListener("mousemove", (e) => { |
| if (!dragging) return; |
| const w = Math.min(MAX_W, Math.max(MIN_W, startW + (e.clientX - startX))); |
| sidebar.style.width = w + "px"; |
| }); |
|
|
| document.addEventListener("mouseup", () => { |
| if (!dragging) return; |
| dragging = false; |
| handle.classList.remove("dragging"); |
| document.body.style.cursor = ""; |
| document.body.style.userSelect = ""; |
| localStorage.setItem("sidebarWidth", sidebar.offsetWidth); |
| }); |
| } |
|
|
| |
|
|
| function transCoord(point) { |
| if (Array.isArray(point)) return [point[0], -point[1]]; |
| return [point.x, -point.y]; |
| } |
|
|
| function getDistrictColor(districtId) { |
| return DISTRICT_PALETTE[stringHash(districtId) % DISTRICT_PALETTE.length]; |
| } |
|
|
| function cross2D(o, a, b) { |
| return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); |
| } |
|
|
| function convexHull(points) { |
| if (!points || points.length <= 2) return points ? points.slice() : []; |
| const sorted = points |
| .slice() |
| .sort((p, q) => (p.x === q.x ? p.y - q.y : p.x - q.x)); |
| const lower = []; |
| for (const point of sorted) { |
| while (lower.length >= 2 && cross2D(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) { |
| lower.pop(); |
| } |
| lower.push(point); |
| } |
| const upper = []; |
| for (let i = sorted.length - 1; i >= 0; i--) { |
| const point = sorted[i]; |
| while (upper.length >= 2 && cross2D(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) { |
| upper.pop(); |
| } |
| upper.push(point); |
| } |
| lower.pop(); |
| upper.pop(); |
| return lower.concat(upper); |
| } |
|
|
| function drawDistrictRegionFills(graphics, districtToPoints) { |
| for (const districtId in districtToPoints) { |
| const pts = districtToPoints[districtId]; |
| if (!pts || pts.length === 0) continue; |
| const color = getDistrictColor(districtId); |
| if (pts.length >= 3) { |
| const hull = convexHull(pts); |
| if (hull.length >= 3) { |
| graphics.lineStyle(1.2, color, 0.28); |
| graphics.beginFill(color, 0.09); |
| graphics.moveTo(hull[0].x, hull[0].y); |
| for (let i = 1; i < hull.length; i++) graphics.lineTo(hull[i].x, hull[i].y); |
| graphics.lineTo(hull[0].x, hull[0].y); |
| graphics.endFill(); |
| continue; |
| } |
| } |
| if (pts.length === 2) { |
| graphics.lineStyle(16, color, 0.08); |
| graphics.drawLine(pts[0], pts[1]); |
| continue; |
| } |
| graphics.lineStyle(0); |
| graphics.beginFill(color, 0.1); |
| graphics.drawCircle(pts[0].x, pts[0].y, 16); |
| graphics.endFill(); |
| } |
| } |
|
|
| function drawEdgePolyline(graphics, edge, width, color, alpha) { |
| graphics.lineStyle(width, color, alpha); |
| for (let i = 1; i < edge.points.length; i++) { |
| graphics.drawLine(edge.points[i - 1], edge.points[i]); |
| } |
| } |
|
|
| function stringHash(str) { |
| let hash = 0, p = 127, pp = 1, m = 1e9 + 9; |
| for (let i = 0; i < str.length; i++) { |
| hash = (hash + str.charCodeAt(i) * pp) % m; |
| pp = (pp * p) % m; |
| } |
| return hash; |
| } |
|
|
| function drawNode(node, graphics) { |
| graphics.beginFill(LANE_COLOR); |
| const outline = node.outline || []; |
| for (let i = 0; i < outline.length; i += 2) { |
| const px = outline[i], py = -outline[i + 1]; |
| if (i === 0) graphics.moveTo(px, py); |
| else graphics.lineTo(px, py); |
| } |
| graphics.endFill(); |
| } |
|
|
| function showToast(msg, type = "info") { |
| const colors = { info: "#5b6cf9", warn: "#f59e0b", error: "#ef4444" }; |
| const toast = document.createElement("div"); |
| toast.style.cssText = ` |
| position: fixed; bottom: 20px; right: 20px; z-index: 9999; |
| background: ${colors[type] || colors.info}; color: #fff; |
| padding: 10px 16px; border-radius: 6px; font-size: 13px; |
| box-shadow: 0 4px 16px rgba(0,0,0,0.4); |
| animation: fadeInUp 0.2s ease; |
| `; |
| toast.textContent = msg; |
| document.body.appendChild(toast); |
| setTimeout(() => toast.remove(), 3500); |
| } |
|
|
| function pickMetric(metrics, keys) { |
| for (const key of keys) { |
| const value = metrics?.[key]; |
| if (typeof value === "number" && Number.isFinite(value)) return value; |
| } |
| return null; |
| } |
|
|
| function formatMetric(value, digits = 1) { |
| if (value == null || !Number.isFinite(value)) return "—"; |
| return Number(value).toFixed(digits); |
| } |
|
|
| function formatDuration(elapsedMs) { |
| const seconds = Number(elapsedMs || 0) / 1000; |
| return `${seconds.toFixed(1)}s`; |
| } |
|
|
| function diffMetric(value, baseline, lowerIsBetter) { |
| if (value == null || baseline == null) return null; |
| const delta = value - baseline; |
| const good = lowerIsBetter ? delta < 0 : delta > 0; |
| const neutral = Math.abs(delta) < 1e-9; |
| return { |
| className: neutral ? "neutral" : (good ? "good" : "bad"), |
| label: `${delta >= 0 ? "+" : ""}${delta.toFixed(1)} vs DQN`, |
| }; |
| } |
|
|
| function debugLog(message) { |
| const line = `[${new Date().toLocaleTimeString()}] ${message}`; |
| console.log("[frontend]", message); |
| if (!debugLogEl) return; |
| const entry = document.createElement("div"); |
| entry.textContent = line; |
| debugLogEl.prepend(entry); |
| while (debugLogEl.childNodes.length > 80) { |
| debugLogEl.removeChild(debugLogEl.lastChild); |
| } |
| } |
|
|
| |
| if (PIXI.Graphics && !PIXI.Graphics.prototype.drawLine) { |
| PIXI.Graphics.prototype.drawLine = function(pA, pB) { |
| this.moveTo(pA.x, pA.y); |
| this.lineTo(pB.x, pB.y); |
| }; |
| } |
|
|