/** * GridMind-RL Dashboard — Premium Chart.js real-time visualization * Polls /dashboard/api/state every 500ms and updates all charts + KPIs. */ 'use strict'; // ── Config ────────────────────────────────────────────────────────────────── const POLL_MS = 500; const EPISODE_STEPS = 96; // 24h × 4 steps/h (15-min) const HISTORY_LEN = EPISODE_STEPS; const CURVE_POINTS = 24; // hourly downsample (EpisodeSteps/4) const API_BASE = '/dashboard/api'; const TASK_NAMES = { 1: 'Task 1 — Cost Minimization (Easy)', 2: 'Task 2 — Temperature Management (Medium)', 3: 'Task 3 — Full Demand Response (Hard)', }; let currentBuilding = 0; let pollTimer = null; let connected = false; // ── Chart.js Premium Theme ────────────────────────────────────────────────── Chart.defaults.color = '#5a6478'; Chart.defaults.borderColor = 'rgba(255,255,255,0.03)'; Chart.defaults.font.family = "'Inter', -apple-system, system-ui, sans-serif"; Chart.defaults.font.size = 11; Chart.defaults.font.weight = 400; Chart.defaults.plugins.legend.display = false; Chart.defaults.animation.duration = 350; Chart.defaults.animation.easing = 'easeOutQuart'; Chart.defaults.elements.line.borderCapStyle = 'round'; Chart.defaults.elements.line.borderJoinStyle = 'round'; const C = { blue: '#5b9cf6', green: '#4ade80', amber: '#f5a623', red: '#f06e6e', purple: '#a78bfa', cyan: '#34d4e4', orange: '#fb923c', teal: '#2dd4bf', rose: '#fb7185', grid: 'rgba(255,255,255,0.025)', surface: '#1a1f2e', }; function rgba(hex, alpha) { const r = parseInt(hex.slice(1,3), 16); const g = parseInt(hex.slice(3,5), 16); const b = parseInt(hex.slice(5,7), 16); return `rgba(${r},${g},${b},${alpha})`; } // ── Chart factory — refined styling ────────────────────────────────────────── function makeLineChart(id, labels, datasets, opts = {}) { const ctx = document.getElementById(id); if (!ctx) return null; return new Chart(ctx.getContext('2d'), { type: 'line', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, layout: { padding: { top: 4, right: 4, bottom: 0, left: 4 } }, scales: { x: { grid: { color: C.grid, drawBorder: false }, ticks: { maxTicksLimit: 8, font: { size: 10, family: "'JetBrains Mono', monospace" }, color: '#3d4558', padding: 4, }, border: { display: false }, }, y: { grid: { color: C.grid, drawBorder: false }, ticks: { font: { size: 10, family: "'JetBrains Mono', monospace" }, color: '#3d4558', padding: 8, }, border: { display: false }, ...opts.yAxis, }, }, plugins: { legend: { display: opts.legend || false, position: 'bottom', labels: { usePointStyle: true, pointStyle: 'circle', padding: 16, font: { size: 10, weight: 500 }, color: '#8a94a8', }, }, tooltip: { backgroundColor: '#12151e', titleColor: '#e8ecf4', bodyColor: '#8a94a8', borderColor: 'rgba(255,255,255,0.08)', borderWidth: 1, cornerRadius: 8, padding: 10, displayColors: true, boxPadding: 4, titleFont: { size: 11, weight: 600 }, bodyFont: { size: 11 }, }, }, ...opts.extra, }, }); } function makeBarChart(id, labels, datasets) { const ctx = document.getElementById(id); if (!ctx) return null; return new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, layout: { padding: { top: 4, right: 4, bottom: 0, left: 4 } }, scales: { x: { stacked: true, grid: { color: C.grid, drawBorder: false }, ticks: { maxTicksLimit: 8, font: { size: 10, family: "'JetBrains Mono', monospace" }, color: '#3d4558' }, border: { display: false }, }, y: { stacked: true, grid: { color: C.grid, drawBorder: false }, ticks: { font: { size: 10, family: "'JetBrains Mono', monospace" }, color: '#3d4558' }, border: { display: false }, }, }, plugins: { legend: { display: true, position: 'bottom', labels: { usePointStyle: true, pointStyle: 'circle', padding: 16, font: { size: 10, weight: 500 }, color: '#8a94a8', }, }, tooltip: { backgroundColor: '#12151e', titleColor: '#e8ecf4', bodyColor: '#8a94a8', borderColor: 'rgba(255,255,255,0.08)', borderWidth: 1, cornerRadius: 8, padding: 10, }, }, }, }); } // ── Gradient helper ────────────────────────────────────────────────────────── function createGradient(ctx, hex, startAlpha, endAlpha) { const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight); gradient.addColorStop(0, rgba(hex, startAlpha)); gradient.addColorStop(1, rgba(hex, endAlpha)); return gradient; } // ── Initialise all charts ───────────────────────────────────────────────────── const emptyLabels = Array.from({ length: CURVE_POINTS }, (_, i) => `${i}h`); const emptyData = Array(CURVE_POINTS).fill(null); // 1. Price curve const priceChart = makeLineChart('chart-price', emptyLabels, [ { label: 'Price ($/kWh)', data: [...emptyData], borderColor: C.amber, backgroundColor: rgba(C.amber, 0.08), borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0, }, { label: 'Current', data: [...emptyData], borderColor: C.red, backgroundColor: 'transparent', borderWidth: 0, pointRadius: 5, pointBackgroundColor: C.red, pointBorderColor: rgba(C.red, 0.3), pointBorderWidth: 6, }, ], { legend: true, yAxis: { title: { display: true, text: '$/kWh', color: '#3d4558', font: { size: 10 } } } } ); // 2. Temperature const tempChart = makeLineChart('chart-temp', [], [ { label: 'Indoor Temp (°C)', data: [], borderColor: C.cyan, backgroundColor: rgba(C.cyan, 0.06), borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0, }, { label: 'T_max (23°C)', data: [], borderColor: rgba(C.red, 0.35), borderWidth: 1, borderDash: [4, 4], pointRadius: 0, fill: false, }, { label: 'T_min (19°C)', data: [], borderColor: rgba(C.blue, 0.35), borderWidth: 1, borderDash: [4, 4], pointRadius: 0, fill: false, }, ], { legend: true, yAxis: { suggestedMin: 15, suggestedMax: 30, title: { display: true, text: '°C', color: '#3d4558', font: { size: 10 } } } } ); // 3. Storage history const storageChart = makeLineChart('chart-storage', [], [{ label: 'Storage Level', data: [], borderColor: C.teal, backgroundColor: rgba(C.teal, 0.1), borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0, }], { yAxis: { min: 0, max: 1 } } ); // 4. HVAC + Load Shed const hvacChart = makeBarChart('chart-hvac', [], [ { label: 'HVAC Power', data: [], backgroundColor: rgba(C.blue, 0.6), borderColor: C.blue, borderWidth: 1, borderRadius: 3, }, { label: 'Load Shed', data: [], backgroundColor: rgba(C.red, 0.6), borderColor: C.red, borderWidth: 1, borderRadius: 3, }, ] ); // 5. Cumulative cost vs baseline const costChart = makeLineChart('chart-cost', [], [ { label: 'Agent Cost ($)', data: [], borderColor: C.green, backgroundColor: rgba(C.green, 0.06), borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0, }, { label: 'Baseline ($)', data: [], borderColor: rgba(C.amber, 0.6), borderDash: [5, 3], borderWidth: 2, fill: false, tension: 0.4, pointRadius: 0, }, ], { legend: true, yAxis: { title: { display: true, text: '$', color: '#3d4558', font: { size: 10 } } } } ); // 6. Grid stress history const stressChart = makeLineChart('chart-stress', [], [{ label: 'Grid Stress', data: [], borderColor: C.red, backgroundColor: rgba(C.red, 0.1), borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0, }], { yAxis: { min: 0, max: 1 } } ); // 7. Carbon curve const carbonChart = makeLineChart('chart-carbon', emptyLabels, [{ label: 'Carbon Intensity (gCO₂/kWh)', data: [...emptyData], borderColor: C.orange, backgroundColor: rgba(C.orange, 0.08), borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0, }], { yAxis: { title: { display: true, text: 'gCO₂/kWh', color: '#3d4558', font: { size: 10 } } } } ); // 8. Reward timeline const rewardChart = makeLineChart('chart-reward', [], [{ label: 'Step Reward', data: [], borderColor: C.green, backgroundColor: rgba(C.green, 0.06), borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0, }], { yAxis: { title: { display: true, text: 'Reward', color: '#3d4558', font: { size: 10 } } } } ); // ── Stress meter bars ──────────────────────────────────────────────────────── function buildStressMeter() { const el = document.getElementById('stress-meter'); if (!el) return; el.innerHTML = ''; for (let i = 0; i < 20; i++) { const bar = document.createElement('div'); bar.className = 'stress-bar'; bar.id = `sm-${i}`; el.appendChild(bar); } } buildStressMeter(); function updateStressMeter(stress) { const bars = 20; const active = Math.round(stress * bars); for (let i = 0; i < bars; i++) { const bar = document.getElementById(`sm-${i}`); if (!bar) continue; const pct = (i / bars) * 100; bar.style.height = `${20 + pct * 0.8}%`; if (i < active) { const color = stress > 0.7 ? C.red : stress > 0.4 ? C.amber : C.green; bar.style.background = color; bar.style.boxShadow = `0 0 6px ${rgba(color === C.red ? C.red : color === C.amber ? C.amber : C.green, 0.3)}`; } else { bar.style.background = 'rgba(255,255,255,0.03)'; bar.style.boxShadow = 'none'; } } } // ── Batch Gantt renderer ───────────────────────────────────────────────────── function renderGantt(jobs, currentStep) { const wrap = document.getElementById('gantt-wrap'); if (!wrap) return; if (!jobs || jobs.length === 0) { wrap.innerHTML = '
No batch jobs in this episode
'; return; } const totalSlots = EPISODE_STEPS; wrap.innerHTML = ''; jobs.forEach(job => { const row = document.createElement('div'); row.className = 'gantt-row'; const label = document.createElement('div'); label.className = 'gantt-label'; label.textContent = `J${job.id}`; row.appendChild(label); const track = document.createElement('div'); track.className = 'gantt-track'; // Deadline marker const deadlinePct = (job.deadline_slot / totalSlots) * 100; const deadline = document.createElement('div'); deadline.className = 'gantt-deadline'; deadline.style.left = `${deadlinePct}%`; deadline.title = `Deadline: step ${job.deadline_slot}`; track.appendChild(deadline); // Job block if (job.scheduled) { const startPct = (job.scheduled_at / totalSlots) * 100; const widthPct = (job.duration / totalSlots) * 100; const block = document.createElement('div'); block.className = 'gantt-block ' + (job.completed ? 'completed' : job.missed_deadline ? 'missed' : 'scheduled'); block.style.left = `${startPct}%`; block.style.width = `${Math.max(widthPct, 1)}%`; track.appendChild(block); } // Current step marker const curPct = (currentStep / totalSlots) * 100; const curMarker = document.createElement('div'); curMarker.style.cssText = `position:absolute;top:2px;bottom:2px;width:2px;background:rgba(91,156,246,0.5);left:${curPct}%;border-radius:1px;box-shadow:0 0 4px rgba(91,156,246,0.3)`; track.appendChild(curMarker); row.appendChild(track); // Status badge const statusWrap = document.createElement('div'); statusWrap.className = 'gantt-status'; let badgeClass = 'pending', badgeText = 'pending'; if (job.completed) { badgeClass = 'ok'; badgeText = 'done'; } else if (job.missed_deadline) { badgeClass = 'missed'; badgeText = 'missed'; } else if (job.scheduled && !job.completed) { badgeClass = 'running'; badgeText = 'running'; } statusWrap.innerHTML = `${badgeText}`; row.appendChild(statusWrap); wrap.appendChild(row); }); } // ── Reward breakdown rows ───────────────────────────────────────────────────── function renderRewardRows(rc) { if (!rc) return; const container = document.getElementById('reward-rows'); if (!container) return; const components = [ { key: 'cost_savings', label: 'Cost Savings', color: C.green }, { key: 'temp_constraint', label: 'Temp Constr.', color: C.cyan }, { key: 'grid_response', label: 'Grid DR', color: C.blue }, { key: 'efficiency_bonus', label: 'Efficiency', color: C.purple }, { key: 'stability_penalty', label: 'Stability', color: C.amber }, { key: 'deadline_penalty', label: 'Deadlines', color: C.red }, { key: 'carbon_reward', label: 'Carbon', color: C.orange }, ]; container.innerHTML = ''; components.forEach(c => { const val = rc[c.key] || 0; const absVal = Math.abs(val); const pct = Math.min(100, absVal * 30); container.innerHTML += `
${c.label}
${val.toFixed(3)}
`; }); } // ── KPI color logic ────────────────────────────────────────────────────────── function colorClass(val, good, bad) { if (val <= good) return 'good'; if (val >= bad) return 'bad'; return 'warn'; } // ── Main state update ───────────────────────────────────────────────────────── let lastStep = -1; async function fetchAndUpdate() { try { const res = await fetch(`${API_BASE}/state`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const state = await res.json(); connected = true; document.getElementById('conn-banner').classList.remove('show'); document.getElementById('status-dot').style.background = 'var(--accent-green)'; document.getElementById('status-label').textContent = 'Live'; const b = state.buildings && state.buildings[currentBuilding]; if (!b) return; const step = state.step; // ── Header ── document.getElementById('ep-step').textContent = `ep:${state.episode} step:${step}/${EPISODE_STEPS}`; document.getElementById('task-badge').textContent = TASK_NAMES[state.task_id] || 'Task 1'; // ── KPIs ── const priceEl = document.getElementById('kpi-price'); priceEl.textContent = `$${b.current_price.toFixed(4)}`; priceEl.className = 'kpi-value ' + colorClass(b.current_price, 0.08, 0.16); const tempEl = document.getElementById('kpi-temp'); tempEl.textContent = `${b.indoor_temperature.toFixed(1)}°C`; const inBounds = b.indoor_temperature >= 19 && b.indoor_temperature <= 23; tempEl.className = 'kpi-value ' + (inBounds ? 'good' : 'bad'); const stressEl = document.getElementById('kpi-stress'); stressEl.textContent = b.grid_stress_signal.toFixed(3); stressEl.className = 'kpi-value ' + colorClass(b.grid_stress_signal, 0.4, 0.7); const costEl = document.getElementById('kpi-cost'); const savings = b.baseline_cost - b.cumulative_cost; costEl.textContent = `$${b.cumulative_cost.toFixed(2)}`; costEl.className = 'kpi-value ' + (savings > 0 ? 'good' : 'warn'); document.getElementById('kpi-baseline').textContent = `$${b.baseline_cost.toFixed(2)}`; document.getElementById('kpi-carbon').textContent = `${b.carbon_intensity.toFixed(0)}`; document.getElementById('kpi-demand').textContent = `${b.process_demand.toFixed(1)}`; document.getElementById('kpi-storage').textContent = `${(b.thermal_storage_level * 100).toFixed(1)}`; // ── Price curve chart ── if (state.price_curve_episode && state.price_curve_episode.length === CURVE_POINTS) { const labels = Array.from({ length: CURVE_POINTS }, (_, i) => `${i}:00`); priceChart.data.labels = labels; priceChart.data.datasets[0].data = state.price_curve_episode; const marker = Array(CURVE_POINTS).fill(null); const markerIdx = Math.min(Math.floor(step / 4), CURVE_POINTS - 1); marker[markerIdx] = state.price_curve_episode[markerIdx]; priceChart.data.datasets[1].data = marker; priceChart.update('none'); } // ── Carbon curve ── if (state.carbon_curve_episode && state.carbon_curve_episode.length === CURVE_POINTS) { carbonChart.data.labels = Array.from({ length: CURVE_POINTS }, (_, i) => `${i}:00`); carbonChart.data.datasets[0].data = state.carbon_curve_episode; carbonChart.update('none'); } // ── Grid stress ── const stressBig = document.getElementById('stress-big'); stressBig.textContent = b.grid_stress_signal.toFixed(3); stressBig.className = 'stress-value ' + (b.grid_stress_signal > 0.7 ? 'high' : b.grid_stress_signal > 0.4 ? 'mid' : 'low'); updateStressMeter(b.grid_stress_signal); const cardStress = document.getElementById('card-stress'); if (b.grid_stress_signal > 0.7) { cardStress.classList.add('alert-active'); } else { cardStress.classList.remove('alert-active'); } // ── Thermal storage bar ── const storagePct = (b.thermal_storage_level * 100).toFixed(1); document.getElementById('storage-pct').textContent = storagePct; document.getElementById('storage-fill').style.width = `${storagePct}%`; // ── History-based charts (only when step changes) ── if (step !== lastStep) { lastStep = step; const stepLabels = Array.from({ length: b.temp_history.length }, (_, i) => i); // Temperature chart if (b.temp_history.length > 0) { tempChart.data.labels = stepLabels; tempChart.data.datasets[0].data = b.temp_history; tempChart.data.datasets[1].data = b.temp_history.map(() => 23); tempChart.data.datasets[2].data = b.temp_history.map(() => 19); tempChart.update('none'); } // Storage history if (b.hvac_history && b.hvac_history.length > 0) { storageChart.data.labels = stepLabels; storageChart.data.datasets[0].data = Array.from({ length: b.hvac_history.length }, () => b.thermal_storage_level ); storageChart.update('none'); } // HVAC + load shed (bar) if (b.hvac_history && b.load_shed_history) { const n = Math.min(b.hvac_history.length, HISTORY_LEN); hvacChart.data.labels = Array.from({ length: n }, (_, i) => i); hvacChart.data.datasets[0].data = b.hvac_history.slice(0, n); hvacChart.data.datasets[1].data = b.load_shed_history.slice(0, n); hvacChart.update('none'); } // Cost vs baseline if (b.cost_history && b.cost_history.length > 0) { const n = b.cost_history.length; costChart.data.labels = Array.from({ length: n }, (_, i) => i); costChart.data.datasets[0].data = b.cost_history; const baselineStep = b.baseline_cost / Math.max(step, 1); costChart.data.datasets[1].data = b.cost_history.map((_, i) => baselineStep * (i + 1)); costChart.update('none'); } // Grid stress + reward history if (b.reward_history && b.reward_history.length > 0) { const n = b.reward_history.length; stressChart.data.labels = Array.from({ length: n }, (_, i) => i); stressChart.data.datasets[0].data = b.reward_history.map(r => Math.max(0, r.grid_response || 0)); stressChart.update('none'); rewardChart.data.labels = Array.from({ length: n }, (_, i) => i); rewardChart.data.datasets[0].data = b.reward_history.map(r => r.total || 0); rewardChart.update('none'); renderRewardRows(b.reward_history[b.reward_history.length - 1]); } // Batch Gantt renderGantt(b.jobs || [], step); } } catch (err) { connected = false; document.getElementById('conn-banner').classList.add('show'); document.getElementById('status-dot').style.background = 'var(--accent-red)'; document.getElementById('status-label').textContent = 'Offline'; } } // ── Episode controls ───────────────────────────────────────────────────────── async function doReset() { const taskId = parseInt(document.getElementById('task-select').value, 10); const btn = document.getElementById('btn-reset'); btn.innerHTML = ' Resetting...'; btn.disabled = true; btn.style.opacity = '0.6'; lastStep = -1; try { await fetch(`${API_BASE}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId, num_buildings: 1 }), }); } catch (e) { console.error(e); } btn.innerHTML = ' New Episode'; btn.disabled = false; btn.style.opacity = '1'; document.getElementById('grade-result').textContent = ''; document.getElementById('grade-result').classList.remove('show'); } let liveSimTimer = null; let isLiveSimulating = false; let lastLiveState = null; // ── Smart Heuristic Agent ─────────────────────────────────────────────────── // Mirrors the Python _heuristic_action() from inference.py. // Reads the latest fetched state and generates intelligent actions that // exercise ALL reward components: cost, temperature, grid DR, efficiency, // stability, deadlines, and carbon. function heuristicAction(b) { if (!b) { return { hvac_power_level: 0.5, thermal_charge_rate: 0.0, batch_job_slot: 0, load_shed_fraction: 0.0, building_id: currentBuilding }; } const price = b.current_price || 0.10; const stress = b.grid_stress_signal || 0.0; const temp = b.indoor_temperature || 21.0; const storage = b.thermal_storage_level || 0.5; const queue = b.batch_queue || []; const carbon = b.carbon_intensity || 300; const step = b.step || 0; // ── HVAC: price-aware + temperature-reactive ── let hvac = 0.5; if (price < 0.07) hvac = 0.7; // cheap → run more else if (price > 0.15) hvac = 0.3; // expensive → reduce else hvac = 0.5; // Temperature override: keep within 19–23°C if (temp > 23.0) hvac = Math.max(hvac, 0.8); else if (temp > 22.0) hvac = Math.max(hvac, 0.6); else if (temp < 19.0) hvac = Math.min(hvac, 0.2); else if (temp < 20.0) hvac = Math.min(hvac, 0.35); // ── Thermal storage: arbitrage ── let charge = 0.0; if (price < 0.07 && storage < 0.8) { charge = 0.6; // charge during cheap periods } else if (price > 0.15 && storage > 0.3) { charge = -0.5; // discharge during expensive periods } else if (price < 0.10 && storage < 0.5) { charge = 0.3; // moderate charge at mid-low prices } else if (price > 0.12 && storage > 0.6) { charge = -0.3; // moderate discharge at mid-high prices } // Carbon-aware: prefer charging when carbon is low if (carbon < 250 && storage < 0.7) { charge = Math.max(charge, 0.4); } // ── Load shedding: grid stress response ── let shed = 0.0; if (stress > 0.8) shed = 0.45; else if (stress > 0.7) shed = 0.35; else if (stress > 0.5) shed = 0.15; else if (stress > 0.3) shed = 0.05; // ── Batch scheduling: urgency-aware ── let slot = 2; // default: moderate defer if (queue.length > 0) { const minDeadline = Math.min(...queue); const stepsLeft = minDeadline - step; if (stepsLeft < 4) slot = 0; // urgent: run now else if (stepsLeft < 8) slot = 1; // soon: start soon else if (stepsLeft < 16) slot = 2; // moderate else if (price < 0.08) slot = 0; // cheap: might as well run now else slot = 3; // defer } return { hvac_power_level: Math.max(0, Math.min(1, hvac)), thermal_charge_rate: Math.max(-1, Math.min(1, charge)), batch_job_slot: Math.max(0, Math.min(4, slot)), load_shed_fraction: Math.max(0, Math.min(0.5, shed)), building_id: currentBuilding, }; } function toggleLiveSim() { const btn = document.getElementById('btn-live'); if (isLiveSimulating) { clearInterval(liveSimTimer); isLiveSimulating = false; btn.innerHTML = ' Start Live Simulation'; btn.classList.remove('active'); } else { isLiveSimulating = true; btn.innerHTML = ' Pause Simulation'; btn.classList.add('active'); liveSimTimer = setInterval(async () => { try { // Fetch current state to make informed actions const stateRes = await fetch(`${API_BASE}/state`); if (stateRes.ok) { const state = await stateRes.json(); lastLiveState = state.buildings && state.buildings[currentBuilding]; } // Use smart heuristic agent based on current state const action = heuristicAction(lastLiveState); await fetch(`${API_BASE}/step`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action), }); } catch (e) { console.error(e); } }, 400); } } async function doGrade() { try { const res = await fetch(`${API_BASE}/grade`); const grade = await res.json(); const score = (grade.score * 100).toFixed(2); const el = document.getElementById('grade-result'); el.textContent = `Score: ${score}% ${grade.exploit_detected ? '⚠ exploit!' : ''}`; el.style.color = grade.score > 0.6 ? 'var(--accent-green)' : grade.score > 0.3 ? 'var(--accent-amber)' : 'var(--accent-red)'; el.style.background = grade.score > 0.6 ? 'rgba(74,222,128,0.08)' : grade.score > 0.3 ? 'rgba(245,166,35,0.08)' : 'rgba(240,110,110,0.08)'; el.classList.add('show'); } catch (e) { console.error(e); } } function onTaskChange() { [tempChart, storageChart, hvacChart, costChart, stressChart, rewardChart].forEach(c => { if (!c) return; c.data.labels = []; c.data.datasets.forEach(d => d.data = []); c.update('none'); }); } function onBuildingChange() { currentBuilding = parseInt(document.getElementById('building-select').value, 10); lastStep = -1; } // ── Start polling ──────────────────────────────────────────────────────────── function startPolling() { if (pollTimer) clearInterval(pollTimer); fetchAndUpdate(); pollTimer = setInterval(fetchAndUpdate, POLL_MS); } startPolling();