document.addEventListener('DOMContentLoaded', () => { // Determine WS URL based on current host const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host || 'localhost:8000'; const wsUrl = `${protocol}//${host}/ws`; let ws; // Make closure function globally accessible for inline onclick window.approveClosure = function(nodeId, event) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'close_gate', node_id: nodeId })); if (event && event.target) { event.target.innerHTML = 'CLOSING...'; event.target.style.opacity = '0.7'; event.target.disabled = true; } } }; window.openGate = function(nodeId, event) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'open_gate', node_id: nodeId })); if (event && event.target) { event.target.innerHTML = 'OPENING...'; event.target.style.opacity = '0.7'; event.target.disabled = true; } } }; // Control Panel logic document.getElementById('control-scenario').addEventListener('change', (e) => { fetch(`/api/scenario?scenario=${e.target.value}&weather=${document.getElementById('control-weather').value}`, {method: 'POST'}); }); document.getElementById('control-weather').addEventListener('change', (e) => { fetch(`/api/scenario?scenario=${document.getElementById('control-scenario').value}&weather=${e.target.value}`, {method: 'POST'}); }); const tickSlider = document.getElementById('control-tick'); tickSlider.addEventListener('input', (e) => { document.getElementById('tick-val-display').textContent = e.target.value + 's'; }); tickSlider.addEventListener('change', (e) => { fetch(`/api/settings/tick?interval=${e.target.value}`, {method: 'POST'}); }); // Control Panel Toggle Logic const controlsHeader = document.getElementById('controls-header'); const controlsBody = document.getElementById('controls-body'); const controlsToggleIcon = document.getElementById('controls-toggle-icon'); if (controlsHeader) { controlsHeader.addEventListener('click', () => { if (controlsBody.style.maxHeight === '0px') { controlsBody.style.maxHeight = '500px'; controlsBody.style.opacity = '1'; controlsBody.style.marginTop = '16px'; controlsToggleIcon.style.transform = 'rotate(0deg)'; } else { controlsBody.style.maxHeight = '0px'; controlsBody.style.opacity = '0'; controlsBody.style.marginTop = '0px'; controlsToggleIcon.style.transform = 'rotate(-90deg)'; } }); } // Vis.js Network Setup const container = document.getElementById('network-graph'); const visNodes = new vis.DataSet(); const visEdges = new vis.DataSet(); const data = { nodes: visNodes, edges: visEdges }; const options = { nodes: { shape: 'dot', font: { color: '#e2e8f0', size: 14, face: 'Outfit' }, borderWidth: 2, shadow: true }, edges: { smooth: { type: 'continuous' }, arrows: { to: { enabled: true, scaleFactor: 0.5 } } }, physics: { enabled: true, solver: 'forceAtlas2Based', forceAtlas2Based: { gravitationalConstant: -120, centralGravity: 0.005, springLength: 160, springConstant: 0.06, damping: 0.45, avoidOverlap: 1.0 }, stabilization: { iterations: 300 } }, interaction: { hover: true, tooltipDelay: 80, navigationButtons: true, keyboard: true, dragNodes: false } }; const network = new vis.Network(container, data, options); // Freeze the graph layout permanently once the initial physics settle network.once("stabilizationIterationsDone", function() { network.setOptions({ physics: false }); }); // Fullscreen Logic const fullscreenBtn = document.getElementById('fullscreen-btn'); const graphPanel = document.getElementById('graph-panel'); fullscreenBtn.addEventListener('click', () => { if (!document.fullscreenElement) { graphPanel.requestFullscreen().catch(err => { console.log(`Error attempting to enable fullscreen: ${err.message}`); }); } else { document.exitFullscreen(); } }); document.addEventListener('fullscreenchange', () => { if (document.fullscreenElement) { fullscreenBtn.textContent = '✖ Close'; graphPanel.style.background = 'var(--bg-color)'; } else { fullscreenBtn.textContent = '⛶ Expand'; graphPanel.style.background = 'var(--panel-bg)'; } }); function connect() { ws = new WebSocket(wsUrl); ws.onopen = () => { document.getElementById('val-connection').textContent = 'Connected'; document.getElementById('val-connection').style.color = 'var(--low-color)'; document.getElementById('val-connection').style.textShadow = '0 0 8px rgba(0,230,168,0.4)'; }; ws.onmessage = (event) => { const payload = JSON.parse(event.data); if (payload.type === 'state_update') { updateDashboard(payload); } }; ws.onclose = () => { document.getElementById('val-connection').textContent = 'Disconnected'; document.getElementById('val-connection').style.color = 'var(--critical-color)'; document.getElementById('val-connection').style.textShadow = '0 0 8px rgba(255,51,102,0.4)'; // Reconnect after 3 seconds setTimeout(connect, 3000); }; ws.onerror = (err) => { console.error('WebSocket error:', err); ws.close(); }; } function updateDashboard(data) { // Update header info document.getElementById('val-scenario').textContent = data.scenario.toUpperCase(); document.getElementById('val-weather').textContent = data.weather.toUpperCase(); document.getElementById('val-tick').textContent = data.tick; // Sync control panel with active backend state if (document.activeElement.id !== 'control-scenario') { document.getElementById('control-scenario').value = data.scenario; } if (document.activeElement.id !== 'control-weather') { document.getElementById('control-weather').value = data.weather; } if (document.activeElement.id !== 'control-tick') { const ti = data.tick_interval || 30; document.getElementById('control-tick').value = ti; document.getElementById('tick-val-display').textContent = ti + 's'; } // --- 1. Update Nodes Table --- const tbody = document.getElementById('nodes-tbody'); // Sort nodes by load_pct descending const nodeList = Object.values(data.nodes); nodeList.sort((a, b) => b.load_pct - a.load_pct); const currentNodeIds = new Set(nodeList.map(n => 'node-row-' + n.node_id)); // Remove stale rows Array.from(tbody.children).forEach(row => { if (row.id && row.id.startsWith('node-row-') && !currentNodeIds.has(row.id)) { row.remove(); } if (!row.id && nodeList.length > 0) { row.remove(); } }); nodeList.forEach((node, index) => { let tr = document.getElementById('node-row-' + node.node_id); if (!tr) { tr = document.createElement('tr'); tr.id = 'node-row-' + node.node_id; tr.innerHTML = `
`; tbody.appendChild(tr); } if (tbody.children[index] !== tr) { tbody.insertBefore(tr, tbody.children[index]); } const colNode = tr.querySelector('.col-node'); colNode.innerHTML = `${node.name}
${node.priority}`; tr.querySelector('.load-text').textContent = `${node.fused_occupancy.toLocaleString()} / ${node.safe_threshold.toLocaleString()}`; const fill = tr.querySelector('.progress-bar-fill'); fill.style.width = Math.min(node.load_pct, 100) + '%'; fill.style.background = node.risk_color; const badge = tr.querySelector('.risk-badge'); badge.textContent = node.risk_level; badge.style.backgroundColor = node.risk_color; badge.style.color = (node.risk_level === 'CRITICAL' || node.risk_level === 'HIGH') ? '#fff' : '#000'; }); // --- 3. Render Alerts --- const alertsContainer = document.getElementById('alerts-container'); let hasAlerts = false; const activeAlertIds = new Set(); if (data.alerts && data.alerts.length > 0) { hasAlerts = true; data.alerts.forEach(alert => { const isClosed = data.approved_closures && data.approved_closures.includes(alert.node_id); const alertId = 'alert-card-' + alert.node_id; activeAlertIds.add(alertId); let div = document.getElementById(alertId); if (!div) { div = document.createElement('div'); div.id = alertId; div.className = 'alert-card'; alertsContainer.appendChild(div); } if (alert.risk_level === 'HIGH') { div.style.background = 'linear-gradient(135deg, rgba(255, 153, 51, 0.15) 0%, rgba(255, 153, 51, 0.05) 100%)'; div.style.borderColor = 'rgba(255, 153, 51, 0.3)'; div.style.borderLeftColor = 'var(--high-color)'; } else if (alert.risk_level === 'CRITICAL') { div.style.background = 'linear-gradient(135deg, rgba(255, 51, 102, 0.15) 0%, rgba(255, 51, 102, 0.05) 100%)'; div.style.borderColor = 'rgba(255, 51, 102, 0.3)'; div.style.borderLeftColor = 'var(--critical-color)'; } let actionHtml = ''; if (alert.risk_level === 'CRITICAL') { if (isClosed) { actionHtml = `
✅ ACTION TAKEN: Gates Closed. Decongesting area.
`; } else { actionHtml = `

⚠️ ACTION REQUIRED: Close Gates & Evacuate?

`; } } div.innerHTML = `
${alert.risk_level}: ${alert.name}
Load: ${alert.load_pct.toFixed(1)}%
Occ: ${alert.fused_occupancy.toLocaleString()} / Cap: ${alert.effective_capacity.toLocaleString()}
${actionHtml} `; }); } // Check for Safe To Reopen alerts if (data.approved_closures && data.approved_closures.length > 0) { data.approved_closures.forEach(nodeId => { const node = Object.values(data.nodes).find(n => n.node_id === nodeId); if (node && (node.risk_level === 'LOW' || node.risk_level === 'MEDIUM')) { hasAlerts = true; const alertId = 'alert-reopen-' + nodeId; activeAlertIds.add(alertId); let div = document.getElementById(alertId); if (!div) { div = document.createElement('div'); div.id = alertId; div.className = 'alert-card'; alertsContainer.appendChild(div); } div.style.background = 'linear-gradient(135deg, rgba(0, 230, 168, 0.15) 0%, rgba(0, 230, 168, 0.05) 100%)'; div.style.borderColor = 'rgba(0, 230, 168, 0.3)'; div.style.borderLeftColor = '#00e6a8'; div.innerHTML = `
✅ SAFE TO REOPEN
${node.name} evacuated. Load: ${node.load_pct.toFixed(1)}%.
`; } }); } // Remove stale alerts Array.from(alertsContainer.children).forEach(child => { if (child.id && child.id.startsWith('alert-') && !activeAlertIds.has(child.id)) { child.remove(); } }); // Handle empty state const emptyStateEl = document.getElementById('empty-state-alert'); if (!hasAlerts) { if (!emptyStateEl) { alertsContainer.innerHTML = '

No active alerts.
All nodes operating safely.

'; } } else if (emptyStateEl) { emptyStateEl.remove(); } // --- 3. Update Vis.js Network Graph --- if (data.graph && data.graph.nodes && data.graph.edges) { // Update Nodes const updatedNodes = data.graph.nodes.map(n => { // Size mapping: base size 15 + load factor const nodeSize = 15 + (n.load_pct / 100) * 15; return { id: n.id, label: n.label, shape: n.shape || 'dot', size: nodeSize, color: { background: n.risk_color, border: '#ffffff', highlight: { background: n.risk_color, border: '#ffffff' } }, title: `${n.label}
Load: ${n.load_pct}%
Occ: ${n.fused_occupancy}` }; }); visNodes.update(updatedNodes); // Update Edges const updatedEdges = data.graph.edges.map(e => { const edgeId = `${e.from}-${e.to}`; return { id: edgeId, from: e.from, to: e.to, width: Math.max(1, Math.min(e.weight / 2, 8)), // Clamp width between 1 and 8 color: { color: e.color, highlight: e.color }, dashes: e.dashes || false, label: `${e.dist_km}km | ${e.gate_status}`, font: { size: 10, color: '#94a3b8', align: 'middle', background: 'rgba(10,10,24,0.7)' }, title: `Weight: ${e.weight}
Dist: ${e.dist_km} km
Gate: ${e.gate_status}` }; }); visEdges.update(updatedEdges); } } // Initial connection connect(); });