Spaces:
Running
Running
| // ============================================= | |
| // 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 = | |
| '<i class="fa-solid fa-circle-exclamation text-4xl mb-3 opacity-50" style="color:#c89a6c"></i>' + | |
| '<span class="font-bold text-[10px] uppercase tracking-widest opacity-50 block mt-2">Frame Load Error</span>'; | |
| } | |
| }; | |
| 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(); | |
| } | |
| } | |