/** * CivicAI Dashboard — Interactive Controller * Connects to FastAPI backend and renders live simulation data. */ // ============================================================ // State // ============================================================ const API = window.location.origin; let autoplayInterval = null; let isAutoplay = false; let totalReward = 0; let stepCount = 0; // Chart data histories const history = { employment: [], inflation: [], satisfaction: [], health: [], crime: [], rewards: [], gini: [], unrest: [], cooperation: [], }; // Charts let mainChart = null; let rewardChart = null; let emergentChart = null; let activeChartId = 'main'; // ============================================================ // Init // ============================================================ document.addEventListener('DOMContentLoaded', async () => { initCharts(); document.getElementById('speed-slider').addEventListener('input', (e) => { document.getElementById('speed-label').textContent = e.target.value + 'ms'; if (isAutoplay) { clearInterval(autoplayInterval); autoplayInterval = setInterval(runStep, parseInt(e.target.value)); } }); // Auto-initialize on page load so Step/Auto work immediately await resetEnv(true); }); // ============================================================ // API Calls // ============================================================ async function resetEnv(silent = false) { const taskId = document.getElementById('task-select').value; const turnsInput = document.getElementById('turns-input'); let maxSteps = parseInt(turnsInput.value) || 50; // Validate if (maxSteps < 5) { maxSteps = 5; turnsInput.value = 5; } if (maxSteps > 200) { maxSteps = 200; turnsInput.value = 200; } document.getElementById('turn-max-display').textContent = maxSteps; stopAutoplay(); totalReward = 0; stepCount = 0; clearHistories(); setStatus('Initializing...', 'running'); try { const res = await fetch(`${API}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId, max_steps: maxSteps }), }); const data = await res.json(); if (!silent) { updateDashboard(data.observation, null, null); } // Fetch full state to get emergent metrics immediately const stateRes = await fetch(`${API}/state`); const state = await stateRes.json(); if (state && state.emergent && !silent) { const em = state.emergent; document.getElementById('em-gini').textContent = em.wealth_inequality.toFixed(2); document.getElementById('em-unrest').textContent = em.social_unrest.toFixed(2); document.getElementById('em-coop').textContent = em.cooperation_index.toFixed(2); setBar('em-gini-bar', em.wealth_inequality * 100); setBar('em-unrest-bar', em.social_unrest * 100); setBar('em-coop-bar', em.cooperation_index * 100); } setStatus('Ready', ''); document.getElementById('total-reward').textContent = '0.000'; document.getElementById('turn-number').textContent = '0'; clearDebate(); clearPolicyLog(); clearInsights(); } catch (err) { console.error('Reset failed:', err); setStatus('Error', 'done'); } } async function runStep() { try { setStatus('Running...', 'running'); const res = await fetch(`${API}/step`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ use_agents: true }), }); if (!res.ok) { const errData = await res.json().catch(() => ({})); const detail = errData.detail || `HTTP ${res.status}`; console.error('Step error:', detail); setStatus(`Error: ${detail}`, 'done'); stopAutoplay(); // If env is not initialized, auto-reset and retry once if (res.status === 400 || res.status === 500) { console.warn('Auto-resetting environment and retrying...'); await resetEnv(); } return; } const data = await res.json(); stepCount++; totalReward += data.reward; updateDashboard(data.observation, data.reward, data.info); document.getElementById('total-reward').textContent = totalReward.toFixed(3); if (data.done) { stopAutoplay(); setStatus('Episode Done ✅', 'done'); } else { setStatus('Ready', ''); } } catch (err) { console.error('Step failed:', err); setStatus('Network Error', 'done'); stopAutoplay(); } } async function runFullEpisode() { const turnsInput = document.getElementById('turns-input'); const taskId = document.getElementById('task-select').value; let maxSteps = parseInt(turnsInput.value) || 50; if (maxSteps < 5) maxSteps = 5; if (maxSteps > 200) maxSteps = 200; turnsInput.value = maxSteps; // Lock UI immediately setStatus('Running...', 'running'); document.getElementById('turn-max-display').textContent = maxSteps; stopAutoplay(); totalReward = 0; stepCount = 0; clearHistories(); clearDebate(); clearPolicyLog(); clearInsights(); // Reset the environment first try { const resetRes = await fetch(`${API}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId, max_steps: maxSteps }), }); if (!resetRes.ok) throw new Error(`Reset failed: HTTP ${resetRes.status}`); const resetData = await resetRes.json(); updateDashboard(resetData.observation, null, null); document.getElementById('turn-number').textContent = '0'; document.getElementById('total-reward').textContent = '0.000'; } catch (err) { console.error('Reset failed:', err); setStatus('Reset Error', 'done'); return; } // Run steps one-by-one, updating the turn counter live const speed = 200; // ms delay between visual updates for (let i = 0; i < maxSteps; i++) { try { const res = await fetch(`${API}/step`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ use_agents: true }), }); if (!res.ok) { const errData = await res.json().catch(() => ({})); console.error('Step error:', errData.detail || res.status); setStatus(`Error at turn ${i + 1}`, 'done'); return; } const data = await res.json(); stepCount++; totalReward += data.reward; // Live turn counter: TURN X / maxSteps document.getElementById('turn-number').textContent = stepCount; document.getElementById('total-reward').textContent = totalReward.toFixed(3); updateDashboard(data.observation, data.reward, data.info); if (data.done) break; await sleep(speed); } catch (err) { console.error('Step failed:', err); setStatus('Network Error', 'done'); return; } } setStatus('Done ✅', 'done'); } // ============================================================ // UI Updates // ============================================================ function updateDashboard(obs, reward, info) { if (!obs) return; // Turn document.getElementById('turn-number').textContent = obs.turn; // Stats animateValue('val-population', obs.population.toLocaleString()); animateValue('val-gdp', `$${obs.gdp.toFixed(1)}B`); animateValue('val-employment', (obs.employment_rate * 100).toFixed(1) + '%'); animateValue('val-inflation', (obs.inflation * 100).toFixed(1) + '%'); animateValue('val-satisfaction', (obs.public_satisfaction * 100).toFixed(1) + '%'); animateValue('val-health', (obs.health_index * 100).toFixed(1) + '%'); animateValue('val-crime', (obs.crime_rate * 100).toFixed(1) + '%'); animateValue('val-budget', (obs.budget_balance * 100).toFixed(1) + '%'); // Bars setBar('bar-employment', obs.employment_rate * 100); setBar('bar-inflation', Math.min(obs.inflation * 1000, 100)); setBar('bar-satisfaction', obs.public_satisfaction * 100); setBar('bar-health', obs.health_index * 100); setBar('bar-crime', obs.crime_rate * 200); // Resources const res = obs.resources || {}; setResource('food', res.food); setResource('energy', res.energy); setResource('medical', res.medical); setResource('infra', res.infrastructure); // Events updateEvents(obs.active_events || []); // History history.employment.push(obs.employment_rate); history.inflation.push(obs.inflation); history.satisfaction.push(obs.public_satisfaction); history.health.push(obs.health_index); history.crime.push(obs.crime_rate); if (reward !== null) { history.rewards.push(reward); } // Emergent if (info && info.emergent) { const em = info.emergent; document.getElementById('em-gini').textContent = (em.wealth_inequality || 0).toFixed(2); document.getElementById('em-unrest').textContent = (em.social_unrest || 0).toFixed(2); document.getElementById('em-coop').textContent = (em.cooperation_index || 0).toFixed(2); setBar('em-gini-bar', (em.wealth_inequality || 0) * 100); setBar('em-unrest-bar', (em.social_unrest || 0) * 100); setBar('em-coop-bar', (em.cooperation_index || 0) * 100); history.gini.push(em.wealth_inequality || 0); history.unrest.push(em.social_unrest || 0); history.cooperation.push(em.cooperation_index || 0); } // Debate if (info && info.debate) { updateDebate(info.debate); } // Policy log if (reward !== null) { addPolicyEntry(obs.turn, reward, info); } // Emergent insights if (info && info.emergent_summary) { updateInsights(info.emergent_summary); } // Update charts updateCharts(); } function animateValue(elementId, newValue) { const el = document.getElementById(elementId); if (!el) return; el.style.transition = 'transform 0.2s, color 0.3s'; el.style.transform = 'scale(1.05)'; el.textContent = newValue; setTimeout(() => { el.style.transform = 'scale(1)'; }, 200); } function setBar(id, pct) { const el = document.getElementById(id); if (el) el.style.width = Math.max(0, Math.min(100, pct)) + '%'; } function setResource(type, value) { if (value === undefined) return; const pct = Math.round(value * 100); document.getElementById(`res-${type}`).style.width = pct + '%'; document.getElementById(`pct-${type}`).textContent = pct + '%'; } function updateEvents(events) { const container = document.getElementById('events-list'); if (!events.length) { container.innerHTML = '
Run a step to see agents debate policies
Insights will appear as patterns emerge
'; } container.innerHTML = html; } function clearInsights() { document.getElementById('insights-log').innerHTML = 'Insights will appear as patterns emerge
'; } // ============================================================ // Charts // ============================================================ function initCharts() { const chartDefaults = { responsive: true, maintainAspectRatio: false, animation: { duration: 300 }, scales: { x: { grid: { color: 'rgba(71,85,105,0.2)' }, ticks: { color: '#64748b', font: { family: 'Inter', size: 10 } }, }, y: { grid: { color: 'rgba(71,85,105,0.2)' }, ticks: { color: '#64748b', font: { family: 'Inter', size: 10 } }, min: 0, max: 1, }, }, plugins: { legend: { labels: { color: '#94a3b8', font: { family: 'Inter', size: 11 }, boxWidth: 12, padding: 12 }, }, }, }; // Main chart mainChart = new Chart(document.getElementById('main-chart'), { type: 'line', data: { labels: [], datasets: [ { label: 'Employment', data: [], borderColor: '#06b6d4', backgroundColor: 'rgba(6,182,212,0.1)', fill: true, tension: 0.3, pointRadius: 0 }, { label: 'Satisfaction', data: [], borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.1)', fill: true, tension: 0.3, pointRadius: 0 }, { label: 'Health', data: [], borderColor: '#a855f7', tension: 0.3, pointRadius: 0 }, { label: 'Crime', data: [], borderColor: '#ef4444', tension: 0.3, pointRadius: 0 }, { label: 'Inflation', data: [], borderColor: '#f97316', tension: 0.3, pointRadius: 0 }, ], }, options: { ...chartDefaults }, }); // Reward chart rewardChart = new Chart(document.getElementById('reward-chart'), { type: 'line', data: { labels: [], datasets: [ { label: 'Reward', data: [], borderColor: '#06b6d4', backgroundColor: 'rgba(6,182,212,0.15)', fill: true, tension: 0.3, pointRadius: 2, pointBackgroundColor: '#06b6d4' }, ], }, options: { ...chartDefaults }, }); // Emergent chart emergentChart = new Chart(document.getElementById('emergent-chart'), { type: 'line', data: { labels: [], datasets: [ { label: 'Gini (Inequality)', data: [], borderColor: '#f97316', tension: 0.3, pointRadius: 0 }, { label: 'Social Unrest', data: [], borderColor: '#ef4444', tension: 0.3, pointRadius: 0 }, { label: 'Cooperation', data: [], borderColor: '#10b981', tension: 0.3, pointRadius: 0 }, ], }, options: { ...chartDefaults }, }); } function updateCharts() { const labels = history.employment.map((_, i) => i); // Main mainChart.data.labels = labels; mainChart.data.datasets[0].data = history.employment; mainChart.data.datasets[1].data = history.satisfaction; mainChart.data.datasets[2].data = history.health; mainChart.data.datasets[3].data = history.crime; mainChart.data.datasets[4].data = history.inflation; mainChart.update('none'); // Reward const rLabels = history.rewards.map((_, i) => i); rewardChart.data.labels = rLabels; rewardChart.data.datasets[0].data = history.rewards; rewardChart.update('none'); // Emergent const eLabels = history.gini.map((_, i) => i); emergentChart.data.labels = eLabels; emergentChart.data.datasets[0].data = history.gini; emergentChart.data.datasets[1].data = history.unrest; emergentChart.data.datasets[2].data = history.cooperation; emergentChart.update('none'); } function switchChart(chartId) { activeChartId = chartId; document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active')); document.querySelector(`.chart-tab[data-chart="${chartId}"]`).classList.add('active'); document.getElementById('main-chart').style.display = chartId === 'main' ? 'block' : 'none'; document.getElementById('reward-chart').style.display = chartId === 'reward' ? 'block' : 'none'; document.getElementById('emergent-chart').style.display = chartId === 'emergent' ? 'block' : 'none'; } // ============================================================ // Autoplay // ============================================================ function toggleAutoplay() { if (isAutoplay) { stopAutoplay(); } else { isAutoplay = true; const speed = parseInt(document.getElementById('speed-slider').value); autoplayInterval = setInterval(runStep, speed); document.getElementById('btn-autoplay').classList.add('active'); document.getElementById('btn-autoplay').textContent = '⏹ Stop'; } } function stopAutoplay() { isAutoplay = false; if (autoplayInterval) clearInterval(autoplayInterval); autoplayInterval = null; document.getElementById('btn-autoplay').classList.remove('active'); document.getElementById('btn-autoplay').textContent = '⏩ Auto'; } // ============================================================ // Helpers // ============================================================ function setStatus(text, cls) { document.getElementById('status-text').textContent = text; const badge = document.getElementById('status-badge'); badge.className = 'status-badge ' + (cls || ''); // Disable inputs when running const isRunning = cls === 'running'; const turnsInput = document.getElementById('turns-input'); const taskSelect = document.getElementById('task-select'); const btnStartNav = document.getElementById('btn-start-nav'); if (turnsInput) turnsInput.disabled = isRunning; if (taskSelect) taskSelect.disabled = isRunning; if (btnStartNav) btnStartNav.disabled = isRunning; } function clearHistories() { Object.keys(history).forEach(k => history[k] = []); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }