Spaces:
Sleeping
Sleeping
| /** | |
| * 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 = '<div class="event-empty">No active events</div>'; | |
| return; | |
| } | |
| const negativeEvents = ['drought', 'pandemic_wave', 'recession', 'protest', 'natural_disaster']; | |
| container.innerHTML = events.map(e => { | |
| const cls = negativeEvents.includes(e) ? 'negative' : 'positive'; | |
| const icons = { | |
| drought: '🌵', pandemic_wave: '🦠', recession: '📉', | |
| protest: '✊', tech_boom: '🚀', natural_disaster: '🌊', trade_deal: '🤝', | |
| }; | |
| return `<div class="event-badge ${cls}">${icons[e] || '⚡'} ${e.replace(/_/g, ' ')}</div>`; | |
| }).join(''); | |
| } | |
| // ============================================================ | |
| // Debate UI | |
| // ============================================================ | |
| function updateDebate(debate) { | |
| const container = document.getElementById('debate-container'); | |
| if (!debate || !debate.messages) return; | |
| container.innerHTML = debate.messages.map(msg => { | |
| const voteCls = `vote-${msg.vote}`; | |
| const voteLabel = { approve: '✅ APPROVE', reject: '❌ REJECT', abstain: '⚪ ABSTAIN' }; | |
| return ` | |
| <div class="debate-msg"> | |
| <div class="debate-msg-header"> | |
| <span class="debate-agent">${msg.agent_role}</span> | |
| <span class="debate-vote ${voteCls}">${voteLabel[msg.vote] || msg.vote}</span> | |
| </div> | |
| <div class="debate-proposal">${msg.proposal}</div> | |
| <div class="debate-reasoning">${msg.reasoning}</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| // Consensus bar | |
| const bar = document.getElementById('consensus-bar'); | |
| bar.style.display = 'flex'; | |
| const pct = Math.round((debate.consensus_score || 0) * 100); | |
| document.getElementById('consensus-fill').style.width = pct + '%'; | |
| document.getElementById('consensus-pct').textContent = pct + '%'; | |
| // Auto-scroll | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| function clearDebate() { | |
| document.getElementById('debate-container').innerHTML = ` | |
| <div class="debate-empty"> | |
| <div class="debate-icon">🤖</div> | |
| <p>Run a step to see agents debate policies</p> | |
| </div>`; | |
| document.getElementById('consensus-bar').style.display = 'none'; | |
| } | |
| // ============================================================ | |
| // Policy Log | |
| // ============================================================ | |
| function addPolicyEntry(turn, reward, info) { | |
| const log = document.getElementById('policy-log'); | |
| if (log.querySelector('.policy-empty')) log.innerHTML = ''; | |
| const rewardCls = reward >= 0.5 ? 'reward-positive' : 'reward-negative'; | |
| const penalties = info && info.penalties ? Object.keys(info.penalties) : []; | |
| const penaltyStr = penalties.length ? ` ⚠ ${penalties.join(', ')}` : ''; | |
| const entry = document.createElement('div'); | |
| entry.className = 'policy-entry'; | |
| entry.innerHTML = ` | |
| <div class="policy-turn">Turn ${turn}</div> | |
| <div class="policy-reward ${rewardCls}">Reward: ${reward.toFixed(4)}${penaltyStr}</div> | |
| `; | |
| log.insertBefore(entry, log.firstChild); | |
| // Keep last 20 | |
| while (log.children.length > 20) log.removeChild(log.lastChild); | |
| } | |
| function clearPolicyLog() { | |
| document.getElementById('policy-log').innerHTML = '<div class="policy-empty">No policies executed yet</div>'; | |
| } | |
| // ============================================================ | |
| // Emergent Insights | |
| // ============================================================ | |
| function updateInsights(summary) { | |
| if (!summary) return; | |
| const container = document.getElementById('insights-log'); | |
| let html = ''; | |
| if (summary.key_insights && summary.key_insights.length) { | |
| html += summary.key_insights.map(i => | |
| `<div class="insight-badge warning">🧠 ${i}</div>` | |
| ).join(''); | |
| } | |
| if (summary.patterns && summary.patterns.length) { | |
| html += summary.patterns.map(p => | |
| `<div class="insight-badge critical">📊 ${p}</div>` | |
| ).join(''); | |
| } | |
| if (!html) { | |
| html = '<p class="insight-empty">Insights will appear as patterns emerge</p>'; | |
| } | |
| container.innerHTML = html; | |
| } | |
| function clearInsights() { | |
| document.getElementById('insights-log').innerHTML = | |
| '<p class="insight-empty">Insights will appear as patterns emerge</p>'; | |
| } | |
| // ============================================================ | |
| // 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)); | |
| } | |