// ============================================= // UrbanFlow — initial.js (Mobile-First) // Added: touch events, mobile upload UX // ============================================= let videoId = null; let runConfig = {}; // ---- Step navigation ---- function showStep(name) { ['modules', 'upload', 'draw'].forEach(s => { const el = document.getElementById('step-' + s); if (el) el.classList.add('hidden'); }); const target = document.getElementById('step-' + name); if (target) target.classList.remove('hidden'); if (name === 'upload') { document.getElementById('upload-progress-container').classList.add('hidden'); document.getElementById('dropzone').classList.remove('hidden'); document.getElementById('upload-bar').style.width = '0%'; document.getElementById('upload-percentage').innerText = '0%'; document.getElementById('upload-text').innerText = 'Uploading...'; document.getElementById('upload-text').classList.remove('text-red-500'); } if (name === 'draw') loadFirstFrame(); } // ---- File input / dropzone ---- const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('file-input'); if (fileInput) { fileInput.addEventListener('change', () => { if (fileInput.files.length) uploadFile(fileInput.files[0]); }); } if (dropzone) { // Desktop drag-and-drop dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dz-active'); }); dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('dz-active'); }); dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('dz-active'); if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); }); } // ---- Upload ---- let currentXHR = null; function uploadFile(file) { if (currentXHR) currentXHR.abort(); const dropzoneEl = document.getElementById('dropzone'); const prog = document.getElementById('upload-progress-container'); const bar = document.getElementById('upload-bar'); const pct = document.getElementById('upload-percentage'); const txt = document.getElementById('upload-text'); if (dropzoneEl) dropzoneEl.classList.add('hidden'); if (prog) prog.classList.remove('hidden'); // ---- Simulated progress (proxy-buffer-safe) ---- // Estimate upload duration: ~1 MB/s conservative, capped between 3s and 60s const fileMB = file.size / (1024 * 1024); const estDurationMs = Math.min(Math.max(fileMB * 1000, 3000), 60000); const targetPct = 100; // stop simulation at 100%, snap to 100% on load const tickMs = 200; // update every 200ms const totalTicks = estDurationMs / tickMs; const stepPerTick = targetPct / totalTicks; let simPct = 0; let simInterval = setInterval(() => { if (simPct < targetPct) { simPct = Math.min(simPct + stepPerTick, targetPct); bar.style.width = simPct.toFixed(1) + '%'; pct.innerText = Math.floor(simPct) + '%'; } }, tickMs); const form = new FormData(); form.append('file', file); const xhr = new XMLHttpRequest(); currentXHR = xhr; xhr.open('POST', 'upload'); // Real progress override — fires if proxy reports actual bytes (rare but handle it) xhr.upload.onprogress = e => { if (e.lengthComputable) { const realPct = Math.round(e.loaded / e.total * 100); // Only override simulation if real progress is AHEAD of it if (realPct > simPct) { simPct = realPct; bar.style.width = simPct + '%'; pct.innerText = simPct + '%'; } } }; xhr.onerror = () => { clearInterval(simInterval); txt.innerText = 'Error: Network failure'; txt.classList.add('text-red-500'); if (fileInput) fileInput.value = ''; }; xhr.onload = () => { clearInterval(simInterval); if (xhr.status !== 200) { txt.innerText = 'Error: ' + xhr.status; txt.classList.add('text-red-500'); if (fileInput) fileInput.value = ''; return; } // Snap to 100% on successful upload response bar.style.width = '100%'; pct.innerText = '100%'; const res = JSON.parse(xhr.responseText); videoId = res.video_id; txt.innerText = 'Extracting Metadata...'; fetch('config/' + videoId) .then(r => r.json()) .then(cfg => { runConfig = cfg; runConfig.conf = 0.12; runConfig.iou = 0.60; txt.innerText = 'Initialization Complete'; if (fileInput) fileInput.value = ''; setTimeout(() => showStep('draw'), 800); }) .catch(() => { txt.innerText = 'Metadata Failed'; txt.classList.add('text-red-500'); if (fileInput) fileInput.value = ''; }); }; xhr.send(form); } // ============================================= // CANVAS — Spatial Boundary Drawing // Supports both mouse (desktop) and touch (mobile) // ============================================= const canvas = document.getElementById('drawing-canvas'); const ctx = canvas ? canvas.getContext('2d') : null; let points = []; let imgNatW = 0, imgNatH = 0; function loadFirstFrame() { const img = document.getElementById('frame-img'); const placeholder = document.getElementById('frame-placeholder'); img.onerror = () => { console.error('Failed to load first frame'); if (placeholder) { placeholder.innerHTML = '' + 'Frame Load Error'; } }; img.src = 'first-frame/' + videoId; img.onload = () => { imgNatW = img.naturalWidth; imgNatH = img.naturalHeight; img.style.display = 'block'; if (placeholder) placeholder.style.display = 'none'; initCanvas(); }; } function initCanvas() { if (!canvas) return; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; // Redraw existing points after resize redrawCanvas(); } window.addEventListener('resize', initCanvas); // ---- Coordinate helpers ---- function getCanvasCoords(clientX, clientY) { const rect = canvas.getBoundingClientRect(); const cx = clientX - rect.left; const cy = clientY - rect.top; const rx = (cx / canvas.width) * imgNatW; const ry = (cy / canvas.height) * imgNatH; return { cx, cy, rx: Math.round(rx), ry: Math.round(ry) }; } function addPoint(coords) { if (points.length >= 2) return; points.push(coords); redrawCanvas(); if (points.length === 2 && canvas) { canvas.style.cursor = 'default'; } } function redrawCanvas() { if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); points.forEach(p => drawDot(p.cx, p.cy)); if (points.length === 2) drawLine(); } // ---- Mouse events (desktop) ---- if (canvas) { canvas.addEventListener('mousedown', e => { e.preventDefault(); addPoint(getCanvasCoords(e.clientX, e.clientY)); }); } // ---- Touch events (mobile) ---- if (canvas) { canvas.addEventListener('touchstart', e => { e.preventDefault(); // prevent scroll while drawing if (e.touches.length === 0) return; const touch = e.touches[0]; addPoint(getCanvasCoords(touch.clientX, touch.clientY)); }, { passive: false }); // touchmove: prevent page scroll when finger is on canvas canvas.addEventListener('touchmove', e => { e.preventDefault(); }, { passive: false }); } // ---- Drawing helpers ---- function drawDot(x, y) { if (!ctx) return; ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); // slightly larger dot for mobile visibility ctx.fillStyle = '#c89a6c'; ctx.fill(); ctx.strokeStyle = '#f0ece6'; ctx.lineWidth = 2; ctx.stroke(); } function drawLine() { if (!ctx || points.length < 2) return; ctx.beginPath(); ctx.moveTo(points[0].cx, points[0].cy); ctx.lineTo(points[1].cx, points[1].cy); ctx.strokeStyle = '#c89a6c'; ctx.lineWidth = 3; ctx.stroke(); } function resetCanvas() { points = []; if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); if (canvas) canvas.style.cursor = 'crosshair'; } function startRun() { if (points.length < 2) return; const line = [[points[0].rx, points[0].ry], [points[1].rx, points[1].ry]]; sessionStorage.setItem('funky_run', JSON.stringify({ video_id: videoId, line: line, config: runConfig })); sessionStorage.setItem('uf_active_tab', 'settings'); // Navigate directly to vehicles.html — avoids the initial.html bootstrap entirely, // which eliminates the white-flash / freeze glitch on the settings transition. window.location.href = 'vehicles.html'; } // ============================================= // Onboarding // ============================================= let _obStep = 0; function triggerIconPulse() { const activeStep = document.querySelector('.onboard-step.active i'); if (activeStep) { activeStep.classList.add('pulse-once'); setTimeout(() => activeStep.classList.remove('pulse-once'), 400); } } function nextOnboardStep() { _obStep++; if (_obStep >= 3) { closeOnboarding(); return; } document.querySelectorAll('.onboard-step').forEach((s, i) => s.classList.toggle('active', i === _obStep)); document.querySelectorAll('.onboard-dot').forEach((d, i) => d.classList.toggle('active', i === _obStep)); if (_obStep === 2) document.getElementById('onboard-next').innerText = 'Get Started'; triggerIconPulse(); } function closeOnboarding() { const overlay = document.getElementById('onboard-overlay'); if (overlay) overlay.style.display = 'none'; } function openOnboarding() { const onboard = document.getElementById('onboard-overlay'); if (onboard) { _obStep = 0; document.querySelectorAll('.onboard-step').forEach((s, i) => s.classList.toggle('active', i === _obStep)); document.querySelectorAll('.onboard-dot').forEach((d, i) => d.classList.toggle('active', i === _obStep)); document.getElementById('onboard-next').innerText = 'Next'; onboard.style.display = 'flex'; triggerIconPulse(); } }