Traffic-Safety / static /js /main.js
WebashalarForML's picture
Upload 31 files
c9aaf0c verified
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; } };
}