// Saarthi frontend — stepper flow, autocomplete, SSE agent stream, // animated verdict, and the Ask Saarthi chat. (function () { const $ = (id) => document.getElementById(id); const state = { from: null, // {name, address, lat, lon} to: null, mode: 'car', eventSource: null, countdownTimer: null, }; // ---------- screen state machine ---------- const SCREENS = ['state-loc', 'state-vehicle', 'state-time', 'state-progress', 'state-verdict']; function show(screenId) { SCREENS.forEach((id) => $(id).classList.toggle('show', id === screenId)); $('panel').scrollTop = 0; } document.querySelectorAll('[data-back]').forEach((btn) => btn.addEventListener('click', () => show(btn.dataset.back)) ); // ---------- mobile bottom sheet: drag + snap points ---------- (function makeSheetDraggable() { const panel = $('panel'); const grab = panel.querySelector('.grab'); const isMobile = () => window.matchMedia('(max-width: 820px)').matches; const SNAPS = () => [0.28, 0.6, 0.88].map((f) => Math.round(window.innerHeight * f)); let startY = 0; let startHeight = 0; grab.addEventListener('pointerdown', (e) => { if (!isMobile()) return; startY = e.clientY; startHeight = panel.getBoundingClientRect().height; panel.classList.add('dragging'); grab.setPointerCapture(e.pointerId); }); grab.addEventListener('pointermove', (e) => { if (!isMobile() || !panel.classList.contains('dragging')) return; const next = Math.min(window.innerHeight * 0.92, Math.max(110, startHeight + (startY - e.clientY))); panel.style.maxHeight = `${next}px`; }); ['pointerup', 'pointercancel'].forEach((evt) => grab.addEventListener(evt, () => { if (!isMobile() || !panel.classList.contains('dragging')) return; panel.classList.remove('dragging'); panel.classList.add('snapping'); const current = panel.getBoundingClientRect().height; const nearest = SNAPS().reduce((a, b) => Math.abs(b - current) < Math.abs(a - current) ? b : a); panel.style.maxHeight = `${nearest}px`; setTimeout(() => panel.classList.remove('snapping'), 320); }) ); })(); // ---------- arrive-by presets ---------- function pad(n) { return String(n).padStart(2, '0'); } function toLocalValue(date) { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function applyPreset(name) { const date = new Date(); if (name === 'tomorrow930') { date.setDate(date.getDate() + 1); date.setHours(9, 30, 0, 0); } else if (name === 'plus2h') { date.setHours(date.getHours() + 2); date.setMinutes(Math.ceil(date.getMinutes() / 5) * 5, 0, 0); } else if (name === 'tonight7') { date.setHours(19, 0, 0, 0); if (date < new Date()) date.setDate(date.getDate() + 1); // 7 PM already passed } $('arrive-by').value = toLocalValue(date); } document.querySelectorAll('.preset').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('.preset').forEach((b) => b.classList.remove('on')); btn.classList.add('on'); applyPreset(btn.dataset.preset); }); }); applyPreset('tomorrow930'); document.querySelector('[data-preset="tomorrow930"]').classList.add('on'); $('arrive-by').addEventListener('input', () => { document.querySelectorAll('.preset').forEach((b) => b.classList.remove('on')); }); // ---------- autocomplete (per-input, keyboard navigable) ---------- function attachAutocomplete(input, box) { const kind = input.id === 'inp-from' ? 'from' : 'to'; const row = input.closest('.loc-row'); let debounce = null; let items = []; let hot = -1; function close() { box.innerHTML = ''; items = []; hot = -1; input.setAttribute('aria-expanded', 'false'); } function pick(item) { state[kind] = item; input.value = item.name; input.classList.add('ok'); close(); SaarthiMap.setPin(kind, item.lat, item.lon, item.name); $('btn-step1').disabled = !(state.from && state.to); } function highlight(index) { hot = index; box.querySelectorAll('.suggest-item').forEach((el, i) => el.classList.toggle('hot', i === hot)); } function render() { box.innerHTML = ''; items.forEach((item, index) => { const el = document.createElement('div'); el.className = 'suggest-item'; el.setAttribute('role', 'option'); el.style.animationDelay = `${index * 35}ms`; el.innerHTML = `
`; el.querySelector('.s-name').textContent = item.name; el.querySelector('.s-addr').textContent = item.address || ''; el.addEventListener('mousedown', (e) => { e.preventDefault(); pick(item); }); box.appendChild(el); }); input.setAttribute('aria-expanded', items.length ? 'true' : 'false'); lucide.createIcons(); } input.addEventListener('input', () => { state[kind] = null; input.classList.remove('ok'); $('btn-step1').disabled = true; clearTimeout(debounce); const query = input.value.trim(); if (query.length < 2) { close(); row.classList.remove('loading'); return; } row.classList.add('loading'); debounce = setTimeout(async () => { try { const response = await fetch(`/api/autocomplete?q=${encodeURIComponent(query)}`); const data = await response.json(); items = data.suggestions || []; hot = -1; render(); } catch { close(); } row.classList.remove('loading'); }, 280); }); input.addEventListener('keydown', (e) => { if (!items.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); highlight(Math.min(hot + 1, items.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); highlight(Math.max(hot - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); pick(items[hot >= 0 ? hot : 0]); } else if (e.key === 'Escape') { close(); } }); input.addEventListener('blur', () => setTimeout(close, 150)); } attachAutocomplete($('inp-from'), $('suggest-from')); attachAutocomplete($('inp-to'), $('suggest-to')); // ---------- step 1 -> 2 ---------- $('btn-step1').addEventListener('click', () => { if (!state.from || !state.to) return; $('route-sub').textContent = `${state.from.name} → ${state.to.name}`; show('state-vehicle'); }); // ---------- vehicle carousel (click + drag + wheel) ---------- document.querySelectorAll('.v-card').forEach((card) => { card.addEventListener('click', () => { document.querySelectorAll('.v-card').forEach((c) => c.classList.remove('active')); card.classList.add('active'); state.mode = card.dataset.mode; card.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); }); }); (function makeDraggable(carousel) { let isDown = false, startX = 0, startScroll = 0, moved = false; carousel.addEventListener('pointerdown', (e) => { isDown = true; moved = false; startX = e.clientX; startScroll = carousel.scrollLeft; }); carousel.addEventListener('pointermove', (e) => { if (!isDown) return; const dx = e.clientX - startX; if (Math.abs(dx) > 5) moved = true; carousel.scrollLeft = startScroll - dx; }); ['pointerup', 'pointerleave'].forEach((evt) => carousel.addEventListener(evt, () => { isDown = false; }) ); carousel.addEventListener('click', (e) => { if (moved) e.stopPropagation(); }, true); carousel.addEventListener('wheel', (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.preventDefault(); carousel.scrollLeft += e.deltaY; } }, { passive: false }); })($('v-carousel')); // ---------- step 2 -> 3 ---------- const MODE_LABELS = { motorcycle: 'Bike', car: 'Car', taxi: 'Taxi', bus: 'Bus' }; const MODE_ICONS = { motorcycle: 'bike', car: 'car-front', taxi: 'car-taxi-front', bus: 'bus-front' }; $('btn-step2').addEventListener('click', () => { if (!state.from || !state.to) { show('state-loc'); return; } const chips = $('sum-chips'); chips.innerHTML = ''; [ ['map-pin', state.from.name], ['flag', state.to.name], [MODE_ICONS[state.mode], MODE_LABELS[state.mode]], ].forEach(([icon, label]) => { const chip = document.createElement('span'); chip.className = 'chip'; chip.innerHTML = ``; chip.querySelector('.chip-label').textContent = label; chips.appendChild(chip); }); lucide.createIcons(); show('state-time'); }); // ---------- analyze: SSE stream + cooking console ---------- const TASK_ORDER = ['geocode', 'traffic', 'weather', 'festivals', 'events', 'advisories', 'verdict']; const COOK_LINES = [ 'Crunching live traffic on every route…', 'Simulating parallel futures of your commute…', 'Checking if rain wants to ruin your day…', 'Sniffing out Bada Mangal bhandaras…', 'Scanning Ekana for match-day chaos…', 'Reading Lucknow Police’s mind…', 'Teaching the AI about Lucknow traffic…', 'Brewing your verdict…', ]; let cookTimer = null; function startCooking() { let index = 0; clearInterval(cookTimer); $('cook-line').textContent = COOK_LINES[0]; cookTimer = setInterval(() => { index = (index + 1) % COOK_LINES.length; const line = $('cook-line'); line.style.opacity = 0; setTimeout(() => { line.textContent = COOK_LINES[index]; line.style.opacity = 1; }, 280); }, 2300); } function stopCooking() { clearInterval(cookTimer); cookTimer = null; } function updateCookBar() { const done = document.querySelectorAll('#task-list .task.done').length; $('cook-bar-fill').style.width = `${Math.round((done / TASK_ORDER.length) * 100)}%`; } function setTask(name, status, subText) { const task = document.querySelector(`[data-task="${name}"]`); if (!task) return; task.classList.remove('wait', 'busy', 'done'); task.classList.add(status); if (subText) task.querySelector('.t-sub').textContent = subText; updateCookBar(); } function resetTasks() { TASK_ORDER.forEach((name) => { const task = document.querySelector(`[data-task="${name}"]`); task.classList.remove('busy', 'done'); task.classList.add('wait'); task.querySelector('.t-sub').textContent = ''; }); setTask('geocode', 'busy'); $('progress-error').innerHTML = ''; $('cook-bar-fill').style.width = '4%'; startCooking(); } function advanceTasks(doneName, subText) { setTask(doneName, 'done', subText); const next = TASK_ORDER[TASK_ORDER.indexOf(doneName) + 1]; if (next) setTask(next, 'busy'); } function startAnalysis() { if (!$('arrive-by').value) return; if (!state.from || !state.to) { show('state-loc'); return; } if (state.eventSource) state.eventSource.close(); resetTasks(); show('state-progress'); const params = new URLSearchParams({ from: state.from.name, to: state.to.name, arrive_by: $('arrive-by').value, mode: state.mode, from_lat: state.from.lat, from_lon: state.from.lon, to_lat: state.to.lat, to_lon: state.to.lon, }); const es = new EventSource(`/api/plan/stream?${params}`); state.eventSource = es; es.onmessage = (message) => { let data; try { data = JSON.parse(message.data); } catch { return; } // skip malformed frames if (data.type === 'tool_result') { advanceTasks(data.tool, data.summary); } else if (data.type === 'status' && (data.message || '').startsWith('Risk score')) { setTask('advisories', 'done'); setTask('verdict', 'busy', data.message); } else if (data.type === 'verdict') { if (!data.data || !data.data.risk) { showError('Received an incomplete verdict — please try again.'); return; } setTask('verdict', 'done'); try { renderVerdict(data.data); } catch (renderError) { console.error('Verdict render failed:', renderError); showError('Could not display the verdict — please try again.'); } } else if (data.type === 'error') { showError(data.message || 'Something went wrong — please try again.'); } else if (data.type === 'done') { es.close(); state.eventSource = null; } }; es.onerror = () => { showError('Connection lost — is the server running?'); }; } $('btn-analyze').addEventListener('click', () => { if (!$('arrive-by').value) return; if (!state.from || !state.to) { show('state-loc'); return; } // button morphs into a loading state before the console takes over const analyzeBtn = $('btn-analyze'); analyzeBtn.classList.add('loading'); setTimeout(() => { analyzeBtn.classList.remove('loading'); startAnalysis(); }, 350); }); $('btn-recheck').addEventListener('click', startAnalysis); function showError(text) { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } stopCooking(); $('cook-line').textContent = 'Hit a snag.'; const box = $('progress-error'); box.innerHTML = `
`; box.querySelector('span').textContent = text; lucide.createIcons(); $('btn-err-back').addEventListener('click', () => show('state-loc')); } // ---------- verdict ---------- const LEVEL_CLASS = { LOW: 'lv-low', MEDIUM: 'lv-med', HIGH: 'lv-high' }; const LEVEL_GRADIENT = { LOW: 'url(#grad-low)', MEDIUM: 'url(#grad-medium)', HIGH: 'url(#grad-high)' }; const FACTOR_ICONS = { car: 'car-front', rain: 'cloud-rain', temple: 'landmark', calendar: 'calendar', alert: 'siren' }; const IMPACT_STYLE = { low: ['gray', 'lv-low', 'LOW'], medium: ['amber', 'lv-med', 'MED'], high: ['red', 'lv-high', 'HIGH'] }; // weather factors get an icon matching the actual forecast function factorIcon(factor) { if (factor.type === 'weather' || factor.icon === 'rain') { const detail = (factor.detail || '').toLowerCase(); if (detail.includes('thunder')) return 'cloud-lightning'; if (detail.includes('drizzle')) return 'cloud-drizzle'; if (detail.includes('fog')) return 'cloud-fog'; if (detail.includes('no significant rain') || detail.includes('clear')) return 'sun'; return 'cloud-rain'; } return FACTOR_ICONS[factor.icon] || 'siren'; } function countUp(el, target, durationMs) { const start = performance.now(); function frame(now) { const t = Math.min(1, (now - start) / durationMs); const eased = 1 - Math.pow(1 - t, 3); el.textContent = Math.round(target * eased); if (t < 1) requestAnimationFrame(frame); } requestAnimationFrame(frame); } function renderVerdict(verdict) { stopCooking(); show('state-verdict'); // gauge — gradient stroke, arc sweep + score count-up in sync const score = verdict.risk.score; const level = verdict.risk.level; const arc = $('gauge-arc'); arc.style.stroke = LEVEL_GRADIENT[level] || LEVEL_GRADIENT.MEDIUM; arc.style.strokeDashoffset = '270'; requestAnimationFrame(() => { arc.style.strokeDashoffset = String(270 * (1 - score / 100)); }); countUp($('gauge-score'), score, 1000); const levelEl = $('gauge-level'); levelEl.textContent = `${level} RISK`; levelEl.className = `gauge-level ${LEVEL_CLASS[level] || 'lv-med'}`; // leave-by block $('leave-time').textContent = verdict.recommended_departure || '--:--'; $('leave-eta').textContent = verdict.eta ? `ETA ${verdict.eta} · deadline ${verdict.arrive_by}` : ''; startCountdown(verdict); $('verdict-summary').textContent = verdict.summary || ''; // factors — staggered entrance const factors = $('factors'); factors.innerHTML = ''; (verdict.factors || []).forEach((factor, index) => { const [iconBg, tagClass, tagText] = IMPACT_STYLE[factor.impact] || IMPACT_STYLE.low; const row = document.createElement('div'); row.className = 'factor'; row.style.animationDelay = `${index * 90}ms`; row.innerHTML = `
${tagText}`; row.querySelector('.f-text').textContent = factor.detail; factors.appendChild(row); }); if (!factors.children.length) { factors.innerHTML = `
No significant risk factors found — smooth ride expected.
`; } // departure curve — bars grow in with stagger const curve = $('curve'); curve.innerHTML = ''; const entries = verdict.departure_curve || []; const maxTravel = Math.max(...entries.map((entry) => entry.travel_min), 1); entries.forEach((entry, index) => { const isRec = entry.depart === verdict.recommended_departure; const color = entry.on_time ? (entry.margin_min >= 5 ? 'var(--green)' : 'var(--amber)') : 'var(--crimson)'; const width = Math.max(8, Math.round((entry.travel_min / maxTravel) * 100)); const label = entry.on_time ? `→ ${entry.eta}${isRec ? ' ★' : ` · ${Math.round(entry.travel_min)} min`}` : `→ ${entry.eta} · LATE`; const row = document.createElement('div'); row.className = `curve-row${isRec ? ' rec' : ''}`; row.innerHTML = ` ${entry.depart} ${label}`; curve.appendChild(row); setTimeout(() => row.classList.add('grown'), 150 + index * 120); }); // tips const tips = $('tips'); tips.innerHTML = ''; (verdict.tips || []).forEach((tipText) => { const tip = document.createElement('div'); tip.className = 'tip'; tip.innerHTML = ''; tip.querySelector('span').textContent = tipText; tips.appendChild(tip); }); $('provider-note').textContent = `Verdict synthesized by: ${verdict.llm_provider}`; // route context pill over the map if (verdict.origin && verdict.destination) { $('route-pill-text').textContent = `${verdict.origin.name} → ${verdict.destination.name}`; $('route-pill').classList.remove('hidden'); SaarthiMap.drawRoute(verdict.origin, verdict.destination, verdict.route); } lucide.createIcons(); } function startCountdown(verdict) { clearInterval(state.countdownTimer); const rec = (verdict.departure_curve || []).find( (entry) => entry.depart === verdict.recommended_departure ); const countdownEl = $('countdown'); if (!rec || !rec.depart_iso) { countdownEl.classList.add('hidden'); return; } function tick() { const minutes = Math.round((new Date(rec.depart_iso) - new Date()) / 60000); if (minutes <= 0) { $('countdown-text').textContent = 'leave now!'; } else if (minutes < 90) { $('countdown-text').textContent = `in ${minutes} min`; } else { $('countdown-text').textContent = `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`; } countdownEl.classList.remove('hidden'); } tick(); state.countdownTimer = setInterval(tick, 30000); } // ---------- restart ---------- $('btn-restart').addEventListener('click', () => { clearInterval(state.countdownTimer); state.from = null; state.to = null; ['inp-from', 'inp-to'].forEach((id) => { $(id).value = ''; $(id).classList.remove('ok'); }); $('btn-step1').disabled = true; $('route-pill').classList.add('hidden'); SaarthiMap.reset(); show('state-loc'); }); // ========================================================== // ASK SAARTHI — chat with live tool streaming // ========================================================== const chat = { history: [], streaming: false, es: null }; $('ask-open').addEventListener('click', () => { $('ask-modal').classList.remove('hidden'); $('ask-input').focus(); }); $('ask-close').addEventListener('click', () => $('ask-modal').classList.add('hidden')); $('ask-modal').addEventListener('click', (event) => { if (event.target === $('ask-modal')) $('ask-modal').classList.add('hidden'); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') $('ask-modal').classList.add('hidden'); }); document.querySelectorAll('.suggest-chip').forEach((chip) => chip.addEventListener('click', () => { $('ask-input').value = chip.textContent; sendChat(); }) ); // minimal safe markdown: escape first, then **bold**, `code`, bullet lists function renderMarkdown(text) { const escaped = String(text) .replace(/&/g, '&').replace(//g, '>'); const inline = escaped .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1'); let html = ''; let inList = false; inline.split('\n').forEach((line) => { const bullet = line.match(/^\s*[-*•]\s+(.*)/); if (bullet) { if (!inList) { html += ''; inList = false; } if (line.trim()) html += `

${line}

`; } }); if (inList) html += ''; return html || '

'; } const TOOL_LABELS = { geocode_place: 'finding the place', get_route: 'checking the route', compare_departures: 'simulating departures', get_weather: 'checking weather', get_festivals: 'checking festivals', get_events: 'checking events', get_police_advisories: 'checking advisories', }; function addMessage(role, html) { $('chat-empty')?.remove(); const msg = document.createElement('div'); msg.className = `msg ${role}`; if (role === 'bot') { msg.innerHTML = `
${html}
`; } else { msg.innerHTML = `
`; msg.querySelector('.bubble').textContent = html; // user text stays plain } $('chat-log').appendChild(msg); lucide.createIcons(); $('chat-log').scrollTop = $('chat-log').scrollHeight; return msg; } function sendChat() { const input = $('ask-input'); const question = input.value.trim(); if (!question || chat.streaming) return; input.value = ''; chat.streaming = true; $('ask-btn').disabled = true; addMessage('user', question); const working = addMessage('bot', `
`); const bubble = working.querySelector('.bubble'); const toolFeed = working.querySelector('.tool-feed'); const params = new URLSearchParams({ question, history: JSON.stringify(chat.history.slice(-8)), }); const es = new EventSource(`/api/ask/stream?${params}`); chat.es = es; function finish() { es.close(); chat.es = null; chat.streaming = false; $('ask-btn').disabled = false; } es.onmessage = (message) => { let data; try { data = JSON.parse(message.data); } catch { return; } if (data.type === 'tool') { toolFeed.querySelectorAll('.tool-chip.running').forEach((c) => c.classList.remove('running')); const chipEl = document.createElement('span'); chipEl.className = 'tool-chip running'; chipEl.innerHTML = ` ${TOOL_LABELS[data.name] || data.name}`; toolFeed.appendChild(chipEl); lucide.createIcons(); $('chat-log').scrollTop = $('chat-log').scrollHeight; } else if (data.type === 'answer') { const toolsHtml = toolFeed.children.length ? `
${toolFeed.innerHTML.replace(/running/g, '')}
` : ''; bubble.innerHTML = renderMarkdown(data.text) + toolsHtml + (data.provider ? `
via ${data.provider}
` : ''); lucide.createIcons(); chat.history.push({ role: 'user', content: question }); chat.history.push({ role: 'assistant', content: data.text }); $('chat-log').scrollTop = $('chat-log').scrollHeight; } else if (data.type === 'error') { bubble.innerHTML = renderMarkdown(`Sorry — ${data.message || 'something went wrong.'}`); finish(); } else if (data.type === 'done') { finish(); } }; es.onerror = () => { bubble.innerHTML = renderMarkdown('Connection lost — please try again.'); finish(); }; } $('ask-btn').addEventListener('click', sendChat); $('ask-input').addEventListener('keydown', (event) => { if (event.key === 'Enter') sendChat(); }); lucide.createIcons(); })();