CivicAI / dashboard /app.js
mahammadaftab's picture
Final updated
18438c3
/**
* 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));
}