Spaces:
Running
Running
| // =========== Custom Select Dropdowns =========== | |
| function ufSelectToggle(id) { | |
| const wrap = document.getElementById(id + '-wrap') || document.getElementById(id + '-trigger')?.closest('.uf-select-wrap'); | |
| const trigger = document.getElementById(id + '-trigger'); | |
| const dropdown = document.getElementById(id + '-dropdown'); | |
| const arrow = document.getElementById(id + '-arrow'); | |
| if (!dropdown) return; | |
| const isOpen = !dropdown.classList.contains('hidden'); | |
| // Close all open dropdowns first | |
| document.querySelectorAll('.uf-select-dropdown').forEach(d => { | |
| d.classList.add('hidden'); | |
| d.classList.remove('uf-select-dropdown-up'); | |
| }); | |
| document.querySelectorAll('.uf-select-arrow').forEach(a => a.classList.remove('uf-select-arrow-open')); | |
| if (!isOpen) { | |
| // Detect direction: open upward only if there's not enough space below | |
| if (trigger) { | |
| const rect = trigger.getBoundingClientRect(); | |
| const spaceBelow = window.innerHeight - rect.bottom; | |
| const spaceAbove = rect.top; | |
| const dropH = 240; // max-height of dropdown | |
| if (spaceBelow < dropH && spaceAbove > spaceBelow) { | |
| dropdown.classList.add('uf-select-dropdown-up'); | |
| } | |
| } | |
| dropdown.classList.remove('hidden'); | |
| if (arrow) arrow.classList.add('uf-select-arrow-open'); | |
| } | |
| } | |
| function ufSelectPick(id, value, label) { | |
| const hidden = document.getElementById(id); | |
| const labelEl = document.getElementById(id + '-label'); | |
| const dropdown = document.getElementById(id + '-dropdown'); | |
| const arrow = document.getElementById(id + '-arrow'); | |
| if (hidden) hidden.value = value; | |
| if (labelEl) { | |
| labelEl.textContent = label; | |
| labelEl.style.color = '#ffffff'; // selected = white, placeholder was grey | |
| } | |
| if (dropdown) dropdown.classList.add('hidden'); | |
| if (arrow) arrow.classList.remove('uf-select-arrow-open'); | |
| // Mark active option | |
| if (dropdown) { | |
| dropdown.querySelectorAll('.uf-select-option').forEach(opt => { | |
| opt.classList.toggle('uf-select-option-active', opt.dataset.value === value); | |
| }); | |
| } | |
| // Fire side-effects by id | |
| if (id === 'live-palette') applyPalette(value); | |
| } | |
| // Close on outside click | |
| document.addEventListener('click', function(e) { | |
| if (!e.target.closest('.uf-select-wrap')) { | |
| document.querySelectorAll('.uf-select-dropdown').forEach(d => d.classList.add('hidden')); | |
| document.querySelectorAll('.uf-select-arrow').forEach(a => a.classList.remove('uf-select-arrow-open')); | |
| } | |
| }); | |
| // =========== Rolling Counter =========== | |
| function animateValue(obj, start, end, duration) { | |
| let startTimestamp = null; | |
| const isPct = typeof end === 'string' && end.includes('%'); | |
| const endNum = isPct ? parseFloat(end) : end; | |
| const startNum = parseFloat(start) || 0; | |
| const step = (timestamp) => { | |
| if (!startTimestamp) startTimestamp = timestamp; | |
| const progress = Math.min((timestamp - startTimestamp) / duration, 1); | |
| const ease = progress * (2 - progress); | |
| const current = ease * (endNum - startNum) + startNum; | |
| if (isPct) { | |
| obj.innerText = current.toFixed(1) + '%'; | |
| } else { | |
| obj.innerText = Math.floor(current); | |
| } | |
| if (progress < 1) { | |
| window.requestAnimationFrame(step); | |
| } else { | |
| obj.innerText = end; | |
| } | |
| }; | |
| window.requestAnimationFrame(step); | |
| } | |
| // Close all open tooltips | |
| function closeAllTips() { | |
| document.querySelectorAll('.info-tip').forEach(tip => { | |
| tip.style.display = 'none'; | |
| }); | |
| } | |
| function positionTip(tip, wrap) { | |
| const rect = wrap.getBoundingClientRect(); | |
| const tipH = tip.offsetHeight || 60; | |
| if (rect.bottom + tipH + 10 > window.innerHeight) { | |
| tip.style.top = (rect.top - tipH - 6) + 'px'; | |
| } else { | |
| tip.style.top = (rect.bottom + 6) + 'px'; | |
| } | |
| tip.style.left = Math.min(rect.left, window.innerWidth - 260) + 'px'; | |
| } | |
| // Desktop hover only — never fires on touch | |
| document.addEventListener('mouseover', e => { | |
| if (window.matchMedia('(hover: none)').matches) return; | |
| const wrap = e.target.closest('.info-wrap'); | |
| if (!wrap) return; | |
| const tip = wrap.querySelector('.info-tip'); | |
| if (!tip) return; | |
| tip.style.display = 'block'; | |
| positionTip(tip, wrap); | |
| }); | |
| document.addEventListener('mouseout', e => { | |
| if (window.matchMedia('(hover: none)').matches) return; | |
| const wrap = e.target.closest('.info-wrap'); | |
| if (!wrap) return; | |
| const tip = wrap.querySelector('.info-tip'); | |
| if (tip) tip.style.display = 'none'; | |
| }); | |
| // Tap/click toggle — works on both desktop and mobile | |
| document.addEventListener('click', e => { | |
| const btn = e.target.closest('.info-btn'); | |
| if (!btn) { | |
| if (!e.target.closest('.info-tip') && !e.target.closest('.info-wrap')) { | |
| closeAllTips(); | |
| } | |
| return; | |
| } | |
| e.stopPropagation(); | |
| const wrap = btn.closest('.info-wrap'); | |
| if (!wrap) return; | |
| const tip = wrap.querySelector('.info-tip'); | |
| if (!tip) return; | |
| const isVisible = tip.style.display === 'block'; | |
| closeAllTips(); | |
| if (!isVisible) { | |
| tip.style.display = 'block'; | |
| positionTip(tip, wrap); | |
| } | |
| }); | |
| // ---- Tab switching — updates both sidebar + mobile bottom nav ---- | |
| function switchTab(tab) { | |
| console.log('[UrbanFlow] Switching to tab:', tab); | |
| const allTabs = ['about', 'overview', 'results', 'settings', 'help', 'feedback', 'profile']; | |
| allTabs.forEach(t => { | |
| const el = document.getElementById('tab-' + t); | |
| if (el) { | |
| el.classList.toggle('hidden', tab !== t); | |
| if (tab === t) console.log('[UrbanFlow] Tab visible:', t); | |
| } | |
| const nav = document.getElementById('nav-' + t); | |
| if (nav) { | |
| if (tab === t) { | |
| nav.classList.add('nav-item-active'); | |
| nav.classList.remove('nav-item-inactive'); | |
| } else { | |
| nav.classList.remove('nav-item-active'); | |
| nav.classList.add('nav-item-inactive'); | |
| } | |
| } | |
| const mobNav = document.getElementById('mob-nav-' + t); | |
| if (mobNav) mobNav.classList.toggle('active', tab === t); | |
| }); | |
| // Refresh Profile UI every switch | |
| if (typeof populateProfileUI === 'function') populateProfileUI(); | |
| if (tab === 'profile') populateProfileTab(); | |
| // Stop glow notification when user views Results | |
| if (tab === 'results') { | |
| const mobResults = document.getElementById('mob-nav-results'); | |
| if (mobResults) mobResults.classList.remove('notify-glow'); | |
| const navResults = document.getElementById('nav-results'); | |
| if (navResults) navResults.classList.remove('notify-glow'); | |
| } | |
| // Store active tab | |
| sessionStorage.setItem('uf_active_tab', tab); | |
| } | |
| // Ensure global access | |
| window.switchTab = switchTab; | |
| // =========== Toast System =========== | |
| function showToast(message, type) { | |
| type = type || 'info'; | |
| const icons = { success: 'fa-check-circle', error: 'fa-circle-xmark', info: 'fa-circle-info' }; | |
| const el = document.createElement('div'); | |
| el.className = `toast toast-${type}`; | |
| el.innerHTML = `<i class="fa-solid ${icons[type] || icons.info}"></i> ${message}`; | |
| document.getElementById('toast-container').appendChild(el); | |
| setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 300); }, 3000); | |
| } | |
| // =========== Keyboard Shortcuts =========== | |
| const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'results', '4': 'settings', '5': 'help', '6': 'feedback', '7': 'profile' }; | |
| function downloadArtifacts() { | |
| const vid = document.body.dataset.lastVideoId; | |
| if (vid) { | |
| const btn = document.getElementById('btn-download-bundle'); | |
| if (btn) { | |
| const originalHTML = btn.innerHTML; | |
| btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> Zipping...`; | |
| btn.disabled = true; | |
| setTimeout(() => { | |
| btn.innerHTML = originalHTML; | |
| btn.disabled = false; | |
| }, 4000); | |
| } | |
| window.open(`bundle/${vid}`, '_blank'); | |
| showToast('Preparing download bundle...', 'info'); | |
| } | |
| } | |
| document.addEventListener('keydown', function(e) { | |
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; | |
| if (TAB_KEYS[e.key]) { switchTab(TAB_KEYS[e.key]); return; } | |
| if (e.key === 'd' || e.key === 'D') { | |
| downloadArtifacts(); | |
| } | |
| }); | |
| // =========== Feedback =========== | |
| let _fbRating = 0; | |
| let _fbEmojis = { | |
| 'fb-recommend': null, | |
| 'fb-security': null, | |
| 'fb-integration': null, | |
| 'fb-ease': null | |
| }; | |
| function setRating(n) { | |
| _fbRating = n; | |
| document.querySelectorAll('.fb-star').forEach(s => { | |
| s.classList.toggle('active', parseInt(s.dataset.v) <= n); | |
| }); | |
| } | |
| function setEmoji(el, qId, val) { | |
| _fbEmojis[qId] = val; | |
| const container = document.getElementById(qId); | |
| if (container) { | |
| container.querySelectorAll('.fb-emoji-btn').forEach(btn => btn.classList.remove('active')); | |
| } | |
| el.classList.add('active'); | |
| } | |
| async function submitFeedback() { | |
| const typeEl = document.getElementById('fb-type'); | |
| const typeText = typeEl.selectedIndex >= 0 ? typeEl.options[typeEl.selectedIndex].text : ""; | |
| const usecaseEl = document.getElementById('fb-usecase'); | |
| const usecaseText = usecaseEl.selectedIndex >= 0 ? usecaseEl.options[usecaseEl.selectedIndex].text : ""; | |
| const text = document.getElementById('fb-text').value.trim(); | |
| const priorities = []; | |
| document.querySelectorAll('#fb-priorities .fb-chip.active').forEach(c => { | |
| priorities.push(c.innerText); | |
| }); | |
| if (_fbRating === 0 && !text && !Object.values(_fbEmojis).some(v => v) && priorities.length === 0) { | |
| showToast("Please provide a star rating or some detailed feedback", "error"); | |
| return; | |
| } | |
| const payload = { | |
| rating: _fbRating, | |
| emojis: _fbEmojis, | |
| type: typeText, | |
| usecase: usecaseText, | |
| priorities: priorities, | |
| details: text, | |
| timestamp: new Date().toISOString() | |
| }; | |
| // Attach authenticated user email if available | |
| const session = (typeof getAuthSession === 'function') ? getAuthSession() : null; | |
| if (session && session.email) { | |
| payload.user_email = session.email; | |
| } | |
| const res = await fetch('api/feedback', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (res.ok) { | |
| showToast('Thank you for your feedback!', 'success'); | |
| document.getElementById('fb-text').value = ''; | |
| // Reset custom uf-select dropdowns | |
| ['fb-usecase', 'fb-type'].forEach(function(id) { | |
| var hidden = document.getElementById(id); | |
| var label = document.getElementById(id + '-label'); | |
| var dropdown = document.getElementById(id + '-dropdown'); | |
| if (hidden) hidden.value = ''; | |
| if (label) { | |
| label.textContent = id === 'fb-usecase' ? 'Select your use case' : 'General Feedback'; | |
| label.style.color = '#666'; | |
| } | |
| if (dropdown) { | |
| dropdown.querySelectorAll('.uf-select-option').forEach(function(opt) { | |
| opt.classList.remove('uf-select-option-active'); | |
| }); | |
| } | |
| }); | |
| document.querySelectorAll('#fb-priorities .fb-chip').forEach(c => c.classList.remove('active')); | |
| // Reset Emojis | |
| Object.keys(_fbEmojis).forEach(k => { | |
| _fbEmojis[k] = null; | |
| const c = document.getElementById(k); | |
| if (c) c.querySelectorAll('.fb-emoji-btn').forEach(b => b.classList.remove('active')); | |
| }); | |
| _fbRating = 0; | |
| setRating(0); | |
| // Start Smooth Cooldown (60 seconds) | |
| const btn = document.getElementById('fb-submit-btn'); | |
| const wrap = document.getElementById('fb-cooldown-wrap'); | |
| const bar = document.getElementById('fb-cooldown-bar'); | |
| btn.disabled = true; | |
| btn.style.opacity = '0.5'; | |
| btn.style.cursor = 'not-allowed'; | |
| btn.innerText = 'Feedback Received'; | |
| wrap.classList.remove('hidden'); | |
| const startTime = Date.now(); | |
| const duration = 60000; | |
| function updateSmoothBar() { | |
| const elapsed = Date.now() - startTime; | |
| const remaining = Math.max(0, duration - elapsed); | |
| bar.style.width = (remaining / duration * 100) + '%'; | |
| if (remaining > 0) { | |
| requestAnimationFrame(updateSmoothBar); | |
| } else { | |
| btn.disabled = false; | |
| btn.style.opacity = '1'; | |
| btn.style.cursor = 'pointer'; | |
| btn.innerText = 'Transmit Feedback'; | |
| wrap.classList.add('hidden'); | |
| bar.style.width = '100%'; | |
| } | |
| } | |
| requestAnimationFrame(updateSmoothBar); | |
| } else { | |
| showToast('Failed to submit — please try again', 'error'); | |
| } | |
| } | |
| // =========== PCU Calculation (client-side mirror) =========== | |
| const PCU_TABLE = {0:1,1:1,2:1,3:1,4:3,5:3,6:1.2,7:0.5,8:3,9:3,10:3,11:0.5,12:1,13:1}; | |
| function calcPCU(classIn, classOut) { | |
| let total = 0; | |
| for (const [k, v] of Object.entries(classIn)) total += (PCU_TABLE[parseInt(k)] || 1) * v; | |
| for (const [k, v] of Object.entries(classOut)) total += (PCU_TABLE[parseInt(k)] || 1) * v; | |
| return Math.round(total * 10) / 10; | |
| } | |
| // =========== Insights Rendering =========== | |
| function renderInsights(d) { | |
| const panel = document.getElementById('insights-panel'); | |
| if (panel) panel.classList.remove('hidden'); | |
| // Congestion insights | |
| const ci = document.getElementById('congestion-insights'); | |
| const pcu = d.pcu || {}; | |
| if (ci) { | |
| ci.innerHTML = [ | |
| infoRow('Total PCU', pcu.total_pcu || 0, 'Passenger Car Units (IRC:106-1990). Normalizes mixed traffic.'), | |
| infoRow('PCU In / Out', `${pcu.pcu_in || 0} / ${pcu.pcu_out || 0}`, 'Directional PCU split.'), | |
| ].join(''); | |
| } | |
| // PCU card in Stats | |
| const pcuCard = document.getElementById('pcu-stats-card'); | |
| if (pcuCard) { | |
| const totalPcu = pcu.total_pcu || 0; | |
| // Capacity label based on total PCU volume | |
| let capLabel, capColor, capNote; | |
| if (totalPcu < 50) { | |
| capLabel = 'Very Low Volume'; capColor = '#22c55e'; | |
| capNote = 'Minimal traffic on this segment'; | |
| } else if (totalPcu < 200) { | |
| capLabel = 'Low Volume'; capColor = '#22c55e'; | |
| capNote = 'Below typical urban arterial demand'; | |
| } else if (totalPcu < 500) { | |
| capLabel = 'Moderate Volume'; capColor = '#c89a6c'; | |
| capNote = 'Typical mixed urban traffic'; | |
| } else if (totalPcu < 900) { | |
| capLabel = 'High Volume'; capColor = '#f97316'; | |
| capNote = 'Approaching single-lane capacity (IRC:106)'; | |
| } else { | |
| capLabel = 'Near/Over Capacity'; capColor = '#ef4444'; | |
| capNote = 'Exceeds IRC single-lane urban reference (1000 PCU)'; | |
| } | |
| // Dominant class from per_class breakdown | |
| let domClass = '—', domFactor = '—'; | |
| if (pcu.per_class && Object.keys(pcu.per_class).length > 0) { | |
| const sorted = Object.entries(pcu.per_class) | |
| .sort((a, b) => b[1].pcu - a[1].pcu); | |
| domClass = sorted[0][0]; | |
| domFactor = sorted[0][1].factor; | |
| } | |
| pcuCard.innerHTML = ` | |
| <div class="w-full space-y-3"> | |
| <div> | |
| <span class="text-base font-black uppercase tracking-wide" | |
| style="color:${capColor}">${capLabel}</span> | |
| <p class="text-[10px] mt-0.5" style="color:#a89f97">${capNote}</p> | |
| <p class="text-[10px] mt-1" style="color:#666"> | |
| Dominant: ${domClass} · PCU factor ${domFactor} | |
| </p> | |
| </div> | |
| <div class="flex justify-between items-center pt-1"> | |
| <span class="text-xs font-medium text-slate-500">Total PCU</span> | |
| <span class="text-2xl font-black" style="color:#8b5e3c">${totalPcu}</span> | |
| </div> | |
| <div class="flex gap-3"> | |
| <div class="flex-1 bg-green-950 rounded-lg p-2.5 text-center border border-green-900"> | |
| <div class="text-lg font-bold text-green-400">${pcu.pcu_in || 0}</div> | |
| <div class="text-[9px] font-bold text-green-600 uppercase">PCU In</div> | |
| </div> | |
| <div class="flex-1 bg-red-950 rounded-lg p-2.5 text-center border border-red-900"> | |
| <div class="text-lg font-bold text-red-400">${pcu.pcu_out || 0}</div> | |
| <div class="text-[9px] font-bold text-red-600 uppercase">PCU Out</div> | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| } | |
| // =========== Run Details helpers =========== | |
| function detailRow(label, value, extra) { | |
| extra = extra || ''; | |
| return `<div class="flex justify-between items-center border-b border-slate-800 pb-2"> | |
| <span class="text-xs font-medium text-slate-500 mono-font">${label}</span> | |
| <span class="text-sm font-bold text-white">${value}${extra}</span> | |
| </div>`; | |
| } | |
| function infoRow(label, value, tip, extra) { | |
| extra = extra || ''; | |
| return `<div class="flex justify-between items-center border-b border-slate-800 pb-2 relative"> | |
| <span class="text-xs font-medium text-slate-500 mono-font flex items-center">${label} | |
| <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span> | |
| <span class="info-tip">${tip}</span></span> | |
| </span> | |
| <span class="text-sm font-bold text-white">${value}${extra}</span> | |
| </div>`; | |
| } | |
| function boolBadge(val) { | |
| if (val) return `<span class="inline-flex items-center bg-green-50 text-green-700 text-[10px] font-bold px-2 py-0.5 rounded border border-green-200"><i class="fa-solid fa-check mr-1"></i>TRUE</span>`; | |
| return `<span class="text-[10px] font-bold text-slate-300">FALSE</span>`; | |
| } | |
| function populateRunDetails(c) { | |
| const res = c.resolution || [0, 0]; | |
| document.getElementById('panel-video').innerHTML = | |
| detailRow('video_fps', (c.video_fps || 0).toFixed(2)) + | |
| detailRow('frames', c.frames) + | |
| detailRow('duration', (c.duration || 0).toFixed(2) + ' sec') + | |
| detailRow('resolution', res[0] + ' <span class="text-slate-400 text-xs">x</span> ' + res[1]) + | |
| detailRow('pixels', (c.pixels || 0).toLocaleString()); | |
| const cpuPct = Math.min(100, Math.round((c.cpu_score / 10) * 100)); | |
| document.getElementById('panel-perf').innerHTML = | |
| `<div class="flex justify-between items-center border-b border-slate-800 pb-2 relative"> | |
| <span class="text-xs font-medium text-slate-500 mono-font flex items-center">cpu_score | |
| <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span> | |
| <span class="info-tip">Available CPU core count used for throughput estimation.</span></span> | |
| </span> | |
| <div class="flex items-center"> | |
| <span class="text-sm font-bold text-white mr-2">${c.cpu_score}</span> | |
| <div class="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden"> | |
| <div class="h-full bg-emerald-500" style="width:${cpuPct}%"></div> | |
| </div> | |
| </div> | |
| </div>` + | |
| infoRow('model_fps_est', c.model_fps_est, 'Estimated model inference throughput based on CPU benchmark.', ' <span class="text-xs text-slate-400 font-normal">fps</span>') + | |
| infoRow('effective_fps', c.effective_fps_est, 'Adjusted throughput accounting for frame stride.', ' <span class="text-xs text-slate-400 font-normal">fps</span>'); | |
| document.getElementById('panel-model').innerHTML = | |
| `<div class="flex justify-between items-center border-b border-slate-800 pb-2"> | |
| <span class="text-xs font-medium text-slate-500 mono-font">model</span> | |
| <a href="https://huggingface.co/Perception365/VehicleNet-Y26s" target="_blank" class="text-sm font-bold text-white mono-font hover:text-slate-300 transition underline underline-offset-4 decoration-slate-700">Perception365/VehicleNet-Y26s</a> | |
| </div>` + | |
| detailRow('task', 'detect') + | |
| detailRow('format', 'OpenVINO') + | |
| detailRow('tracker', 'ByteTrack'); | |
| populateInferPanel(c); | |
| } | |
| function populateInferPanel(c) { | |
| document.getElementById('panel-infer').innerHTML = | |
| infoRow('imgsz', c.imgsz, 'Input image resolution for model inference.') + | |
| infoRow('vid_stride', c.detect_stride, 'Frames skipped between consecutive detections relative to the spatial boundary.') + | |
| infoRow('conf', (c.conf || 0.12).toFixed(2), 'Minimum confidence threshold for valid detections.') + | |
| infoRow('iou', (c.iou || 0.60).toFixed(2), 'Intersection-over-Union threshold for non-max suppression.') + | |
| infoRow('stream', 'TRUE', 'Frame-by-frame processing for constant memory usage.') + | |
| infoRow('verbose', 'FALSE', 'Console logging suppressed during inference.'); | |
| } | |
| // =========== Palettes =========== | |
| const PALETTES = { | |
| default: { congestion: '#f97316', congestionBg: 'rgba(249,115,22,0.08)', dominance: '#14b8a6', flow: '#3b82f6', doughIn: '#3b82f6', doughOut: '#f97316' }, | |
| vibrant: { congestion: '#ff2d55', congestionBg: 'rgba(255,45,85,0.08)', dominance: '#a855f7', flow: '#22d3ee', doughIn: '#22d3ee', doughOut: '#ff2d55' }, | |
| corporate: { congestion: '#1e40af', congestionBg: 'rgba(30,64,175,0.08)', dominance: '#0d9488', flow: '#475569', doughIn: '#475569', doughOut: '#1e40af' }, | |
| neon: { congestion: '#f0abfc', congestionBg: 'rgba(240,171,252,0.08)', dominance: '#22d3ee', flow: '#a855f7', doughIn: '#a855f7', doughOut: '#f0abfc' }, | |
| earth: { congestion: '#84cc16', congestionBg: 'rgba(132,204,22,0.08)', dominance: '#22c55e', flow: '#f59e0b', doughIn: '#f59e0b', doughOut: '#84cc16' }, | |
| ocean: { congestion: '#06b6d4', congestionBg: 'rgba(6,182,212,0.08)', dominance: '#3b82f6', flow: '#14b8a6', doughIn: '#14b8a6', doughOut: '#06b6d4' }, | |
| sunset: { congestion: '#f59e0b', congestionBg: 'rgba(245,158,11,0.08)', dominance: '#f43f5e', flow: '#fb923c', doughIn: '#fb923c', doughOut: '#f59e0b' }, | |
| midnight: { congestion: '#38bdf8', congestionBg: 'rgba(56,189,248,0.08)', dominance: '#1d4ed8', flow: '#f8fafc', doughIn: '#f8fafc', doughOut: '#38bdf8' }, | |
| gold: { congestion: '#fbbf24', congestionBg: 'rgba(251,191,36,0.08)', dominance: '#a8a29e', flow: '#f5f5f4', doughIn: '#f5f5f4', doughOut: '#fbbf24' } | |
| }; | |
| // =========== Global State (Initialized in initApp) =========== | |
| let currentPalette = 'default'; | |
| let activePalette = PALETTES.default; | |
| let MODEL_CLASSES = {}; | |
| let BUSINESS_MAP = {}; | |
| let congChart, doughChart, domChart, flowChart; | |
| async function initApp() { | |
| // Read settings from sessionStorage | |
| const rawRun = sessionStorage.getItem('funky_run'); | |
| const runSettings = rawRun ? (JSON.parse(rawRun).settings || {}) : {}; | |
| currentPalette = runSettings.palette || 'default'; | |
| activePalette = PALETTES[currentPalette]; | |
| // =========== Charts =========== | |
| Chart.defaults.font.family = "'Montserrat', sans-serif"; | |
| Chart.defaults.color = '#888888'; | |
| Chart.defaults.borderColor = '#222222'; | |
| Chart.defaults.plugins.tooltip.backgroundColor = '#0a0a0a'; | |
| Chart.defaults.plugins.tooltip.titleColor = '#ffffff'; | |
| Chart.defaults.plugins.tooltip.bodyColor = '#aaaaaa'; | |
| Chart.defaults.plugins.tooltip.borderColor = '#222222'; | |
| Chart.defaults.plugins.tooltip.borderWidth = 1; | |
| congChart = new Chart(document.getElementById('congestionChart').getContext('2d'), { | |
| type: 'line', | |
| data: { | |
| labels: [], datasets: [{ | |
| data: [], | |
| borderColor: activePalette.congestion, | |
| backgroundColor: activePalette.congestionBg, | |
| fill: true, | |
| tension: 0.2, | |
| borderWidth: 1.5, | |
| pointRadius: 0 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| x: { grid: { display: false }, ticks: { maxTicksLimit: 10, font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Frame Index', font: { size: 10, weight: '700' }, color: '#888888' } }, | |
| y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Active Vehicles', font: { size: 10, weight: '700' }, color: '#888888' } } | |
| }, | |
| animation: { duration: 0 } | |
| }, | |
| plugins: [] | |
| }); | |
| doughChart = new Chart(document.getElementById('doughnutChart').getContext('2d'), { | |
| type: 'doughnut', | |
| data: { | |
| labels: ['Incoming', 'Outgoing'], datasets: [{ | |
| data: [0, 0], | |
| backgroundColor: [activePalette.doughIn, activePalette.doughOut], | |
| borderColor: '#0a0a0a', | |
| borderWidth: 3, | |
| hoverOffset: 6 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| cutout: '68%', | |
| plugins: { | |
| legend: { display: true, position: 'bottom', labels: { padding: 12, usePointStyle: true, pointStyle: 'circle', font: { size: 10, weight: '600' } } } | |
| }, | |
| animation: { duration: 0 } | |
| }, | |
| plugins: [] | |
| }); | |
| domChart = new Chart(document.getElementById('dominanceChart').getContext('2d'), { | |
| type: 'bar', | |
| data: { labels: [], datasets: [{ data: [], backgroundColor: activePalette.dominance, borderRadius: 2 }] }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| layout: { padding: { bottom: 12 } }, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| x: { grid: { display: false }, ticks: { font: { size: 9, weight: '500' }, color: '#666666', maxRotation: 45, minRotation: 30, autoSkip: false } }, | |
| y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Total Vehicle Count', font: { size: 10, weight: '700' }, color: '#888888' } } | |
| }, | |
| animation: { duration: 0 } | |
| }, | |
| plugins: [] | |
| }); | |
| flowChart = new Chart(document.getElementById('flowChart').getContext('2d'), { | |
| type: 'bar', | |
| data: { labels: [], datasets: [{ data: [], backgroundColor: activePalette.flow, borderColor: '#0a0a0a', borderWidth: 1.5, barPercentage: 1.0, categoryPercentage: 1.0 }] }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| x: { grid: { display: false }, ticks: { maxTicksLimit: 10, font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Time (seconds)', font: { size: 10, weight: '700' }, color: '#888888' } }, | |
| y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Vehicles Crossed', font: { size: 10, weight: '700' }, color: '#888888' } } | |
| }, | |
| animation: { duration: 0 } | |
| }, | |
| plugins: [] | |
| }); | |
| // Original init() logic | |
| const raw = sessionStorage.getItem('funky_run'); | |
| if (!raw) { | |
| if (typeof showOnboardingPhase === 'function') { | |
| showOnboardingPhase(); | |
| } else { | |
| window.location.replace('/'); | |
| } | |
| return; | |
| } | |
| _params = JSON.parse(raw); | |
| const cRes = await fetch('constants'); | |
| const cData = await cRes.json(); | |
| MODEL_CLASSES = cData.classes; | |
| BUSINESS_MAP = cData.business_map; | |
| populateAndInit(_params); | |
| sessionStorage.removeItem('funky_run'); | |
| // Sync current active tab from session if set | |
| const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings'; | |
| switchTab(activeTab); | |
| // Populate auth profile UI | |
| populateProfileUI(); | |
| } | |
| // =========== Update functions =========== | |
| function sumValues(obj) { return Object.values(obj).reduce((a, b) => a + b, 0); } | |
| // =========== Live Palette Switching =========== | |
| function applyPalette(key) { | |
| activePalette = PALETTES[key] || PALETTES.default; | |
| currentPalette = key; | |
| // Congestion | |
| congChart.data.datasets[0].borderColor = activePalette.congestion; | |
| congChart.data.datasets[0].backgroundColor = activePalette.congestionBg; | |
| congChart.update(); | |
| // Doughnut | |
| doughChart.data.datasets[0].backgroundColor = [activePalette.doughIn, activePalette.doughOut]; | |
| doughChart.update(); | |
| // Dominance | |
| domChart.data.datasets[0].backgroundColor = activePalette.dominance; | |
| domChart.update(); | |
| // Flow | |
| flowChart.data.datasets[0].backgroundColor = activePalette.flow; | |
| flowChart.update(); | |
| // Update palette dropdown in progress bar | |
| const barSel = document.getElementById('live-palette-select'); | |
| if (barSel) barSel.value = key; | |
| } | |
| function renderPalettePreview(key) { | |
| const pal = PALETTES[key]; | |
| const colors = [ | |
| { color: pal.congestion, label: 'Congestion' }, | |
| { color: pal.dominance, label: 'Dominance' }, | |
| { color: pal.flow, label: 'Flow' }, | |
| { color: pal.doughIn, label: 'Incoming' }, | |
| { color: pal.doughOut, label: 'Outgoing' } | |
| ]; | |
| const el = document.getElementById('live-palette-preview'); | |
| if (el) { | |
| el.innerHTML = colors.map(c => | |
| `<div class="flex-1 rounded-lg overflow-hidden border border-neutral-800"> | |
| <div class="h-6" style="background:${c.color}"></div> | |
| <div class="text-[8px] font-bold text-neutral-500 text-center py-1 bg-neutral-900">${c.label}</div> | |
| </div>` | |
| ).join(''); | |
| } | |
| } | |
| function populateSettingsTab(config, settings) { | |
| // Populate stepper values from config | |
| document.getElementById('sv-imgsz').textContent = config.imgsz || 640; | |
| document.getElementById('sv-conf').textContent = (config.conf || 0.12).toFixed(2); | |
| document.getElementById('sv-iou').textContent = (config.iou || 0.60).toFixed(2); | |
| document.getElementById('sv-stride').textContent = config.detect_stride || 2; | |
| // Populate export settings | |
| const selReport = document.getElementById('sv-report'); | |
| if (selReport) selReport.value = settings.reportFormat || 'png'; | |
| const togAnnot = document.getElementById('sv-annotated'); | |
| if (togAnnot && settings.annotatedVideo) togAnnot.classList.add('active'); | |
| // Set live palette dropdown | |
| const sel = document.getElementById('live-palette-select'); | |
| if (sel) sel.value = currentPalette; | |
| renderPalettePreview(currentPalette); | |
| } | |
| // =========== Settings Stepper Logic =========== | |
| const PARAM_LIMITS = { | |
| imgsz: { min: 640, max: 1280 }, | |
| conf: { min: 0.10, max: 0.95 }, | |
| iou: { min: 0.50, max: 0.95 }, | |
| stride: { min: 1, max: 10 }, | |
| smoothing: { min: 0.05, max: 0.95 } | |
| }; | |
| function stepParam(param, delta) { | |
| const el = document.getElementById('sv-' + param); | |
| if (!el) return; | |
| const limits = PARAM_LIMITS[param]; | |
| let val = parseFloat(el.textContent); | |
| val = Math.round((val + delta) * 100) / 100; | |
| val = Math.max(limits.min, Math.min(limits.max, val)); | |
| el.textContent = (param === 'conf' || param === 'iou' || param === 'smoothing') ? val.toFixed(2) : val; | |
| } | |
| function lockSettings() { | |
| document.querySelectorAll('#settings-params .s-row').forEach(row => { | |
| const p = row.dataset.param; | |
| if (p && p !== 'palette') { | |
| row.classList.add('disabled'); | |
| } | |
| }); | |
| const reportRow = document.getElementById('sv-report'); | |
| if (reportRow) reportRow.closest('.s-row').classList.add('disabled'); | |
| // Also directly hide the wrap and close dropdown — CSS may be cached | |
| const svWrap = document.getElementById('sv-report-wrap'); | |
| if (svWrap) { | |
| svWrap.style.pointerEvents = 'none'; | |
| svWrap.style.opacity = '0.4'; | |
| const dd = document.getElementById('sv-report-dropdown'); | |
| if (dd) dd.classList.add('hidden'); | |
| } | |
| const annotatedRow = document.getElementById('sv-annotated'); | |
| if (annotatedRow) annotatedRow.closest('.s-row').classList.add('disabled'); | |
| const wrap = document.getElementById('settings-start-wrap'); | |
| if (wrap) wrap.style.display = 'none'; | |
| } | |
| function startNewAnalysis() { | |
| sessionStorage.clear(); | |
| _params = null; | |
| // SPA Router | |
| if (typeof showOnboardingPhase === 'function') { | |
| showOnboardingPhase(); | |
| } else { | |
| window.location.replace('/'); | |
| } | |
| } | |
| function updateBreakdown(classIn, classOut) { | |
| const container = document.getElementById('class-breakdown'); | |
| const totalAll = sumValues(classIn) + sumValues(classOut); | |
| container.innerHTML = ''; | |
| // Update Doughnut border logic (remove gap if unidirectional) | |
| const sumIn = sumValues(classIn); | |
| const sumOut = sumValues(classOut); | |
| doughChart.data.datasets[0].borderWidth = (sumIn === 0 || sumOut === 0) ? 0 : 3; | |
| doughChart.data.datasets[0].data = [sumIn, sumOut]; | |
| doughChart.update(); | |
| document.getElementById('cnt-total').innerText = totalAll; | |
| Object.keys(MODEL_CLASSES).map(Number).sort((a, b) => a - b).forEach(id => { | |
| const inC = classIn[String(id)] || 0; | |
| const outC = classOut[String(id)] || 0; | |
| const total = inC + outC; | |
| const pct = totalAll > 0 ? ((total / totalAll) * 100).toFixed(1) : '0.0'; | |
| const row = document.createElement('div'); | |
| row.className = 'flex items-center justify-between text-xs py-2 border-b border-slate-800'; | |
| row.innerHTML = ` | |
| <div class="w-[30%] font-bold text-white truncate" title="${MODEL_CLASSES[id]}">${MODEL_CLASSES[id]}</div> | |
| <div class="w-[20%] text-slate-500 text-[11px]">${total} total</div> | |
| <div class="w-[15%] text-slate-500 text-[11px]"><i class="fa-solid fa-arrow-down text-[9px] mr-1"></i>${inC}</div> | |
| <div class="w-[15%] text-slate-500 text-[11px]"><i class="fa-solid fa-arrow-up text-[9px] mr-1"></i>${outC}</div> | |
| <div class="w-[20%] text-right font-bold text-white">${pct}%</div> | |
| `; | |
| container.appendChild(row); | |
| }); | |
| } | |
| function updateDominance(classIn, classOut) { | |
| const labels = [], values = []; | |
| for (const [group, ids] of Object.entries(BUSINESS_MAP)) { | |
| let total = 0; | |
| ids.forEach(id => { total += (classIn[String(id)] || 0) + (classOut[String(id)] || 0); }); | |
| labels.push(group); | |
| values.push(total); | |
| } | |
| domChart.data.labels = labels; | |
| domChart.data.datasets[0].data = values; | |
| domChart.update(); | |
| } | |
| function buildFlowHistogram(flowTimes, videoDuration) { | |
| const binCount = Math.max(1, Math.ceil(videoDuration)); | |
| const bins = new Array(binCount).fill(0); | |
| const labels = []; | |
| for (let i = 0; i < binCount; i++) labels.push(i); | |
| flowTimes.forEach(t => { bins[Math.min(Math.floor(t), binCount - 1)]++; }); | |
| flowChart.data.labels = labels; | |
| flowChart.data.datasets[0].data = bins; | |
| flowChart.update(); | |
| } | |
| let _alpha = 0.25; | |
| function updateCongestion(congestion, stride) { | |
| let data = congestion; | |
| // Apply EMA smoothing if alpha is less than 1 (1 = no smoothing) | |
| if (_alpha < 0.99) { | |
| data = []; | |
| let s = congestion[0] || 0; | |
| for (let v of congestion) { | |
| s = _alpha * v + (1 - _alpha) * s; | |
| data.push(s); | |
| } | |
| } | |
| const len = data.length; | |
| if (len <= 200) { | |
| congChart.data.labels = data.map((_, i) => i * stride); | |
| congChart.data.datasets[0].data = data; | |
| } else { | |
| // Dynamic sampling to keep chart performance high | |
| const step = Math.ceil(len / 200); | |
| const sampled = [], labels = []; | |
| for (let i = 0; i < len; i += step) { | |
| labels.push(i * stride); | |
| sampled.push(data[i]); | |
| } | |
| congChart.data.labels = labels; | |
| congChart.data.datasets[0].data = sampled; | |
| } | |
| congChart.update(); | |
| } | |
| // =========== Main =========== | |
| let _params = null; | |
| function populateAndInit(params) { | |
| populateRunDetails(params.config); | |
| populateSettingsTab(params.config, params.settings || {}); | |
| } | |
| function startProcessingFromSettings() { | |
| if (!_params) return; | |
| // Read current stepper/control values | |
| const imgsz = parseInt(document.getElementById('sv-imgsz').textContent); | |
| const conf = parseFloat(document.getElementById('sv-conf').textContent); | |
| const iou = parseFloat(document.getElementById('sv-iou').textContent); | |
| const stride = parseInt(document.getElementById('sv-stride').textContent); | |
| const reportFmt = document.getElementById('sv-report').value; | |
| const annotated = document.getElementById('sv-annotated').classList.contains('active'); | |
| // Annotation Options | |
| const annotated_options = { | |
| bbox: true, // Always true if export is enabled | |
| spatial: document.getElementById('chip-spatial').classList.contains('active'), | |
| class_name: document.getElementById('chip-class_name').classList.contains('active'), | |
| class_id: document.getElementById('chip-class_id').classList.contains('active'), | |
| track_id: document.getElementById('chip-track_id').classList.contains('active') | |
| }; | |
| const exportJson = document.getElementById('sv-export-json').classList.contains('active'); | |
| const exportCsv = document.getElementById('sv-export-csv').classList.contains('active'); | |
| // Apply to config | |
| _params.config.imgsz = imgsz; | |
| _params.config.conf = conf; | |
| _params.config.iou = iou; | |
| _params.config.detect_stride = stride; | |
| // Reflect final resolved params in Run tab | |
| populateInferPanel(_params.config); | |
| // Lock settings | |
| lockSettings(); | |
| // Freeze annotation chips during processing | |
| const chipSelector = document.getElementById('chip-selector'); | |
| if (chipSelector) { | |
| chipSelector.style.pointerEvents = 'none'; | |
| chipSelector.style.opacity = '0.5'; | |
| } | |
| // Switch to overview | |
| switchTab('overview'); | |
| document.getElementById('proc-label').innerText = 'Connecting...'; | |
| // Analytics Funnel | |
| if (typeof trackFunnel === 'function') { | |
| trackFunnel('PROCESS_STARTED'); | |
| } | |
| // Reset Run Tab Results to Awaiting | |
| document.getElementById('run-results-content').innerHTML = ` | |
| <div class="flex flex-col items-center justify-center p-8 bg-black/40 border border-slate-800 rounded-2xl col-span-3 text-slate-500"> | |
| <i class="fa-solid fa-spinner fa-spin text-2xl mb-3 text-white"></i> | |
| <span class="text-xs font-semibold">Executing inference pipeline... results pending</span> | |
| </div>`; | |
| // Update Results tab pending message | |
| const repIcon = document.getElementById('reports-pending-icon'); | |
| if (repIcon) { | |
| repIcon.className = 'fa-solid fa-satellite-dish animate-pulse text-5xl mb-2'; | |
| repIcon.style.color = '#c89a6c'; | |
| } | |
| const repText = document.getElementById('reports-pending-text'); | |
| if (repText) repText.innerText = 'Transmission in progress...'; | |
| // Start WebSocket | |
| const videoDuration = _params.config.duration || 10; | |
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | |
| // Get the directory path (e.g., /app/ or /) rather than the full filename | |
| const dirPath = location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1); | |
| const ws = new WebSocket(`${proto}://${location.host}${dirPath}ws/run`); | |
| ws.onopen = () => { | |
| ws.send(JSON.stringify({ | |
| video_id: _params.video_id, | |
| line: _params.line, | |
| config: _params.config, | |
| annotated_video: annotated, | |
| annotated_options: annotated_options, | |
| export_json: exportJson, | |
| export_csv: exportCsv, | |
| report_format: reportFmt | |
| })); | |
| }; | |
| ws.onerror = e => { | |
| console.error('WS Error:', e); | |
| document.getElementById('proc-label').innerText = 'Connection Error'; | |
| showToast('Connection error — server may be busy', 'error'); | |
| }; | |
| let processingDone = false; | |
| let firstMessageReceived = false; | |
| ws.onclose = () => { | |
| console.log('WS Closed'); | |
| if (!processingDone) { | |
| // Closed before done=True received — show error state | |
| document.getElementById('proc-label').innerText = 'Disconnected'; | |
| document.getElementById('run-results-content').innerHTML = ` | |
| <div class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-red-900/40 rounded-2xl col-span-3 text-slate-400"> | |
| <i class="fa-solid fa-triangle-exclamation text-2xl mb-3 text-red-400"></i> | |
| <span class="text-xs font-semibold mb-1">Processing connection was lost.</span> | |
| <span class="text-[10px] text-slate-500 mb-4">The server may have timed out or restarted. Please try again.</span> | |
| <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c"> | |
| <i class="fa-solid fa-house mr-1"></i> Home | |
| </button> | |
| </div>`; | |
| } | |
| }; | |
| let lastUIUpdate = 0; | |
| let liveCongestion = []; | |
| let liveFlowTimes = []; | |
| ws.onmessage = e => { | |
| const d = JSON.parse(e.data); | |
| if (!firstMessageReceived) { | |
| firstMessageReceived = true; | |
| document.getElementById('proc-label').innerText = 'Processing'; | |
| } | |
| // Hide empty state on first data | |
| const emptyState = document.getElementById('stats-empty-state'); | |
| if (emptyState) emptyState.style.display = 'none'; | |
| if (d.error) { | |
| processingDone = true; | |
| document.getElementById('proc-label').innerText = 'Engine Error'; | |
| console.error('[UrbanFlow] Engine error:', d.detail || d.error); | |
| document.getElementById('run-results-content').innerHTML = ` | |
| <div class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-red-900/40 rounded-2xl col-span-3 text-slate-400"> | |
| <i class="fa-solid fa-triangle-exclamation text-2xl mb-3 text-red-400"></i> | |
| <span class="text-xs font-semibold mb-1">Inference pipeline failed.</span> | |
| <span class="text-[10px] text-slate-500 mb-4 text-center max-w-xs">${d.error}</span> | |
| <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c"> | |
| <i class="fa-solid fa-house mr-1"></i> Home | |
| </button> | |
| </div>`; | |
| return; | |
| } | |
| if (d.done) { | |
| processingDone = true; | |
| // Stats Tracking (Scoped by email) | |
| const session = (typeof getAuthSession === 'function') ? getAuthSession() : null; | |
| const emailKey = session ? `_${session.email}` : ''; | |
| let currentRuns = parseInt(localStorage.getItem(`uf_total_runs${emailKey}`) || '0'); | |
| localStorage.setItem(`uf_total_runs${emailKey}`, currentRuns + 1); | |
| localStorage.setItem(`uf_last_active${emailKey}`, new Date().toLocaleString()); | |
| document.getElementById('proc-label').innerText = 'Complete'; | |
| document.getElementById('proc-bar').style.width = '100%'; | |
| document.getElementById('proc-pct').innerText = '100%'; | |
| // Force frame counter to n/n | |
| const framesEl = document.getElementById('proc-frames'); | |
| if (framesEl) { | |
| const parts = framesEl.innerText.split('/'); | |
| if (parts.length === 2) { | |
| const total = parts[1].trim().replace(' Frames', ''); | |
| framesEl.innerText = `${total} / ${total} Frames`; | |
| } | |
| } | |
| // GLOW NOTIFICATION: Let the user know artifacts are ready | |
| const resultsMob = document.getElementById('mob-nav-results'); | |
| if (resultsMob) { | |
| resultsMob.classList.add('notify-glow'); | |
| } | |
| // Show results content immediately (telemetry first, reports load async) | |
| const rPendingMsg = document.getElementById('reports-pending-message'); | |
| if (rPendingMsg) rPendingMsg.classList.add('hidden'); | |
| const rContentWrap = document.getElementById('results-content-wrap'); | |
| if (rContentWrap) rContentWrap.classList.remove('hidden'); | |
| document.getElementById('run-results-content').innerHTML = | |
| detailRow('Inference Time', (d.processing_time || 0).toFixed(2) + ' sec') + | |
| infoRow('Throughput (FPS)', (d.actual_fps || 0).toFixed(2), 'Measured frame throughput during processing.', ' <span class="text-xs text-slate-400 font-normal">fps</span>') + | |
| infoRow('Real-time Ratio', (d.speed_vs_realtime || 0).toFixed(2) + 'x', 'Processing speed relative to video playback rate.'); | |
| if (d.video_id) { | |
| loadReports(d.video_id).then(data => { | |
| if (!data) return; | |
| // Auto-Download Logic (Respects live toggle state) | |
| if (document.getElementById('sv-auto-download').classList.contains('active')) { | |
| // Download the full bundle ZIP via direct navigation | |
| setTimeout(() => { | |
| console.log('[UrbanFlow] Fetching ZIP bundle for:', d.video_id); | |
| window.open(`bundle/${d.video_id}`, '_blank'); | |
| }, 1000); | |
| } | |
| }); | |
| } | |
| // Disable Auto-Download toggle after completion | |
| const adToggle = document.getElementById('sv-auto-download'); | |
| if (adToggle) { | |
| adToggle.closest('.s-row').classList.add('disabled'); | |
| } | |
| const jsonToggle = document.getElementById('sv-export-json'); | |
| if (jsonToggle) { | |
| jsonToggle.closest('.s-row').classList.add('disabled'); | |
| } | |
| // NOTIFY USER: Glow the results icon in mobile nav | |
| const resultsNav = document.getElementById('mob-nav-results'); | |
| if (resultsNav) { | |
| resultsNav.classList.add('notify-glow'); | |
| } | |
| const csvToggle = document.getElementById('sv-export-csv'); | |
| if (csvToggle) { | |
| csvToggle.closest('.s-row').classList.add('disabled'); | |
| } | |
| // Show New Analysis button in Settings | |
| const newWrap = document.getElementById('new-analysis-wrap'); | |
| if (newWrap) newWrap.classList.remove('hidden'); | |
| // Toast + Insights | |
| showToast('Processing complete — artifacts ready', 'success'); | |
| renderInsights(d); | |
| showRetryBubble(); | |
| // Store video_id for keyboard shortcut download | |
| document.body.setAttribute('data-last-video-id', d.video_id); | |
| return; | |
| } | |
| let pct = ((d.frame_index / d.total_iters) * 100).toFixed(1); | |
| if (d.frame_index >= d.total_iters - 1) pct = '100.0'; | |
| document.getElementById('proc-bar').style.width = pct + '%'; | |
| document.getElementById('proc-frames').innerText = `${d.frame_index} / ${d.total_iters} Frames`; | |
| const procPctEl = document.getElementById('proc-pct'); | |
| const currPct = parseFloat(procPctEl.innerText) || 0; | |
| animateValue(procPctEl, currPct, pct + '%', 300); | |
| const totalIn = sumValues(d.class_in); | |
| const totalOut = sumValues(d.class_out); | |
| const cntTotalEl = document.getElementById('cnt-total'); | |
| const currTotal = parseInt(cntTotalEl.innerText) || 0; | |
| animateValue(cntTotalEl, currTotal, totalIn + totalOut, 300); | |
| // Update PCU display | |
| const pcuVal = calcPCU(d.class_in, d.class_out); | |
| const pcuEl = document.getElementById('cnt-pcu'); | |
| if (pcuEl) pcuEl.innerText = pcuVal; | |
| // Update doughnut | |
| doughChart.data.datasets[0].data = [totalIn, totalOut]; | |
| doughChart.update(); | |
| // Live history management (Backend sends incremental updates now) | |
| if (d.congestion_last !== undefined) { | |
| liveCongestion.push(d.congestion_last); | |
| } | |
| if (d.flow_count !== undefined) { | |
| // Approximate timestamps for new crossings based on current frame | |
| const videoFps = _params.config.video_fps || 30; | |
| while (liveFlowTimes.length < d.flow_count) { | |
| liveFlowTimes.push(d.frame_index * stride / videoFps); | |
| } | |
| } | |
| const now = performance.now(); | |
| if (now - lastUIUpdate < 300) return; | |
| lastUIUpdate = now; | |
| updateCongestion(liveCongestion, stride); | |
| updateBreakdown(d.class_in, d.class_out); | |
| updateDominance(d.class_in, d.class_out); | |
| buildFlowHistogram(liveFlowTimes, videoDuration); | |
| }; | |
| } | |
| const REPORT_LABELS = { | |
| 'direction_pie.png': { title: 'Directional Split', desc: 'Incoming vs outgoing vehicle ratio' }, | |
| 'direction_pie.pdf': { title: 'Directional Split', desc: 'Incoming vs outgoing vehicle ratio' }, | |
| 'flow_over_time.png': { title: 'Traffic Flow', desc: 'Vehicles crossing the line over time' }, | |
| 'flow_over_time.pdf': { title: 'Traffic Flow', desc: 'Vehicles crossing the line over time' }, | |
| 'congestion_index.png': { title: 'Congestion Index', desc: 'Active vehicles per frame with smoothing' }, | |
| 'congestion_index.pdf': { title: 'Congestion Index', desc: 'Active vehicles per frame with smoothing' }, | |
| 'class_dominance.png': { title: 'Class Dominance', desc: 'Vehicle count by classification type' }, | |
| 'class_dominance.pdf': { title: 'Class Dominance', desc: 'Vehicle count by classification type' }, | |
| 'confidence_dist.png': { title: 'Confidence Curve', desc: 'Distribution of detection confidences' }, | |
| 'confidence_dist.pdf': { title: 'Confidence Curve', desc: 'Distribution of detection confidences' }, | |
| 'annotated.mp4': { title: 'Annotated Video Export', desc: 'Rendered video with tracking overlays' }, | |
| 'heatmap.png': { title: 'Detection Confidence Density Map', desc: 'xAI spatial explanation — confidence-weighted Gaussian kernel density over all detections' }, | |
| 'heatmap.pdf': { title: 'Detection Confidence Density Map', desc: 'xAI spatial explanation — confidence-weighted Gaussian kernel density over all detections' }, | |
| 'raw_data.csv': { title: 'Raw Analytics Export', desc: 'Comma-separated values of all crossings' }, | |
| 'analysis.json': { title: 'Structured JSON Export', desc: 'Complete analysis data with metadata for API consumption' } | |
| }; | |
| async function loadReports(videoId) { | |
| const res = await fetch(`reports/${videoId}`, { method: 'POST' }); | |
| const data = await res.json(); | |
| if (!data.files || !data.files.length) return null; | |
| const rPending = document.getElementById('reports-pending'); | |
| if (rPending) rPending.classList.add('hidden'); | |
| const rMsg = document.getElementById('reports-pending-message'); | |
| if (rMsg) rMsg.classList.add('hidden'); | |
| document.getElementById('post-process-cards').classList.remove('hidden'); | |
| const grid = document.getElementById('reports-grid'); | |
| grid.classList.remove('hidden'); | |
| const mobileBtn = document.getElementById('mobile-download-wrap'); | |
| if (mobileBtn) mobileBtn.classList.remove('hidden'); | |
| grid.innerHTML = ''; | |
| data.files.forEach(name => { | |
| const info = REPORT_LABELS[name] || { title: name, desc: '' }; | |
| const url = `reports/${videoId}/${name}`; | |
| const isVideo = name.endsWith('.mp4'); | |
| const isPDF = name.endsWith('.pdf'); | |
| const isCSV = name.endsWith('.csv'); | |
| const card = document.createElement('div'); | |
| card.className = 'bg-black rounded-xl border border-slate-800 shadow-sm flex flex-col overflow-hidden'; | |
| let previewHTML = ''; | |
| if (isVideo) { | |
| previewHTML = ` | |
| <div class="flex flex-col items-center justify-center py-12 text-slate-700"> | |
| <i class="fa-solid fa-film text-6xl mb-4 text-white"></i> | |
| <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Video Ready for Local Analysis</span> | |
| </div>`; | |
| } else if (isPDF) { | |
| previewHTML = ` | |
| <div class="flex flex-col items-center justify-center py-12 text-slate-700"> | |
| <i class="fa-solid fa-file-pdf text-6xl mb-4 text-white"></i> | |
| <span class="text-xs font-bold uppercase tracking-widest text-slate-500">PDF Document</span> | |
| </div>`; | |
| } else if (isCSV) { | |
| previewHTML = ` | |
| <div class="flex flex-col items-center justify-center py-12 text-slate-700"> | |
| <i class="fa-solid fa-file-csv text-6xl mb-4 text-white"></i> | |
| <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Raw Analytics Export</span> | |
| </div>`; | |
| } else if (name.endsWith('.json')) { | |
| previewHTML = ` | |
| <div class="flex flex-col items-center justify-center py-12 text-slate-700"> | |
| <i class="fa-solid fa-code text-6xl mb-4 text-white"></i> | |
| <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Structured JSON</span> | |
| </div>`; | |
| } else { | |
| previewHTML = `<img src="${url}" alt="${info.title}" class="max-w-full max-h-[320px] object-contain rounded">`; | |
| } | |
| const isHeatmap = name.includes('heatmap'); | |
| const tooltipHTML = isHeatmap ? ` | |
| <span class="info-wrap"> | |
| <span class="info-btn"><i class="fa-solid fa-info"></i></span> | |
| <span class="info-tip">Confidence-weighted spatial density map — a faithful xAI explanation of WHERE the detector is most certain vehicles exist. Each detection stamps a Gaussian kernel scaled by its confidence score. Brighter regions = higher accumulated detection confidence.</span> | |
| </span>` : ''; | |
| card.innerHTML = ` | |
| <div class="px-5 py-3 border-b border-slate-800 bg-slate-900/40 flex justify-between items-center"> | |
| <div> | |
| <h3 class="font-bold text-white text-sm flex items-center">${info.title}${tooltipHTML}</h3> | |
| <p class="text-[10px] text-slate-400 mt-0.5">${info.desc}</p> | |
| </div> | |
| <a href="${url}" download="${name}" | |
| class="inline-flex items-center gap-1.5 px-3 py-1.5 border border-[#444444] text-white text-[10px] font-bold rounded-full hover:bg-neutral-800 transition uppercase tracking-wider"> | |
| <i class="fa-solid fa-download text-[9px]"></i> Download | |
| </a> | |
| </div> | |
| <div class="p-4 flex items-center justify-center bg-black/30"> | |
| ${previewHTML} | |
| </div> | |
| `; | |
| grid.appendChild(card); | |
| }); | |
| return data; | |
| } | |
| function toggleExportMaster(el) { | |
| el.classList.toggle('active'); | |
| const chips = document.getElementById('chip-selector'); | |
| if (el.classList.contains('active')) { | |
| chips.classList.remove('hidden-chip-container'); | |
| } else { | |
| chips.classList.add('hidden-chip-container'); | |
| } | |
| } | |
| function toggleAutoDownload(el) { | |
| el.classList.toggle('active'); | |
| } | |
| function toggleChip(id) { | |
| const chip = document.getElementById(`chip-${id}`); | |
| chip.classList.toggle('active'); | |
| const icon = chip.querySelector('i'); | |
| if (chip.classList.contains('active')) { | |
| icon.className = 'fa-solid fa-check'; | |
| } else { | |
| icon.className = 'fa-solid fa-plus'; | |
| } | |
| } | |
| function toggleHelp(id) { | |
| const ans = document.getElementById(id + '-ans'); | |
| const icon = document.getElementById(id + '-icon'); | |
| if (ans.classList.contains('hidden')) { | |
| ans.classList.remove('hidden'); | |
| icon.classList.replace('fa-plus', 'fa-minus'); | |
| } else { | |
| ans.classList.add('hidden'); | |
| icon.classList.replace('fa-minus', 'fa-plus'); | |
| } | |
| } | |
| function showRetryBubble() { | |
| // Desktop has the sidebar nav always visible — the hint is redundant there. | |
| const isMobile = window.innerWidth < 1024; | |
| if (!isMobile) return; | |
| if (sessionStorage.getItem('uf_retry_shown')) return; | |
| sessionStorage.setItem('uf_retry_shown', '1'); | |
| const existing = document.getElementById('retry-bubble'); | |
| if (existing) existing.remove(); | |
| const bubble = document.createElement('div'); | |
| bubble.id = 'retry-bubble'; | |
| Object.assign(bubble.style, { | |
| position: 'fixed', | |
| background: '#111111', | |
| border: '1px solid #c89a6c', | |
| borderRadius: '12px', | |
| padding: '12px 16px', | |
| fontFamily: 'Montserrat, sans-serif', | |
| color: '#f0ece6', | |
| zIndex: '9000', | |
| boxShadow: '0 8px 32px rgba(0,0,0,0.8)', | |
| maxWidth: '220px', | |
| textAlign: 'center', | |
| lineHeight: '1.4', | |
| }); | |
| bubble.innerHTML = ` | |
| <p style="color:#c89a6c;font-weight:800;font-size:13px;margin:0 0 5px 0"> | |
| Want to try another video? | |
| </p> | |
| <p style="color:#a89f97;font-size:11px;margin:0"> | |
| Tap <b style="color:#f0ece6">⚙ Settings</b> & click '<b style="color:#f0ece6">Home</b>' | |
| </p> | |
| <div id="retry-bubble-arrow"></div> | |
| `; | |
| // Append to fixed portal so overflow:hidden on body/main never clips it | |
| const portal = document.getElementById('fixed-portal') || document.body; | |
| portal.style.pointerEvents = 'none'; | |
| bubble.style.pointerEvents = 'auto'; | |
| portal.appendChild(bubble); | |
| if (isMobile) { | |
| // 7 icons in bottom nav. Settings = 4th (index 3). | |
| // Center of 4th icon = (3 + 0.5) / 7 = 50% of viewport width. | |
| const navH = parseInt( | |
| getComputedStyle(document.documentElement) | |
| .getPropertyValue('--mob-nav-h') || '68', 10 | |
| ); | |
| const vpW = window.innerWidth; | |
| const settingsCenterX = (3.5 / 7) * vpW; | |
| const bubbleW = bubble.offsetWidth || 220; | |
| const leftPx = Math.max(8, Math.min(settingsCenterX - bubbleW / 2, vpW - bubbleW - 8)); | |
| Object.assign(bubble.style, { | |
| bottom: (navH + 10) + 'px', | |
| left: leftPx + 'px', | |
| top: 'auto', | |
| right: 'auto', | |
| transform: 'none', | |
| }); | |
| const arrowLeft = settingsCenterX - leftPx; | |
| const arrow = bubble.querySelector('#retry-bubble-arrow'); | |
| Object.assign(arrow.style, { | |
| position: 'absolute', | |
| bottom: '-8px', | |
| left: arrowLeft + 'px', | |
| transform: 'translateX(-50%)', | |
| width: '0', height: '0', | |
| borderLeft: '8px solid transparent', | |
| borderRight: '8px solid transparent', | |
| borderTop: '8px solid #c89a6c', | |
| }); | |
| } else { | |
| // Desktop sidebar <aside class="w-60"> = 240px. | |
| // Settings is 5th nav link: logo(112px) + 4 items × 44px + 22px = 310px from top. | |
| const settingsY = 310; | |
| Object.assign(bubble.style, { | |
| left: '256px', | |
| top: (settingsY - 40) + 'px', | |
| bottom: 'auto', | |
| right: 'auto', | |
| transform: 'none', | |
| textAlign: 'left', | |
| }); | |
| const arrow = bubble.querySelector('#retry-bubble-arrow'); | |
| Object.assign(arrow.style, { | |
| position: 'absolute', | |
| left: '-8px', | |
| top: '50%', | |
| transform: 'translateY(-50%)', | |
| width: '0', height: '0', | |
| borderTop: '8px solid transparent', | |
| borderBottom: '8px solid transparent', | |
| borderRight: '8px solid #c89a6c', | |
| }); | |
| } | |
| bubble.addEventListener('click', () => bubble.remove()); | |
| setTimeout(() => { | |
| const el = document.getElementById('retry-bubble'); | |
| if (el) el.remove(); | |
| }, 6000); | |
| } | |
| // =========== Auth & Profile UI =========== | |
| function populateProfileUI() { | |
| const session = (typeof getAuthSession === 'function') ? getAuthSession() : null; | |
| if (!session) return; | |
| // Update PFP in Desktop Sidebar | |
| const sidebarPfp = document.getElementById('sidebar-profile-pfp-wrap'); | |
| if (sidebarPfp && session.picture) { | |
| sidebarPfp.innerHTML = `<img src="${session.picture}" alt="" class="w-full h-full object-cover rounded-full" referrerpolicy="no-referrer">`; | |
| } | |
| // Update PFP in Mobile Bottom Nav | |
| const mobPfp = document.getElementById('mob-pfp-wrap'); | |
| if (mobPfp && session.picture) { | |
| mobPfp.innerHTML = `<img src="${session.picture}" alt="" class="w-full h-full object-cover rounded-full" referrerpolicy="no-referrer">`; | |
| } | |
| // Sync Palette Preference | |
| const savedPalette = localStorage.getItem('uf_pref_palette') || 'default'; | |
| const paletteInp = document.getElementById('pref-palette'); | |
| if (paletteInp) { | |
| paletteInp.value = savedPalette; | |
| const label = document.getElementById('pref-palette-label'); | |
| if (label) label.innerText = savedPalette.charAt(0).toUpperCase() + savedPalette.slice(1); | |
| } | |
| } | |
| function populateProfileTab() { | |
| const session = (typeof getAuthSession === 'function') ? getAuthSession() : null; | |
| if (!session) return; | |
| // Identity | |
| const pfpLarge = document.getElementById('profile-pfp-large'); | |
| if (pfpLarge && session.picture) pfpLarge.src = session.picture; | |
| const emailEl = document.getElementById('profile-email'); | |
| if (emailEl) emailEl.innerText = session.email; | |
| const nameInp = document.getElementById('profile-username-input'); | |
| if (nameInp) nameInp.value = session.username || session.name || ''; | |
| // Stats (Scoped by email) | |
| const emailKey = `_${session.email}`; | |
| const totalRuns = localStorage.getItem(`uf_total_runs${emailKey}`) || '0'; | |
| const lastActive = localStorage.getItem(`uf_last_active${emailKey}`) || 'Never'; | |
| if (document.getElementById('profile-total-runs')) | |
| document.getElementById('profile-total-runs').innerText = totalRuns; | |
| if (document.getElementById('profile-last-active')) | |
| document.getElementById('profile-last-active').innerText = lastActive; | |
| } | |
| async function saveProfileUsername() { | |
| const session = (typeof getAuthSession === 'function') ? getAuthSession() : null; | |
| if (!session) return; | |
| const input = document.getElementById('profile-username-input'); | |
| const newName = input.value.trim(); | |
| if (!newName) return showToast('Name cannot be empty', 'error'); | |
| const btn = document.getElementById('btn-save-username'); | |
| btn.disabled = true; | |
| btn.innerText = 'Saving...'; | |
| try { | |
| // Save locally first (always works) | |
| session.username = newName; | |
| if (typeof saveAuthSession === 'function') saveAuthSession(session); | |
| // Try backend as best-effort | |
| fetch('api/auth/onboard', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ email: session.email, username: newName }) | |
| }).catch(() => {}); | |
| showToast('Profile updated successfully', 'success'); | |
| populateProfileUI(); | |
| } catch (err) { | |
| showToast('Failed to update profile', 'error'); | |
| } finally { | |
| btn.disabled = false; | |
| btn.innerText = 'Save'; | |
| } | |
| } | |
| function toggleLegalMenu(e) { | |
| e.stopPropagation(); | |
| const menus = ['legal-menu', 'legal-menu-profile']; | |
| menus.forEach(m => { | |
| const el = document.getElementById(m); | |
| if (el) el.classList.toggle('hidden'); | |
| }); | |
| // Close on click outside | |
| const closer = () => { | |
| menus.forEach(m => { | |
| const el = document.getElementById(m); | |
| if (el) el.classList.add('hidden'); | |
| }); | |
| window.removeEventListener('click', closer); | |
| }; | |
| window.addEventListener('click', closer); | |
| } | |
| // Palette Persistence | |
| document.addEventListener('change', (e) => { | |
| if (e.target.id === 'pref-palette') { | |
| localStorage.setItem('uf_pref_palette', e.target.value); | |
| showToast(`Palette preference saved: ${e.target.value}`, 'success'); | |
| } | |
| }); | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Phase 1: instant visual — show the shell immediately on first paint | |
| const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings'; | |
| switchTab(activeTab); | |
| // Phase 2: defer all heavy init until after browser completes first paint | |
| requestAnimationFrame(() => { | |
| setTimeout(initApp, 0); | |
| }); | |
| }); |