document.addEventListener('DOMContentLoaded', () => { const scenarioList = document.getElementById('scenario-list'); const weatherSelect = document.getElementById('weather'); const framesInput = document.getElementById('frames'); const launchForm = document.getElementById('launch-form'); const launchBtn = document.getElementById('launch-btn'); const launchText = document.getElementById('launch-text'); const launchSpinner = document.getElementById('launch-spinner'); const terminalOutput = document.getElementById('terminal-output'); const clearTerminalBtn = document.getElementById('clear-terminal'); // New Elements const stopBtn = document.getElementById('stop-btn'); const simStatusDot = document.getElementById('sim-status-dot'); const simStatusText = document.getElementById('sim-status-text'); const launchSimBtn = document.getElementById('launch-sim-btn'); // Idle Toggle Elements const idleToggleContainer = document.getElementById('idle-toggle-container'); const gpuIdleToggle = document.getElementById('gpu-idle-toggle'); let currentEventSource = null; let idleTimer = null; let isSimulatorRunning = false; let isSimulationActive = false; // user currently running a scenario // --- Collapsible Config Card Logic --- const configSection = document.getElementById('config-section'); const configToggleHeader = document.getElementById('config-toggle-header'); // Initial State Persistence if (localStorage.getItem('config_collapsed') === 'true') { configSection.classList.add('collapsed'); } configToggleHeader.addEventListener('click', () => { configSection.classList.toggle('collapsed'); localStorage.setItem('config_collapsed', configSection.classList.contains('collapsed')); }); // Auto Idle Tracker function resetIdleTimer() { if (idleTimer) clearTimeout(idleTimer); // If simulation is not active and simulator is ready but NOT paused if (!isSimulationActive && isSimulatorRunning && gpuIdleToggle && !gpuIdleToggle.checked) { idleTimer = setTimeout(() => { console.log("Auto-idling simulator due to 30s inactivity..."); gpuIdleToggle.checked = true; gpuIdleToggle.dispatchEvent(new Event('change')); }, 30000); } } if (gpuIdleToggle) { gpuIdleToggle.addEventListener('change', (e) => { const isIdle = e.target.checked; const endpoint = isIdle ? '/api/simulator/idle' : '/api/simulator/wake'; fetch(endpoint, { method: 'POST' }); if (!isIdle) { resetIdleTimer(); } else { if (idleTimer) clearTimeout(idleTimer); } }); } // Simulator Status Polling function updateSimulatorStatus() { fetch('/api/simulator/status') .then(res => res.json()) .then(data => { const status = data.status; // offline, loading, ready simStatusDot.className = 'dot'; if (status === 'ready') { if (!isSimulatorRunning) { isSimulatorRunning = true; resetIdleTimer(); // start tracker as soon as it becomes ready } if (idleToggleContainer) idleToggleContainer.style.display = 'flex'; if (gpuIdleToggle && gpuIdleToggle.checked !== data.is_paused) { gpuIdleToggle.checked = data.is_paused; } if (data.is_paused) { simStatusDot.classList.add('yellow'); simStatusText.textContent = 'Ready (Idle)'; } else { simStatusDot.classList.add('green'); simStatusText.textContent = 'Ready'; } launchSimBtn.style.display = 'none'; } else if (status === 'loading') { isSimulatorRunning = false; if (idleToggleContainer) idleToggleContainer.style.display = 'none'; simStatusDot.classList.add('yellow'); simStatusText.textContent = 'Loading...'; launchSimBtn.style.display = 'none'; } else { isSimulatorRunning = false; if (idleToggleContainer) idleToggleContainer.style.display = 'none'; if (idleTimer) clearTimeout(idleTimer); simStatusDot.classList.add('red'); simStatusText.textContent = 'Offline'; launchSimBtn.style.display = 'block'; } }) .catch(() => { simStatusDot.className = 'dot red'; simStatusText.textContent = 'Error'; }); } updateSimulatorStatus(); setInterval(updateSimulatorStatus, 3000); // Launch CarlaUE4 action launchSimBtn.addEventListener('click', () => { simStatusText.textContent = 'Starting...'; simStatusDot.className = 'dot yellow'; fetch('/api/simulator/launch', { method: 'POST' }); }); // Fetch Initial Config fetch('/api/config') .then(response => response.json()) .then(config => { // Populate Scenarios config.scenarios.forEach((scenario, index) => { const isChecked = scenario === config.default_scenario ? 'checked' : ''; const cardHtml = ` `; scenarioList.insertAdjacentHTML('beforeend', cardHtml); }); // Listen for scenario changes to load dynamic parameters const scenarioRadios = document.querySelectorAll('input[name="scenario"]'); scenarioRadios.forEach(radio => { radio.addEventListener('change', (e) => loadScenarioParams(e.target.value)); }); // Load params for the initially selected scenario if (config.default_scenario) { loadScenarioParams(config.default_scenario); } // Populate Weather config.weather_options.forEach(weather => { const isSelected = weather === config.default_weather ? 'selected' : ''; const optionHtml = ``; weatherSelect.insertAdjacentHTML('beforeend', optionHtml); }); // Default Frames framesInput.value = config.default_frames; appendLog(`> Config loaded successfully. Found ${config.scenarios.length} scenarios.`, 'system'); }) .catch(error => { appendLog(`> Error loading config: ${error.message}`, 'error'); }); // Handle Launch launchForm.addEventListener('submit', (e) => { e.preventDefault(); isSimulationActive = true; if (idleTimer) clearTimeout(idleTimer); // Ensure simulator is awake before scenario runs just in case if (gpuIdleToggle && gpuIdleToggle.checked) { gpuIdleToggle.checked = false; fetch('/api/simulator/wake', { method: 'POST' }); } // UI Loading State setLoadingState(true); appendLog(`\n> --- NEW SIMULATION RUN ---`, 'info'); // Close existing stream if any if (currentEventSource) { currentEventSource.close(); } const formData = new FormData(launchForm); // Scenario radions are in the sidebar, outside the form const scenarioRadio = document.querySelector('input[name="scenario"]:checked'); const scenarioValue = scenarioRadio ? scenarioRadio.value : 'braking'; // Serialize dynamic parameters const paramInputs = document.querySelectorAll('#dynamic-params-container input'); let paramsString = []; paramInputs.forEach(input => { if (input.value !== '') { paramsString.push(`${input.name}=${input.value}`); } }); const payload = { scenario: scenarioValue, frames: formData.get('frames'), weather: formData.get('weather'), no_record: formData.get('no_record') === 'on', params: paramsString.join(',') }; // We use fetch API to trigger the run (which could take long), but Server Sent Events (SSE) // doesn't support POST natively without fetch streams. So we do a POST fetch that returns an SSE stream. startStream('/api/run', payload); }); // Clear Terminal clearTerminalBtn.addEventListener('click', () => { terminalOutput.innerHTML = ''; appendLog('> Terminal cleared.', 'system'); }); // Stop Simulation if (stopBtn) { stopBtn.addEventListener('click', () => { stopBtn.disabled = true; stopBtn.querySelector('.btn-text').textContent = 'Stopping...'; appendLog('\n> Sending stop signal to simulation. Waiting for graceful exit...', 'warn'); fetch('/api/stop', { method: 'POST' }) .then(res => res.json()) .then(data => { console.log('Stop requested:', data.message); }) .catch(err => { appendLog('> Failed to send stop signal: ' + err.message, 'error'); stopBtn.disabled = false; }); }); } // Utility: Append log to terminal function appendLog(text, type = '') { const line = document.createElement('div'); line.className = `log-line ${type}`; // Advanced heuristic for colored logs if (text.includes('[INFO]')) line.classList.add('info'); if (text.includes('[WARN]')) line.classList.add('warn'); if (text.includes('[ERROR]')) line.classList.add('error'); if (text.includes('[SUCCESS]')) line.classList.add('success'); if (text.includes('[FRAME ')) line.classList.add('frame-log'); if (text.includes('Traceback') || text.includes('Exception')) line.classList.add('error'); line.textContent = text; // Smart Scroll Lock // Check if the user is currently at the bottom of the scroll view const isAtBottom = terminalOutput.scrollHeight - terminalOutput.scrollTop - terminalOutput.clientHeight < 50; terminalOutput.appendChild(line); if (isAtBottom) { terminalOutput.scrollTop = terminalOutput.scrollHeight; } } // Utility: Post JSON and stream response async function startStream(url, payload) { try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let boundary = buffer.indexOf('\n\n'); while (boundary !== -1) { const chunk = buffer.substring(0, boundary); buffer = buffer.substring(boundary + 2); if (chunk.startsWith('data: ')) { const data = chunk.substring(6); // Check for completion marker if (data.startsWith('[PROCESS_COMPLETED]')) { appendLog(`> Simulation Finished. ${data}`, 'info'); setLoadingState(false); document.getElementById('sim-progress-container').style.display = 'none'; isSimulationActive = false; resetIdleTimer(); } else { // Intercept progress bar logs if (data.includes('SIMULATING:')) { const match = data.match(/(\d+)%\|.*\|\s*(\d+)\/(\d+)/); if (match) { document.getElementById('sim-progress-container').style.display = 'block'; document.getElementById('sim-progress-fill').style.width = match[1] + '%'; document.getElementById('sim-progress-text').textContent = `${match[1]}% (${match[2]} / ${match[3]} frames)`; continue; // Skip appending this line to the terminal box } } // Prevent carriage return visual artifacts if (data.includes('\r')) { const parts = data.split('\r'); const lastPart = parts[parts.length - 1]; const lastLine = terminalOutput.lastElementChild; if (lastLine && !lastLine.textContent.includes('\n')) { lastLine.textContent = lastPart; continue; } } appendLog(data); } } boundary = buffer.indexOf('\n\n'); } } } catch (error) { appendLog(`> Connection error: ${error.message}`, 'error'); setLoadingState(false); } } function setLoadingState(isLoading) { launchBtn.disabled = isLoading; launchBtn.style.display = isLoading ? 'none' : 'block'; if (stopBtn) { stopBtn.style.display = isLoading ? 'block' : 'none'; } if (!isLoading) { launchText.textContent = "Launch Another Simulation"; if (stopBtn) { stopBtn.disabled = false; stopBtn.querySelector('.btn-text').textContent = 'Stop Simulation'; } } } // Utility: Fetch and render dynamic scenario parameters function loadScenarioParams(scenarioName) { const container = document.getElementById('dynamic-params-container'); container.innerHTML = '