// =========== 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 = ` ${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 = ` 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 = `
${capLabel}

${capNote}

Dominant: ${domClass} · PCU factor ${domFactor}

Total PCU ${totalPcu}
${pcu.pcu_in || 0}
PCU In
${pcu.pcu_out || 0}
PCU Out
`; } } // =========== Run Details helpers =========== function detailRow(label, value, extra) { extra = extra || ''; return `
${label} ${value}${extra}
`; } function infoRow(label, value, tip, extra) { extra = extra || ''; return `
${label} ${tip} ${value}${extra}
`; } function boolBadge(val) { if (val) return `TRUE`; return `FALSE`; } 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] + ' x ' + 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 = `
cpu_score Available CPU core count used for throughput estimation.
${c.cpu_score}
` + infoRow('model_fps_est', c.model_fps_est, 'Estimated model inference throughput based on CPU benchmark.', ' fps') + infoRow('effective_fps', c.effective_fps_est, 'Adjusted throughput accounting for frame stride.', ' fps'); document.getElementById('panel-model').innerHTML = `
model Perception365/VehicleNet-Y26s
` + 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 => `
${c.label}
` ).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 = `
${MODEL_CLASSES[id]}
${total} total
${inC}
${outC}
${pct}%
`; 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 = `
Executing inference pipeline... results pending
`; // 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 = `
Processing connection was lost. The server may have timed out or restarted. Please try again.
`; } }; 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 = `
Inference pipeline failed. ${d.error}
`; 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.', ' fps') + 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 = `
Video Ready for Local Analysis
`; } else if (isPDF) { previewHTML = `
PDF Document
`; } else if (isCSV) { previewHTML = `
Raw Analytics Export
`; } else if (name.endsWith('.json')) { previewHTML = `
Structured JSON
`; } else { previewHTML = `${info.title}`; } const isHeatmap = name.includes('heatmap'); const tooltipHTML = isHeatmap ? ` 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. ` : ''; card.innerHTML = `

${info.title}${tooltipHTML}

${info.desc}

Download
${previewHTML}
`; 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 = `

Want to try another video?

Tap ⚙ Settings & click 'Home'

`; // 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