UrbanFlow / frontend /js /vehicles.js
Subh775's picture
restored to the safe state
a018ed7
// =========== 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">&#9881; Settings</b> &amp; 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);
});
});