Spaces:
Sleeping
Sleeping
| document.addEventListener('DOMContentLoaded', () => { | |
| // Check if we are on dashboard or landing | |
| if (document.getElementById('processed-stream')) { | |
| initDashboard(); | |
| } | |
| }); | |
| function initDashboard() { | |
| const dropZone = document.getElementById('drop-zone-overlay'); | |
| const fileInput = document.getElementById('file-input'); | |
| const streamImg = document.getElementById('processed-stream'); | |
| const resultsPanel = document.getElementById('results-panel'); | |
| const miniResultsPanel = document.getElementById('mini-results-panel'); | |
| const violationBadge = document.getElementById('violation-count-badge'); | |
| // System Stat Elements | |
| const statTotalRiders = document.getElementById('stat-total-riders'); | |
| const statSafeCount = document.getElementById('stat-safe-count'); | |
| const statViolationCount = document.getElementById('stat-violation-count'); | |
| // Camera elements | |
| const cameraVideo = document.getElementById('camera-video'); | |
| const cameraCanvas = document.getElementById('camera-canvas'); | |
| // Modal Elements | |
| const modal = document.getElementById('detail-modal'); | |
| const modalThumb = document.getElementById('modal-thumb'); | |
| const modalPlateThumb = document.getElementById('modal-plate-thumb'); | |
| const modalPlateText = document.getElementById('modal-plate-text'); | |
| const modalJson = document.getElementById('modal-json'); | |
| const closeModal = document.querySelector('.close-modal'); | |
| let knownDetections = new Map(); // Store full object | |
| let pollInterval = null; | |
| let socket = null; | |
| let cameraStream = null; | |
| let cameraMode = false; | |
| let sessionId = null; | |
| let cameraRotation = 0; // 0, 90, 180, 270 | |
| let cameraMirrored = false; | |
| let activeModalId = null; // tracks which violation is currently open in the modal | |
| // --- MODE SELECTION (Using Event Delegation) --- | |
| function attachModeButtons() { | |
| const uploadBtn = document.getElementById('upload-mode-btn'); | |
| const cameraBtn = document.getElementById('camera-mode-btn'); | |
| const remoteBtn = document.getElementById('remote-mode-btn'); | |
| const rotateBtn = document.getElementById('rotate-btn'); | |
| const mirrorBtn = document.getElementById('mirror-btn'); | |
| console.log('[DEBUG] Attaching mode buttons...', { uploadBtn, cameraBtn }); | |
| if (rotateBtn) { | |
| rotateBtn.onclick = (e) => { | |
| e.preventDefault(); e.stopPropagation(); | |
| cameraRotation = (cameraRotation + 90) % 360; | |
| console.log('[DEBUG] Rotation:', cameraRotation); | |
| }; | |
| } | |
| if (mirrorBtn) { | |
| mirrorBtn.onclick = (e) => { | |
| e.preventDefault(); e.stopPropagation(); | |
| cameraMirrored = !cameraMirrored; | |
| console.log('[DEBUG] Mirror:', cameraMirrored); | |
| }; | |
| } | |
| if (uploadBtn) { | |
| uploadBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| console.log('[DEBUG] Upload mode clicked'); | |
| // NEW: Stop any active camera streams before opening the file picker | |
| if (cameraMode) { | |
| stopCameraMode(); | |
| resetDropZone(); // Re-shows the buttons so the user can choose again if they cancel | |
| } | |
| fileInput.click(); | |
| }; | |
| console.log('[DEBUG] Upload button attached'); | |
| } else { | |
| console.error('[DEBUG] Upload button not found!'); | |
| } | |
| // Keyboard shortcuts | |
| document.onkeydown = (e) => { | |
| if (cameraMode) { | |
| if (e.key.toLowerCase() === 'r') { | |
| cameraRotation = (cameraRotation + 90) % 360; | |
| console.log('[SHORTCUT] Rotation:', cameraRotation); | |
| if (rotateBtn) { | |
| rotateBtn.classList.add('active'); | |
| setTimeout(() => rotateBtn.classList.remove('active'), 200); | |
| } | |
| } | |
| if (e.key.toLowerCase() === 'm') { | |
| cameraMirrored = !cameraMirrored; | |
| console.log('[SHORTCUT] Mirror:', cameraMirrored); | |
| if (mirrorBtn) { | |
| mirrorBtn.classList.toggle('active', cameraMirrored); | |
| } | |
| } | |
| } | |
| }; | |
| if (cameraBtn) { | |
| cameraBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| console.log('[DEBUG] Camera mode clicked'); | |
| startCameraMode(); | |
| }; | |
| console.log('[DEBUG] Camera button attached'); | |
| } else { | |
| console.error('[DEBUG] Camera button not found!'); | |
| } | |
| if (remoteBtn) { | |
| remoteBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| const sessionInput = document.getElementById('remote-session-id'); | |
| const sid = sessionInput.value.trim().toUpperCase(); | |
| if (!sid) { | |
| alert("Please enter a Session ID"); | |
| return; | |
| } | |
| console.log('[DEBUG] Remote mode clicked:', sid); | |
| startRemoteMode(sid); | |
| }; | |
| } | |
| } | |
| // Attach buttons on load | |
| console.log('[DEBUG] Initializing dashboard...'); | |
| attachModeButtons(); | |
| // Fetch active sessions for datalist | |
| async function pollActiveSessions() { | |
| if (cameraMode) return; // Don't bother fetching if we're already streaming | |
| try { | |
| const res = await fetch('/api/sessions'); | |
| const data = await res.json(); | |
| const list = document.getElementById('active-sessions-list'); | |
| if (list && data.sessions) { | |
| list.innerHTML = ''; | |
| data.sessions.forEach(sid => { | |
| const opt = document.createElement('option'); | |
| opt.value = sid; | |
| list.appendChild(opt); | |
| }); | |
| } | |
| } catch (e) { | |
| console.error('[API] Failed to fetch sessions', e); | |
| } | |
| } | |
| pollActiveSessions(); | |
| setInterval(pollActiveSessions, 3000); | |
| // --- CAMERA MODE --- | |
| async function startCameraMode() { | |
| try { | |
| const liveStatus = document.getElementById('live-status'); | |
| const liveIndicator = document.getElementById('live-indicator'); | |
| dropZone.innerHTML = '<i class="fas fa-spinner fa-spin" style="font-size:3rem; color:var(--purple-main);"></i><p style="margin-top:10px">Requesting Camera Access...</p>'; | |
| if (liveStatus) liveStatus.textContent = 'REQUESTING CAMERA...'; | |
| if (liveIndicator) liveIndicator.style.background = '#facc15'; | |
| // Request camera permission | |
| cameraStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| facingMode: 'environment', | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 } | |
| } | |
| }); | |
| console.log('[DEBUG] Camera stream obtained:', cameraStream); | |
| cameraVideo.srcObject = cameraStream; | |
| cameraMode = true; | |
| // NEW: Explicitly command the video to play | |
| cameraVideo.play().catch(err => console.error('[DEBUG] Video play failed:', err)); | |
| if (liveStatus) liveStatus.textContent = 'CAMERA ACTIVE'; | |
| if (liveIndicator) liveIndicator.style.background = '#10b981'; | |
| // Hide overlay and show stream | |
| dropZone.style.display = 'none'; | |
| document.querySelector('.feed-card').classList.remove('overlay-active'); | |
| document.querySelector('.feed-card').classList.add('video-active'); | |
| streamImg.style.display = 'block'; | |
| streamImg.style.opacity = '1'; | |
| streamImg.style.visibility = 'visible'; | |
| streamImg.style.zIndex = '5'; | |
| console.log('[DEBUG] Stream image element:', streamImg); | |
| console.log('[DEBUG] Stream image display:', streamImg.style.display); | |
| console.log('[DEBUG] Stream image visibility:', streamImg.style.visibility); | |
| // Clear UI | |
| knownDetections.clear(); | |
| resultsPanel.innerHTML = ''; | |
| miniResultsPanel.innerHTML = ''; | |
| violationBadge.innerText = '0'; | |
| // Initialize Socket.IO | |
| console.log('[DEBUG] Initializing Socket.IO...'); | |
| if (!socket) { | |
| socket = io(); | |
| } | |
| console.log('[DEBUG] Registering socket event handlers...'); | |
| const joinLiveSession = () => { | |
| console.log('[SOCKET] Connected, socket ID:', socket.id); | |
| if (liveStatus) liveStatus.textContent = 'SOCKET CONNECTED'; | |
| // Changed from generateSessionId() to proper uppercase generator | |
| sessionId = Math.random().toString(36).substring(2, 10).toUpperCase(); | |
| console.log('[SOCKET] Emitting start_camera_session with ID:', sessionId); | |
| socket.emit('start_camera_session', { session_id: sessionId }); | |
| }; | |
| if (socket.connected) { | |
| joinLiveSession(); | |
| } else { | |
| socket.off('connect', joinLiveSession); // prevent dupes | |
| socket.on('connect', joinLiveSession); | |
| } | |
| // Clean up old listeners before adding new ones | |
| socket.off('camera_session_started'); | |
| socket.on('camera_session_started', (data) => { | |
| console.log('[SOCKET] Session started:', data.session_id); | |
| console.log('[SOCKET] Socket connected:', socket.connected); | |
| if (liveStatus) liveStatus.textContent = 'LIVE CAMERA STREAM (' + data.session_id + ')'; | |
| sessionId = data.session_id; | |
| // Ensure stream image is ready | |
| streamImg.style.display = 'block'; | |
| streamImg.style.visibility = 'visible'; | |
| streamImg.style.zIndex = '5'; | |
| console.log('[SOCKET] Stream image prepared for display'); | |
| startCameraCapture(); | |
| }); | |
| socket.on('processed_frame', (data) => { | |
| console.log('[SOCKET] ========== RECEIVED PROCESSED FRAME =========='); | |
| console.log('[SOCKET] Data keys:', Object.keys(data)); | |
| console.log('[SOCKET] Frame length:', data.frame ? data.frame.length : 'no frame'); | |
| console.log('[SOCKET] Violations:', data.violations ? data.violations.length : 'no violations'); | |
| console.log('[SOCKET] Stream img element:', streamImg); | |
| console.log('[SOCKET] Stream img parent:', streamImg.parentElement); | |
| // Display processed frame | |
| if (data.frame) { | |
| console.log('[SOCKET] Setting image src...'); | |
| streamImg.src = data.frame; | |
| streamImg.style.display = 'block'; | |
| streamImg.style.visibility = 'visible'; | |
| streamImg.style.zIndex = '5'; | |
| } | |
| // Update Stats | |
| if (data.stats) { | |
| if (statTotalRiders) statTotalRiders.innerText = data.stats.total_riders; | |
| if (statSafeCount) statSafeCount.innerText = data.stats.safe_count; | |
| if (statViolationCount) statViolationCount.innerText = data.stats.violation_count; | |
| } | |
| // Update violations (Sync with full list from backend) | |
| if (data.violations) { | |
| const currentIds = new Set(data.violations.map(v => Number(v.id))); | |
| // Remove corrected false positives | |
| for (let id of knownDetections.keys()) { | |
| if (!currentIds.has(Number(id))) { | |
| console.log(`[DEBUG] Removing corrected violation ID: ${id}`); | |
| const row = document.getElementById(`log-${id}`); | |
| if (row) row.remove(); | |
| knownDetections.delete(id); | |
| } | |
| } | |
| data.violations.forEach(v => { | |
| const stored = knownDetections.get(v.id); | |
| const isNew = !stored; | |
| const isUpdated = stored && ( | |
| stored.plate_number !== v.plate_number || | |
| stored.plate_image_url !== v.plate_image_url | |
| ); | |
| if (isNew || isUpdated) { | |
| knownDetections.set(v.id, v); | |
| updateUI(v, isNew); | |
| } | |
| }); | |
| violationBadge.innerText = knownDetections.size; | |
| } | |
| }); | |
| socket.on('error', (data) => { | |
| console.error('[SOCKET] Error:', data.message); | |
| if (liveStatus) liveStatus.textContent = 'ERROR: ' + data.message; | |
| if (liveIndicator) liveIndicator.style.background = '#ef4444'; | |
| }); | |
| socket.on('disconnect', () => { | |
| console.log('[SOCKET] Disconnected'); | |
| if (liveStatus) liveStatus.textContent = 'DISCONNECTED'; | |
| if (liveIndicator) liveIndicator.style.background = '#ef4444'; | |
| // Try to reconnect after 2 seconds | |
| setTimeout(() => { | |
| if (cameraMode && cameraStream) { | |
| console.log('[SOCKET] Attempting to reconnect...'); | |
| if (liveStatus) liveStatus.textContent = 'RECONNECTING...'; | |
| socket.connect(); | |
| } | |
| }, 2000); | |
| }); | |
| // Handle OCR updates (live camera) | |
| socket.on('ocr_update', (data) => { | |
| console.log('[SOCKET] OCR Update received:', data); | |
| const trackId = data.track_id; | |
| const plateNumber = data.plate_number; | |
| const violation = data.violation; | |
| // Update the stored violation data including plate_image_url | |
| if (knownDetections.has(trackId)) { | |
| const existing = knownDetections.get(trackId); | |
| existing.plate_number = plateNumber; | |
| existing.plate_image_url = violation.plate_image_url || existing.plate_image_url; | |
| existing.ocr_attempts = violation.ocr_attempts; | |
| knownDetections.set(trackId, existing); | |
| // Update the UI row (isNew=false → replaces row in place) | |
| updateUI(existing, false); | |
| } | |
| }); | |
| } catch (err) { | |
| console.error('Camera access denied:', err); | |
| const liveStatus = document.getElementById('live-status'); | |
| const liveIndicator = document.getElementById('live-indicator'); | |
| if (liveStatus) liveStatus.textContent = 'CAMERA DENIED'; | |
| if (liveIndicator) liveIndicator.style.background = '#ef4444'; | |
| dropZone.innerHTML = '<i class="fas fa-exclamation-triangle" style="font-size:3rem; color:#ef4444;"></i><p>Camera Access Denied</p><p style="font-size:0.8rem; opacity:0.6;">Please allow camera permissions</p>'; | |
| setTimeout(() => { | |
| resetDropZone(); | |
| if (liveStatus) liveStatus.textContent = 'LIVE INFERENCE'; | |
| if (liveIndicator) liveIndicator.style.background = '#fff'; | |
| }, 3000); | |
| } | |
| } | |
| function startCameraCapture() { | |
| const ctx = cameraCanvas.getContext('2d'); | |
| console.log('[DEBUG] Starting camera capture...'); | |
| console.log('[DEBUG] Video element:', cameraVideo); | |
| console.log('[DEBUG] Video ready state:', cameraVideo.readyState); | |
| function captureFrame() { | |
| if (!cameraMode || !cameraStream) { | |
| console.log('[DEBUG] Camera mode stopped'); | |
| return; | |
| } | |
| // Check if video is ready | |
| // Check if video is ready AND has valid dimensions | |
| if (cameraVideo.readyState < 2 || cameraVideo.videoWidth === 0) { | |
| console.log('[DEBUG] Video not ready or width is 0, waiting...'); | |
| setTimeout(captureFrame, 100); | |
| return; | |
| } | |
| // Auto-detect orientation: mobile cameras often report raw sensor dims | |
| // (landscape) even when the phone is held in portrait. The <video> | |
| // element auto-corrects via metadata, but canvas.drawImage does NOT. | |
| // We detect this by comparing raw dims to rendered display dims. | |
| const vW = cameraVideo.videoWidth; | |
| const vH = cameraVideo.videoHeight; | |
| const displayW = cameraVideo.clientWidth || cameraVideo.offsetWidth; | |
| const displayH = cameraVideo.clientHeight || cameraVideo.offsetHeight; | |
| const rawLandscape = vW > vH; | |
| const displayPortrait = displayH > displayW; | |
| let autoRotation = 0; | |
| if (rawLandscape && displayPortrait) { | |
| autoRotation = 90; // sensor is landscape but phone is portrait | |
| } else if (!rawLandscape && !displayPortrait && vH > vW) { | |
| autoRotation = -90; // unlikely but handle reverse case | |
| } | |
| // Combine auto + manual rotation | |
| const totalRotation = (autoRotation + cameraRotation) % 360; | |
| const needsSwap = (totalRotation === 90 || totalRotation === 270 || | |
| totalRotation === -90 || totalRotation === -270); | |
| let targetW = needsSwap ? vH : vW; | |
| let targetH = needsSwap ? vW : vH; | |
| if (cameraCanvas.width !== targetW || cameraCanvas.height !== targetH) { | |
| cameraCanvas.width = targetW; | |
| cameraCanvas.height = targetH; | |
| } | |
| // Draw with combined rotation + mirror | |
| ctx.clearRect(0, 0, targetW, targetH); | |
| ctx.save(); | |
| ctx.translate(targetW / 2, targetH / 2); | |
| if (cameraMirrored) ctx.scale(-1, 1); | |
| if (totalRotation !== 0) ctx.rotate((totalRotation * Math.PI) / 180); | |
| ctx.drawImage(cameraVideo, -vW / 2, -vH / 2); | |
| ctx.restore(); | |
| // Convert to base64 | |
| const frameData = cameraCanvas.toDataURL('image/jpeg', 0.8); | |
| console.log('[DEBUG] Sending frame, size:', frameData.length, 'bytes'); | |
| // Send to server | |
| if (socket && socket.connected) { | |
| socket.emit('camera_frame', { frame: frameData }); | |
| console.log('[DEBUG] Frame emitted to server, socket ID:', socket.id); | |
| } else { | |
| console.error('[DEBUG] Socket not connected! Socket state:', socket ? socket.connected : 'socket is null'); | |
| } | |
| // Capture next frame (adjust FPS here - currently ~10 FPS) | |
| setTimeout(captureFrame, 100); | |
| } | |
| // Start immediately if video is ready, otherwise wait for metadata | |
| if (cameraVideo.readyState >= 2) { | |
| console.log('[DEBUG] Video already ready, starting capture'); | |
| captureFrame(); | |
| } else { | |
| console.log('[DEBUG] Waiting for video metadata...'); | |
| cameraVideo.addEventListener('loadedmetadata', () => { | |
| console.log('[DEBUG] Video metadata loaded, starting capture'); | |
| captureFrame(); | |
| }, { once: true }); | |
| // Fallback: start after 1 second anyway | |
| setTimeout(() => { | |
| if (cameraVideo.readyState >= 2) { | |
| console.log('[DEBUG] Fallback: starting capture after timeout'); | |
| captureFrame(); | |
| } | |
| }, 1000); | |
| } | |
| } | |
| function startRemoteMode(sid) { | |
| try { | |
| console.log('[DEBUG] Starting remote mode for:', sid); | |
| sessionId = sid; | |
| cameraMode = true; | |
| // UI Adjustment | |
| dropZone.style.display = 'none'; | |
| streamImg.style.display = 'block'; | |
| streamImg.style.opacity = '1'; | |
| streamImg.style.visibility = 'visible'; | |
| streamImg.style.zIndex = '5'; | |
| const feedCard = document.querySelector('.feed-card'); | |
| if (feedCard) { | |
| feedCard.classList.remove('overlay-active'); | |
| feedCard.classList.add('video-active'); | |
| } | |
| const remoteStatus = document.getElementById('remote-status'); | |
| if (remoteStatus) { | |
| remoteStatus.style.display = 'inline'; | |
| remoteStatus.textContent = `[REMOTE: JOINING ${sid}...]`; | |
| remoteStatus.style.color = '#facc15'; | |
| } | |
| if (!socket) { | |
| console.log('[DEBUG] Initializing Socket.IO for Remote...'); | |
| socket = io(); | |
| } | |
| const joinRemote = () => { | |
| console.log('[REMOTE] Emitting join_remote_session for:', sessionId); | |
| socket.emit('join_remote_session', { session_id: sessionId }); | |
| }; | |
| if (socket.connected) { | |
| joinRemote(); | |
| } else { | |
| socket.on('connect', joinRemote); | |
| } | |
| socket.off('remote_session_joined'); | |
| socket.on('remote_session_joined', (data) => { | |
| console.log('[REMOTE] Successfully joined session:', data.session_id); | |
| if (remoteStatus) { | |
| remoteStatus.textContent = `[REMOTE: CONNECTED — ${data.session_id}]`; | |
| remoteStatus.style.color = '#10b981'; | |
| } | |
| }); | |
| // Handle errors to show on UI | |
| socket.on('error', (err) => { | |
| console.error('[REMOTE] Server Error:', err); | |
| if (remoteStatus) { | |
| remoteStatus.textContent = `[REMOTE: ERROR — ${err.message || err}]`; | |
| remoteStatus.style.color = '#ef4444'; | |
| } | |
| }); | |
| socket.on('connect_error', (err) => { | |
| console.error('[REMOTE] Network Error:', err); | |
| if (remoteStatus) { | |
| remoteStatus.textContent = '[REMOTE: CONNECTION FAILED]'; | |
| remoteStatus.style.color = '#ef4444'; | |
| } | |
| }); | |
| // Listen for relayed processed frames from the publisher's session | |
| socket.off('processed_frame_relay'); | |
| socket.on('processed_frame_relay', (data) => { | |
| // Show the processed frame image | |
| if (data.frame) { | |
| streamImg.src = data.frame; | |
| } | |
| // Update violations | |
| if (data.violations) { | |
| data.violations.forEach(v => { | |
| const stored = knownDetections.get(v.id); | |
| const isNew = !stored; | |
| const isUpdated = stored && ( | |
| stored.plate_number !== v.plate_number || | |
| stored.plate_image_url !== v.plate_image_url | |
| ); | |
| if (isNew || isUpdated) { | |
| knownDetections.set(v.id, v); | |
| updateUI(v, isNew); | |
| } | |
| }); | |
| violationBadge.innerText = knownDetections.size; | |
| } | |
| // Update stats | |
| if (data.stats) { | |
| if (statTotalRiders) statTotalRiders.innerText = data.stats.total_riders; | |
| if (statSafeCount) statSafeCount.innerText = data.stats.safe_count; | |
| if (statViolationCount) statViolationCount.innerText = data.stats.violation_count; | |
| } | |
| }); | |
| } catch (err) { | |
| console.error('[REMOTE] Initialization Error:', err); | |
| const remoteStatus = document.getElementById('remote-status'); | |
| if (remoteStatus) { | |
| remoteStatus.textContent = '[REMOTE: INIT FAILED]'; | |
| remoteStatus.style.color = '#ef4444'; | |
| } | |
| } | |
| } | |
| function stopCameraMode() { | |
| cameraMode = false; | |
| if (cameraStream) { | |
| cameraStream.getTracks().forEach(track => track.stop()); | |
| cameraStream = null; | |
| } | |
| if (socket) { | |
| socket.disconnect(); | |
| socket = null; | |
| } | |
| } | |
| function generateSessionId() { | |
| return Math.random().toString(36).substring(2, 10); | |
| } | |
| function resetDropZone() { | |
| console.log('[DEBUG] Resetting drop zone...'); | |
| dropZone.innerHTML = ` | |
| <i class="fas fa-plus-circle" style="font-size:3.5rem; color: var(--purple-main); margin-bottom:1rem;"></i> | |
| <h3 style="font-weight: 700;">Deploy Synaptic Node</h3> | |
| <p style="opacity: 0.6; font-size: 0.9rem; margin-bottom: 1.5rem;">Choose your input source</p> | |
| <div style="display: flex; gap: 1rem; margin-bottom: 1rem; z-index: 10; position: relative;"> | |
| <button id="upload-mode-btn" class="mode-btn" style="padding: 0.8rem 1.5rem; background: rgba(138, 79, 255, 0.2); border: 2px solid var(--purple-main); border-radius: 10px; color: #fff; cursor: pointer; font-weight: 600; transition: all 0.3s; font-size: 0.9rem;" | |
| onmouseover="this.style.background='rgba(138, 79, 255, 0.4)'" | |
| onmouseout="this.style.background='rgba(138, 79, 255, 0.2)'"> | |
| <i class="fas fa-upload"></i> Upload Video | |
| </button> | |
| <button id="camera-mode-btn" class="mode-btn" style="padding: 0.8rem 1.5rem; background: rgba(138, 79, 255, 0.2); border: 2px solid var(--purple-main); border-radius: 10px; color: #fff; cursor: pointer; font-weight: 600; transition: all 0.3s; font-size: 0.9rem;" | |
| onmouseover="this.style.background='rgba(138, 79, 255, 0.4)'" | |
| onmouseout="this.style.background='rgba(138, 79, 255, 0.2)'"> | |
| <i class="fas fa-video"></i> Live Camera | |
| </button> | |
| </div> | |
| `; | |
| dropZone.style.display = 'flex'; | |
| document.querySelector('.feed-card').classList.add('overlay-active'); | |
| document.querySelector('.feed-card').classList.remove('video-active'); | |
| console.log('[DEBUG] Re-attaching buttons after reset...'); | |
| attachModeButtons(); | |
| } | |
| // --- UPLOAD HANDLING --- | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| if (!cameraMode) { | |
| dropZone.style.background = 'rgba(138, 79, 255, 0.2)'; | |
| } | |
| }); | |
| dropZone.addEventListener('dragleave', () => { | |
| if (!cameraMode) { | |
| dropZone.style.background = 'rgba(0,0,0,0.6)'; | |
| } | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| if (e.dataTransfer.files.length && !cameraMode) { | |
| handleUpload(e.dataTransfer.files[0]); | |
| } | |
| }); | |
| fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length) handleUpload(e.target.files[0]); | |
| }); | |
| async function handleUpload(file) { | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| // NEW: Stop camera if it's running before starting upload | |
| if (cameraMode) { | |
| stopCameraMode(); | |
| } | |
| dropZone.innerHTML = '<i class="fas fa-spinner fa-spin" style="font-size:3rem; color:var(--purple-main);"></i><p style="margin-top:10px">Ingesting Neural Feed...</p>'; | |
| try { | |
| const response = await fetch('/upload', { method: 'POST', body: formData }); | |
| const data = await response.json(); | |
| if (data.filename) { | |
| // Ensure the upload overlay is hidden | |
| dropZone.style.display = 'none'; | |
| document.querySelector('.feed-card').classList.remove('overlay-active'); | |
| document.querySelector('.feed-card').classList.add('video-active'); | |
| // NEW: Explicitly make the stream image visible (Copying logic from Camera Mode) | |
| streamImg.src = `/video_feed/${data.filename}/${data.session_id}`; | |
| streamImg.style.display = 'block'; | |
| streamImg.style.opacity = '1'; | |
| streamImg.style.visibility = 'visible'; | |
| streamImg.style.zIndex = '5'; | |
| console.log('[DEBUG] Upload successful, showing stream:', streamImg.src); | |
| // Clear UI | |
| knownDetections.clear(); | |
| resultsPanel.innerHTML = ''; | |
| miniResultsPanel.innerHTML = ''; | |
| violationBadge.innerText = '0'; | |
| // Start Polling | |
| if (pollInterval) clearInterval(pollInterval); | |
| pollInterval = setInterval(pollViolations, 1000); | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| dropZone.innerHTML = '<i class="fas fa-exclamation-triangle" style="font-size:3rem; color:#ef4444;"></i><p>Connection Refused</p>'; | |
| } | |
| } | |
| // --- POLLING LOGIC --- | |
| async function pollViolations() { | |
| try { | |
| const response = await fetch('/get_violations'); | |
| const data = await response.json(); // Array of current violations | |
| const currentIds = new Set(data.map(v => Number(v.id))); | |
| // REMOVE old detections that are no longer on the server (false positives cleared) | |
| for (let id of knownDetections.keys()) { | |
| if (!currentIds.has(Number(id))) { | |
| console.log(`[DEBUG] Clearing invalidated detection: ${id}`); | |
| const row = document.getElementById(`log-${id}`); | |
| if (row) row.remove(); | |
| knownDetections.delete(id); | |
| } | |
| } | |
| data.forEach(v => { | |
| // If new OR updated (plate_number OR plate_image_url changed) | |
| const stored = knownDetections.get(v.id); | |
| const isNew = !stored; | |
| const isUpdated = stored && ( | |
| stored.plate_number !== v.plate_number || | |
| stored.plate_image_url !== v.plate_image_url | |
| ); | |
| if (isNew || isUpdated) { | |
| knownDetections.set(v.id, v); | |
| updateUI(v, isNew); | |
| } | |
| }); | |
| violationBadge.innerText = knownDetections.size; | |
| // Update stats via polling | |
| const statsResponse = await fetch('/get_stats'); | |
| const stats = await statsResponse.json(); | |
| if (statTotalRiders) statTotalRiders.innerText = stats.total_riders; | |
| if (statSafeCount) statSafeCount.innerText = stats.safe_count; | |
| if (statViolationCount) statViolationCount.innerText = stats.violation_count; | |
| } catch (err) { console.error("Sync Error", err); } | |
| } | |
| function updateUI(v, isNew) { | |
| // If it's an update, remove the old row first | |
| if (!isNew) { | |
| const oldRow = document.getElementById(`log-${v.id}`); | |
| if (oldRow) oldRow.remove(); | |
| } | |
| // Create Log Row | |
| const row = document.createElement('div'); | |
| row.id = `log-${v.id}`; | |
| const plateDisplay = v.plate_number === "Scanning..." ? | |
| `<span class="log-row-plate" style="color:var(--text-dim);"><i class="fas fa-circle-notch fa-spin"></i> Reading...</span>` : | |
| `<span class="log-row-plate">${v.plate_number}</span>`; | |
| row.className = 'log-row'; | |
| row.innerHTML = ` | |
| <div class="log-row-header"> | |
| <span class="log-row-title">⚠ NO HELMET DETECTED</span> | |
| <span class="log-row-time">${v.timestamp}</span> | |
| </div> | |
| <div class="log-row-meta"> | |
| <span class="log-row-id">ID: ${v.id}</span> | |
| ${plateDisplay} | |
| </div> | |
| `; | |
| row.onclick = () => showDetail(v); | |
| // Prepend to list | |
| resultsPanel.prepend(row); | |
| // Add to Mini Gallery (only if new) | |
| if (isNew) { | |
| const thumb = document.createElement('div'); | |
| thumb.className = 'mini-thumb'; | |
| thumb.innerHTML = `<img src="${v.image_url}" style="width:100%; height:100%; object-fit:cover;">`; | |
| thumb.onclick = () => showDetail(knownDetections.get(v.id)); // get latest data on click | |
| miniResultsPanel.prepend(thumb); | |
| // Limit gallery items | |
| if (miniResultsPanel.children.length > 8) { | |
| miniResultsPanel.removeChild(miniResultsPanel.lastChild); | |
| } | |
| } | |
| // If this violation's modal is currently open, live-refresh it | |
| if (activeModalId !== null && activeModalId === v.id) { | |
| refreshOpenModal(v); | |
| } | |
| } | |
| function syntaxHighlightJSON(obj) { | |
| // Create a table view for better mobile responsiveness | |
| let tableHTML = '<table class="json-table">'; | |
| for (const [key, value] of Object.entries(obj)) { | |
| let displayValue = value; | |
| let valueClass = 'json-str'; | |
| if (typeof value === 'number') { | |
| valueClass = 'json-num'; | |
| } else if (typeof value === 'boolean') { | |
| valueClass = 'json-bool'; | |
| } else if (value === null) { | |
| valueClass = 'json-null'; | |
| displayValue = 'null'; | |
| } else if (typeof value === 'string' && value.startsWith('http')) { | |
| valueClass = 'json-url'; | |
| displayValue = `<a href="${value}" target="_blank" style="color: #38bdf8; text-decoration: underline;">${value}</a>`; | |
| } else if (typeof value === 'object') { | |
| displayValue = JSON.stringify(value, null, 2); | |
| } | |
| tableHTML += ` | |
| <tr> | |
| <td><span class="json-key">${key}</span></td> | |
| <td><span class="${valueClass}">${displayValue}</span></td> | |
| </tr> | |
| `; | |
| } | |
| tableHTML += '</table>'; | |
| return tableHTML; | |
| } | |
| function showDetail(v) { | |
| // Refetch latest from map in case OCR updated while modal was closed | |
| const latest = knownDetections.get(v.id) || v; | |
| activeModalId = latest.id; | |
| refreshOpenModal(latest); | |
| modal.style.display = 'flex'; | |
| } | |
| function refreshOpenModal(v) { | |
| modalThumb.src = v.image_url; | |
| // Handle Plate Image | |
| if (v.plate_image_url) { | |
| modalPlateThumb.src = v.plate_image_url + '?t=' + Date.now(); // cache-bust so new best image loads | |
| modalPlateThumb.style.display = 'block'; | |
| } else { | |
| modalPlateThumb.style.display = 'none'; | |
| modalPlateThumb.src = ''; | |
| } | |
| modalPlateText.innerText = v.plate_number || "----"; | |
| modalJson.innerHTML = syntaxHighlightJSON(v); | |
| } | |
| closeModal.onclick = () => { modal.style.display = 'none'; activeModalId = null; }; | |
| window.onclick = (e) => { if (e.target == modal) { modal.style.display = 'none'; activeModalId = null; } }; | |
| } |