// =============================================
// 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();
}
}