/* Interaction demo (redesigned). All data is fake (data.js). * * Design pillars (see recommendations): * 1. ONE unified canvas — no modal "modes". Discovery is a drawer, not a * mode-switch: the plan rail and map stay visible while browsing. * 2. Smoother, proactive discovery — drawer chips + a "you might like" * suggestion block in the rail, not just chat prompts. * 3. Frictionless replacement — every stop card expands to inline * alternatives with one-click swap, and any candidate can be dragged * straight onto the plan (drop on a same-slot stop = swap). * 4. Multi-scale comparison — a persistent bottom "compare dock" lines up * any 2–4 candidates/stops side by side (hotel scale and stop scale). * 5. Lower cognitive load — states read as "in plan / idea / backup", * strict validators are rephrased as gentle nudges with one-tap fixes. */ let M = window.MOCK; // active trip dataset (reassigned on trip switch) const $ = s => document.querySelector(s); const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }; const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math.round(m % 60)).padStart(2, '0'); const deep = o => JSON.parse(JSON.stringify(o)); const WALK_KMH = 4.6; const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60))); // ---------------- route client (local OSRM via the /route proxy) ---------- // Legs start life as straight-line estimates (conf 1) and are upgraded in the // background to real road-network times + geometry (conf 3) when the point // pair is inside the router's extract. Out of coverage → stays an estimate. let routerOn = false; // each trip declares which local OSRM extract covers it (server.js maps // the key to an upstream; default is the NE-US car extract) const routerKey = () => (M && M.router) || 'northeast'; async function checkRouter() { try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; } catch { routerOn = false; } } // ---------------- LLM client (local llama.cpp via the /llm-chat proxy) ----- // Free-form chat answers come from the model; deterministic UI intents // (drawer discovery, time nudges, swaps) stay local — the LLM proposes, // the user's UI commits. Falls back to canned replies when the server is // down or the model isn't loaded. let llmOn = false; async function checkLLM() { try { const r = await fetch('/llm-status'); const j = await r.json(); llmOn = !!j.llm; } catch { llmOn = false; } } const badgeText = () => '📶 online' + (routerOn ? ' · router' : '') + (llmOn ? ' · llm' : ''); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Compact itinerary snapshot (the design doc's LLM context form): stops with // times + stays + bookings. No geometry, no tool output. layout() fills // s.start in place, so it is safe to call at any time. function llmContext() { const L = [`Trip: ${M.trip.title}`]; for (const b of M.trip.bookings) L.push(`Booking (${b.type}): ${b.name}${b.ref ? ' — ref ' + b.ref : ''}${b.dates ? ', ' + b.dates : ''}${b.from ? `, ${b.from} → ${b.to}, ${b.date} dep ${b.depart}` : ''}`); for (const st of stays) L.push(`Stay: ${st.name} (${st.place || ''}) — ${st.checkIn} → ${st.checkOut}${st.price ? ', ' + st.price + ' €/night' : ''}`); for (const d of M.days) { const dd = days[d.id]; layout(dd); const stops = dd.stops.filter(s => s.state === 'planned').map( s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`); L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`); } return L.join('\n'); } // ---------------- LLM agent: tools + calling loop ---------------- // The model is the orchestrator: it reads the plan, calls deterministic // tools (search / route / review), and proposes small fix-ops (add / remove / // move / re-time). The tools decide — they validate, compute, and commit, so // every edit is undo-able. This llama.cpp build does not honor the native // OpenAI `tools` field, so tool calls use a text protocol: the model emits // {"name":...,"args":{...}}, we execute it, and feed the JSON // result back as the next turn; the loop ends when the model stops calling. const LLM_TOOL_MAX = 10; // max tool-call turns per user message // Live-state lookups (the source of truth is `days`, not the original M.days) function liveStopById(id) { for (const d of Object.values(days)) { const s = d.stops.find(x => x.id === id); if (s) return s; } return null; } function candById(id) { for (const cat of Object.keys(M.candidates)) { const c = M.candidates[cat].find(x => x.id === id); if (c) return { ...c, cat }; } return null; } // Resolve a model-given place ref — "hotel", a stop/candidate id, or a name // (exact then substring) — to {name, at, id} function resolvePlace(ref) { const q = String(ref || '').trim(); const ql = q.toLowerCase(); if (!q) return null; if (ql === 'hotel' || ql === 'base' || ql === 'the hotel' || ql === 'my hotel') { const b = dayBase(); return { name: b.name, at: b.at, id: b.id || 'hotel' }; } const s = liveStopById(q); if (s) return { name: s.name, at: s.at, id: s.id }; const c = candById(q); if (c) return { name: c.name, at: c.at, id: c.id }; const all = []; Object.values(days).forEach(d => d.stops.forEach(x => all.push(x))); Object.values(M.candidates).forEach(pool => pool.forEach(x => all.push(x))); const hit = all.find(x => x.name.toLowerCase() === ql) || all.find(x => x.name.toLowerCase().includes(ql)); return hit ? { name: hit.name, at: hit.at, id: hit.id } : null; } // Resolve a live stop by id or name (the model often names stops, not ids) function resolveStop(ref) { const s = liveStopById(ref); if (s) return s; const q = String(ref || '').trim().toLowerCase(); if (!q) return null; for (const d of Object.values(days)) { const hit = d.stops.find(x => x.name.toLowerCase() === q) || d.stops.find(x => x.name.toLowerCase().includes(q)); if (hit) return hit; } return null; } const TOOLS = { // ---- queries (read-only) ---- itinerary_summary: () => ({ plan: llmContext() }), search_places: (a) => { const cat = String(a.category || '').toLowerCase(); if (!M.candidates[cat]) return { error: `unknown category "${a.category}"; use ${Object.keys(M.candidates).join(', ')}` }; let region = null; if (a.region) { region = REGION_MAP[String(a.region).toLowerCase()]; if (!region) return { error: `unknown region "${a.region}"; use ${Object.keys(REGION_MAP).join(', ')} or omit` }; } const results = poolFor(cat, region).map(c => ({ id: c.id, name: c.name, price_eur: c.price, dur_min: c.dur, tags: c.tags, pitch: c.pitch, walk_from_hotel_min: hotelWalkMin(c.at), })); return { category: cat, region: a.region || null, count: results.length, results }; }, place_facts: (a) => { const p = resolvePlace(a.id || a.name); if (!p) return { error: `no place matching "${a.id || a.name}"` }; const c = candById(p.id) || liveStopById(p.id); return { name: p.name, price_eur: c?.price, dur_min: c?.dur, tags: c?.tags, pitch: c?.pitch, walk_from_hotel_min: hotelWalkMin(p.at), url: c?.url || null, summary: c?.detail?.summary || null }; }, route_between: async (a) => { const A = resolvePlace(a.from), B = resolvePlace(a.to); if (!A) return { error: `cannot resolve "from" = "${a.from}"` }; if (!B) return { error: `cannot resolve "to" = "${a.to}"` }; if (A.id === B.id || A.name === B.name) return { from: A.name, to: B.name, minutes: 0, km: 0, source: 'same place' }; let minutes, km, source; const res = await routePts([A.at, B.at], 'foot'); if (res) { minutes = res.durMin; km = Math.round(res.distance / 100) / 10; source = 'local router (OSRM walk)'; } else { minutes = walkMin(A.at, B.at); km = Math.round(haversine(A.at, B.at) / 100) / 10; source = routerOn ? 'straight-line est (outside router coverage)' : 'straight-line est (router offline)'; } return { from: A.name, to: B.name, minutes, km, source }; }, review_plan: (a) => { const ids = a.dayId ? [a.dayId] : Object.keys(days); const issues = []; for (const id of ids) { const d = days[id]; if (!d) { issues.push(`${id}: not a day`); continue; } layout(d); const prim = d.stops.filter(s => s.state === 'planned'); const planned = prim.reduce((t, s) => t + s.dur, 0) + d.legs.reduce((t, g) => t + (g?.dur || 0), 0); const endMin = d.startMin + planned; const capMin = d.startMin + (d.wakingHours || 11) * 60; if (endMin > capMin) issues.push(`${d.label}: planned time ends ~${fmt(endMin)} but the waking window closes ~${fmt(capMin)} — ~${Math.round(endMin - capMin)} min over`); prim.forEach(s => { if (s.slot === 'lunch' && (s.start < 11 * 60 || s.start > 15 * 60)) issues.push(`${d.label}: lunch at ${s.name} starts ${fmt(s.start)} — outside a normal 11:00–15:00 window`); if (s.slot === 'dinner' && (s.start < 17 * 60 || s.start > 21 * 60)) issues.push(`${d.label}: dinner at ${s.name} starts ${fmt(s.start)} — outside a normal 17:00–21:00 window`); }); const seen = {}; prim.forEach(s => { const k = s.name.toLowerCase(); if (seen[k] && !s.name.includes('—')) issues.push(`${d.label}: "${s.name}" appears more than once in the plan`); seen[k] = (seen[k] || 0) + 1; }); if (!prim.length) issues.push(`${d.label}: has no planned stops`); } return { issues: issues.length ? issues : ['no problems found'] }; }, // ---- fix-ops (mutations; each commits → undo-able) ---- add_stop: (a) => { const SLOTS = ['lunch', 'dinner', 'visit']; const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"; days are ${Object.keys(days).join(', ')}` }; const c = candById(a.candidateId); if (!c) return { error: `no candidate "${a.candidateId}" — call search_places first` }; const slot = SLOTS.includes(String(a.slot || c.cat).toLowerCase()) ? String(a.slot || c.cat).toLowerCase() : c.cat; if (slot === 'hotel') return { error: 'hotels are stays, not day stops — point the user to the hotel drawer' }; const stop = makeStop(c, slot, a.state && ['planned', 'maybe', 'alt'].includes(a.state) ? a.state : 'planned'); const inc = d.stops.find(x => x.slot === slot && x.state === 'planned' && x.id !== stop.id); if (inc && stop.state === 'planned') inc.state = 'alt'; // one slot, one plan d.stops.push(stop); rebuildLegs(d); commit(`[llm] ${stop.name} → ${stateLabel(stop.state)} on ${d.label}`); renderAll([stop.id]); return { ok: true, action: 'add_stop', stop: stop.name, slot, state: stop.state, day: d.label, demoted: inc ? inc.name : null }; }, remove_stop: (a) => { const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` }; const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))]; d.stops.splice(d.stops.indexOf(stop), 1); rebuildLegs(d); commit(`[llm] removed ${stop.name} from ${d.label}`); renderAll(); return { ok: true, action: 'remove_stop', removed: stop.name, day: d.label }; }, move_stop: (a) => { const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` }; const to = days[a.toDayId]; if (!to) return { error: `unknown day "${a.toDayId}"` }; const fromId = Object.keys(days).find(k => days[k].stops.includes(stop)); days[fromId].stops.splice(days[fromId].stops.indexOf(stop), 1); to.stops.push(stop); rebuildLegs(days[fromId]); rebuildLegs(to); commit(`[llm] moved ${stop.name} → ${to.label}`); renderAll(); return { ok: true, action: 'move_stop', stop: stop.name, from: days[fromId].label, to: to.label }; }, set_duration: (a) => { const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` }; const mins = Math.round(+a.minutes || 0); if (!mins || mins < 5) return { error: 'minutes must be a number ≥ 5' }; const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))]; stop.dur = mins; stop.suggested = { value: mins, conf: 4, src: 'assistant' }; rebuildLegs(d); layout(d); commit(`[llm] ${stop.name} → ${mins} min`); renderAll([stop.id]); return { ok: true, action: 'set_duration', stop: stop.name, minutes: mins, day: d.label, day_ends: fmt(layout(d)) }; }, set_start: (a) => { const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` }; const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' }; const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' }; d.startMin = h * 60 + mn; rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll(); return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) }; }, }; function llmSystemPrompt() { const cats = Object.keys(M.candidates); const regions = Object.keys(REGION_MAP).map(k => REGION_MAP[k].label); return ( 'You are the planning assistant inside a trip-planning app. The user is refining a trip and you are its orchestrator. Never invent times, prices, or places — read them from tools. Deterministic tools compute routes and validate the plan; you decide which to call and turn the results into a short, warm answer.\n\n' + 'You may call TOOLS. To call one, reply with ONLY this exact format — a single fenced json block, nothing else on that turn:\n' + '\n{"name":"TOOL_NAME","args":{...}}\n\n' + 'The tool result is then returned to you. From there you either call another tool the same way, or give your final natural-language answer.\n\n' + 'TOOLS (name — args — what it does):\n' + ' itinerary_summary() — the current plan: bookings, stays, and each day\'s scheduled stops with times.\n' + ' search_places(category, region?) — candidate places. category ∈ {' + cats.join(', ') + '} ; region ∈ {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' + ' place_facts(id) — full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' + ' route_between(from, to) — real walking minutes + km between two places ("hotel" or a place id/name).\n' + ' review_plan(dayId?) — deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' + ' add_stop(dayId, slot, candidateId, state?) — put a candidate on a day. slot ∈ lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state ∈ planned,idea,backup (default planned). candidateId comes from search_places.\n' + ' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' + ' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' + ' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' + ' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n\n' + 'Guidelines:\n' + '- dayId is the day id like "D1" or "D2" — shown in brackets in the plan rows, e.g. [D1 · 2026-09-12]. Never use the calendar date as a dayId.\n' + '- Answer concisely (1–4 sentences), plain text, no markdown.\n' + '- For any factual question about times, distances, or prices, CALL the tool — do not guess.\n' + '- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' + '- Before a big rework, consider review_plan() and mention any issues it reports.\n' + '- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough — do not re-search or re-fetch the stop you are moving/editing.\n\n' + 'Current plan:\n' + llmContext()); } // Parse a {...} block from the model output, if present. function parseToolBlock(text) { const m = /\s*([\s\S]*?)\s*<\/tool>/.exec(text); if (!m) return null; try { return JSON.parse(m[1]); } catch { return null; } } // A subtle activity line so the user sees the model working its tools. function toolLine(name, args) { const m = el('div', 'msg tool', '⚙ ' + esc(name) + (args && Object.keys(args).length ? ' ' + esc(JSON.stringify(args)) + '' : '')); $('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' }); return m; } async function askLLM(v) { scriptBusy = true; let t = typing(); const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }]; const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; }; try { let finalText = null; for (let i = 0; i < LLM_TOOL_MAX; i++) { const r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, max_tokens: 8192 }) }); const j = await r.json(); const content = (j.choices?.[0]?.message?.content || '').trim(); const call = parseToolBlock(content); if (call) { t.remove(); if (TOOLS[call.name]) toolLine(call.name, call.args || {}); t = typing(); // the assistant keeps "thinking" messages.push({ role: 'assistant', content }); let res; if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {})); else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') }; messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) }); continue; } finalText = content; break; } if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.'); else if (finalText) finish(esc(finalText).replace(/\n+/g, '
')); else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.'); } catch (e) { t.remove(); msg('ai', 'LLM call failed — falling back to canned replies.'); scriptBusy = false; } } // Google encoded-polyline decoder (OSRM `overview=full` geometry) // OSRM encodes with precision 5 (unlike Google’s default 6) function decodePolyline(str, precision = 5) { const out = []; let p = 0, lat = 0, lng = 0; const next = () => { // one zigzag varint component let result = 0, shift = 0, b; do { b = str.charCodeAt(p++) - 63; result += (b & 31) << shift; shift += 5; } while (b >= 32); return (result & 1) ? ~(result >> 1) : (result >> 1); }; while (p < str.length) { lat += next(); lng += next(); out.push([lng / 10 ** precision, lat / 10 ** precision]); // [lng, lat] } return out; } // route an ordered list of [lat,lng] points; null when unroutable (out of coverage) async function routePts(points, mode = 'foot') { if (!routerOn) return null; const q = points.map(p => `${p[1]},${p[0]}`).join(';'); try { const r = await fetch(`/route?mode=${mode}&router=${routerKey()}&points=${encodeURIComponent(q)}`); const j = await r.json(); const route = j?.routes?.[0]; if (!route) return null; // NoTable / out of coverage if (!route.distance && !route.duration) return null; // distance 0 = both points snapped to the same node → adjacent stops return { durMin: Math.max(1, Math.round(route.duration / 60)), distance: route.distance, geometry: route.geometry ? decodePolyline(route.geometry).map(p => [p[1], p[0]]) : [], // → [lat, lng] }; } catch { return null; } } let legToken = 0; // background enrichment: upgrade each straight-line leg to a real route async function enrichLegs(d = day) { if (!routerOn) return; const token = ++legToken; for (let i = 0; i < d.stops.length - 1; i++) { const a = d.stops[i], b = d.stops[i + 1]; if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue; // legs touching a transit stop are fixed (flight/taxi/bus), never re-routed if (a.kind === 'transit' || b.kind === 'transit') continue; const res = await routePts([a.at, b.at], d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode); if (token !== legToken) return; // the day changed mid-flight const leg = d.legs[i]; if (res) { const changed = leg.dur !== res.durMin || !leg.geometry; leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg.src = 'local router'; if (changed) renderAll(); } else { leg.conf = 1; leg.src = 'estimate — outside router coverage'; renderAll(); } } // return-to-hotel leg when a single stay anchors the day if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) { const last = d.stops[d.stops.length - 1]; if (last.kind !== 'region' && last.kind !== 'transit') { const res = await routePts([last.at, dayBase(d).at], 'foot'); if (token !== legToken) return; if (res) { d.retRouted = res; renderAll(); } } } } // ---------------- state ---------------- // (filled by enterTrip() — the app can switch between window.TRIPS at runtime) let days = {}; let curDay, day; const stopById = id => day.stops.find(s => s.id === id); const allStops = id => M.days.flatMap(d => d.stops).find(s => s.id === id); let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = []; let map, l3 = null, scriptBusy = false; let dragCand = null; // candidate object currently being dragged (drawer / dock) let compareItems = []; // items in the comparison dock (max 4) // user-facing state vocabulary (engine keeps planned/maybe/alt underneath) const stateLabel = st => st === 'planned' ? 'in plan' : st === 'maybe' ? 'idea' : 'backup'; // ---------------- mobile: single pane + bottom tabs ---------------- (function () { const tabs = $('#mobiletabs'); if (!tabs) return; const panes = { plan: $('#rail'), map: $('#mapwrap'), chat: $('#chat') }; const setTab = t => { Object.entries(panes).forEach(([k, e]) => e.classList.toggle('pane-hidden', k !== t)); tabs.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.t === t)); if (t === 'map') setTimeout(() => map.invalidateSize(), 80); }; tabs.querySelectorAll('button').forEach(b => b.onclick = () => setTab(b.dataset.t)); if (window.matchMedia('(max-width: 860px)').matches) setTab('plan'); })(); // ---------------- stays / base ---------------- // stays, not a single hotel: each stay anchors the dates it covers. // >1 candidate stay covering the same dates → worst-case anchoring for those dates. let stays, staySeq; // filled by enterTrip() const covers = (s, date) => s.checkIn <= date && date <= s.checkOut; const staysForDay = (d = day) => stays.filter(s => covers(s, d.date)); const dayBase = (d = day) => { const list = staysForDay(d); return list.find(s => s.state === 'booked') || list[0] || stays[0]; }; const hotelWalkMin = (at, d = day) => { const list = staysForDay(d); return list.length ? Math.max(1, ...list.map(o => Math.round(haversine(at, o.at) / 1000 / (WALK_KMH / 60)))) : 1; }; const stayRange = s => `${s.checkIn.slice(8)}–${s.checkOut.slice(8)} Sep`; function renderHotelMks() { hotelMks.forEach(m => map.removeLayer(m)); hotelMks = []; (stays || []).forEach(o => { const booked = o.state === 'booked'; hotelMks.push(L.marker(o.at, { icon: L.divIcon({ className: 'hotel-ico', html: `
🏨
`, iconSize: [0, 0] }) }).addTo(map).bindTooltip(`${o.name} · ${stayRange(o)} — ${booked ? 'booked, anchors these nights' : 'open option (worst-case anchor)'}`)); }); } let hotelMks = [], trainMks = []; function initMap() { const c = M.trip.places?.[0]?.center || [43.769, 11.252]; map = L.map('map', { zoomControl: false }).setView(c, 14); L.control.zoom({ position: 'bottomright' }).addTo(map); L.tileLayer('/tiles/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19 }).addTo(map); renderHotelMks(); // booked trains: origin + destination markers only, no route line M.trip.bookings.filter(b => b.type === 'train').forEach(b => { const mk = (pt, label) => L.marker(pt, { icon: L.divIcon({ className: 'hotel-ico', html: '
🚄
', iconSize: [0, 0] }) }).addTo(map).bindTooltip(label); trainMks.push(mk(b.stations.from, `${b.name} · dep ${b.depart} · ${b.from}`)); trainMks.push(mk(b.stations.to, `${b.name} · arr ${b.arrive} · ${b.to}`)); }); } function flashMarker(mk, on) { const e = mk.getElement()?.querySelector('.hotel-pin'); if (e) e.style.transform = on ? 'translate(-50%,-90%) scale(1.35)' : 'translate(-50%,-90%)'; if (on) mk.openTooltip(); else mk.closeTooltip(); if (on && !map.getBounds().contains(mk.getLatLng())) map.flyTo(mk.getLatLng(), 15, { duration: .5 }); } const CONF_CLS = { 5: 't-user', 4: 't-sched', 3: 't-computed', 2: 't-search', 1: 't-llm' }; function tchip(mins, conf, src) { const e = el('span', 'tc ' + CONF_CLS[conf], fmt(mins) + ' min'); if (src) e.title = 'source: ' + src; return e; } const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌', fly: '✈️' }; // ---------------- layout / legs ---------------- // every day ends by returning to the hotel anchor function returnDur(d = day) { if (d.isDeparture) return 0; // departure day: the train is the end of the day const last = d.stops[d.stops.length - 1]; if (!last) return 0; // a single stay anchored the day and the router has a real walk → use it if (d.retRouted && staysForDay(d).length === 1 && last.kind !== 'transit') return d.retRouted.durMin; const list = staysForDay(d); // worst case across every candidate stay for these nights (equals the real walk when one) return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (WALK_KMH / 60))); } function layout(d = day) { let t = d.startMin; d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; }); return t + returnDur(d); } function rebuildLegs(d = day) { d.legs = []; delete d.retRouted; for (let i = 0; i < d.stops.length - 1; i++) { const a = d.stops[i], b = d.stops[i + 1]; // a vague (region) stop at either end → worst-case leg until refined if (a.kind === 'region' || b.kind === 'region') { d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true }); } else { // legs touching a transit stop: fixed duration (flight + taxi/bus), // optionally broken down via M.multimodal entries if (a.kind === 'transit' || b.kind === 'transit') { const mm = (M.multimodal || []).find(x => x.into === b.id) || (M.multimodal || []).find(x => x.into === a.id); d.legs.push(mm ? { mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub } : { mode: 'fly', dur: 30, conf: 3, sub: [{ mode: 'fly', dur: 30 }] }); continue; } const mm = (M.multimodal || []).find(x => x.into === b.id); if (mm) { d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub }); } else { let h = 0; const key = a.name + '→' + b.name; for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997; // straight-line estimate until the router upgrades it (or proves out of coverage) d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 1 }); } } } enrichLegs(d); } // ---------------- vague / region placeholder stops ---------------- const REGION_MAP = { oltrarno: { label: 'Oltrarno', at: [43.7606, 11.2485], r: 520 }, smn: { label: 'near SMN', at: [43.7731, 11.2563], r: 620 }, duomo: { label: 'around the Duomo', at: [43.7731, 11.2556], r: 620 }, }; let nextStopId = 30; function addRegionStop(slot, region) { const existing = day.stops.find(x => x.slot === slot && x.state === 'planned'); const stop = { id: 'S' + (nextStopId++), name: `${slot[0].toUpperCase() + slot.slice(1)} — somewhere ${region.label}`, kind: 'region', mealKind: slot, slot, state: 'planned', at: region.at, region: { ...region }, dur: slot === 'breakfast' ? 30 : 90, durConf: 1, suggested: { value: slot === 'breakfast' ? 30 : 90, conf: 1, src: null } }; if (existing) { existing.state = 'alt'; day.stops[day.stops.indexOf(existing)] = stop; } else day.stops.push(stop); rebuildLegs(); renderAll([stop.id]); ai(`Placed a placeholder for ${slot} ${region.label} — it holds the slot and reserves time; travel times are worst-case until you pick a specific place. Open the ${slot} drawer or tap the card to see options.`, 900); } // ---------------- map ---------------- function renderMap() { Object.values(markers).forEach(m => map.removeLayer(m)); markers = {}; Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {}; otherLayers.forEach(l => map.removeLayer(l)); otherLayers = []; retLine = null; retLines = []; // every day, faintly — the whole trip is always visible M.days.forEach(d => { const obj = days[d.id]; if (!obj.stops.length) return; if (d.id !== curDay) { const pts = obj.stops.map(s => s.at); for (let i = 0; i < pts.length - 1; i++) otherLayers.push(L.polyline([pts[i], pts[i + 1]], { color: '#b6bcc8', weight: 3, opacity: .8, dashArray: '4 7' }).addTo(map)); if (pts.length && !obj.isDeparture) staysForDay(obj).forEach(o => otherLayers.push(L.polyline([pts[pts.length - 1], o.at], { color: '#c8cdd6', weight: 2.5, opacity: .8, dashArray: '2 6' }).addTo(map))); obj.stops.forEach(s => otherLayers.push(L.circleMarker(s.at, { radius: 5, color: '#9aa3b2', weight: 2, fillColor: '#fff', fillOpacity: 1 }) .addTo(map).bindTooltip(`${d.label} · ${s.name}`))); return; } const pts = obj.stops.map(s => s.at); for (let i = 0; i < pts.length - 1; i++) { // real road geometry once the router has answered, straight line until then const g = obj.legs[i]?.geometry || [pts[i], pts[i + 1]]; legEls[i] = L.polyline(g, { color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : obj.legs[i]?.mode === 'fly' ? '#8a93a6' : '#2e9e5b', weight: 5, opacity: .85, dashArray: obj.legs[i]?.mode === 'fly' ? '6 6' : undefined }).addTo(map); } if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row) staysForDay(obj).forEach(o => { const g = (obj.retRouted && staysForDay(obj).length === 1) ? obj.retRouted.geometry : [pts[pts.length - 1], o.at]; retLine = L.polyline(g, { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map); otherLayers.push(retLine); retLines.push(retLine); }); } obj.stops.forEach((s, i) => { const dim = s.state !== 'planned'; markers[s.id] = L.marker(s.at, { icon: L.divIcon({ className: '', html: dim ? `
${i + 1}
` : `
${i + 1}
`, iconSize: [26, 26], iconAnchor: [13, 13] }) }).addTo(map).bindTooltip(`${s.name} · ${fmt(s.start)}${s.state !== 'planned' ? ' · ' + stateLabel(s.state) : ''}`); if (s.region) // vague stop: show the search area, not a point otherLayers.push(L.circle(s.region.at, { radius: s.region.r, color: '#b5533c', weight: 2, dashArray: '6 6', fillColor: '#b5533c', fillOpacity: .08 }).addTo(map)); }); }); const cur = days[curDay]; const pts = scope === 'trip' ? M.days.flatMap(md => days[md.id].stops.map(s => s.at)) : cur.stops.map(s => s.at); if (pts.length) { const discOpen = !$('#discover').classList.contains('hidden'); const pad = matchMedia('(max-width: 860px)').matches ? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width : { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] }; // day scope: only the hotels anchoring THIS day (a multi-city trip has several) const stayPts = scope === 'trip' ? stays.map(o => o.at) : staysForDay().map(o => o.at); map.fitBounds(L.latLngBounds([dayBase().at, ...stayPts, ...pts]), pad); } } function highlightStop(id, on) { const m = markers[id]; if (!m) return; m.getElement()?.style?.setProperty('transform', on ? 'scale(1.25)' : ''); if (on) m.openTooltip(); else m.closeTooltip(); } // pulse in place; only reposition the map if the point is out of view function focusPoint(latlng, zoom = 16) { if (!map.getBounds().contains(latlng)) map.flyTo(latlng, Math.max(map.getZoom(), zoom), { duration: .5 }); const ring = L.circleMarker(latlng, { radius: 16, color: '#b5533c', weight: 3, fill: false, opacity: .9 }).addTo(map); setTimeout(() => map.removeLayer(ring), 1500); } const flyTo = s => focusPoint(s.at); function tempShow(s) { if (stopById(s.id)) return; const m = L.circleMarker(s.at, { radius: 8, color: '#5b6472', weight: 2, fillColor: '#8b93a3', fillOpacity: .6 }) .addTo(map).bindTooltip(s.name); tempMarkers.push(m); m.openTooltip(); if (!map.getBounds().contains(s.at)) map.flyTo(s.at, Math.max(map.getZoom(), 15), { duration: .4 }); } function tempClear() { tempMarkers.forEach(m => map.removeLayer(m)); tempMarkers = []; } // ---------------- day strip (multi-scale L1 overview) ---------------- function renderStrip() { const p = $('#daystrip'); if (!p) return; p.innerHTML = ''; layout(); const t0 = Math.floor(day.startMin / 60) * 60; const t1 = Math.max(layout() + 15, day.startMin + 360); const span = t1 - t0; const pos = t => (t - t0) / span * 100; const track = el('div', 'strip-track'); const block = (t, dur, cls, label, tip, onClick) => { const b = el('div', 'sb ' + cls, label); b.style.left = pos(t) + '%'; b.style.width = Math.max(1.4, dur / span * 100) + '%'; if (tip) b.title = tip; if (onClick) b.onclick = onClick; track.append(b); }; let cur = t0; if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'morning free'); day.stops.forEach((s, i) => { if (cur < s.start) block(cur, s.start - cur, 'gap', '', 'free time'); const kindCls = s.kind === 'meal' ? 'meal' : s.kind === 'transit' ? 'transit' : s.kind === 'region' ? 'region' : 'visit'; block(s.start, s.dur, kindCls + (s.state !== 'planned' ? ' dimmed' : ''), String(i + 1), `${s.name} · ${fmt(s.start)}–${fmt(s.start + s.dur)}${s.state !== 'planned' ? ' · ' + stateLabel(s.state) : ''}`, () => flyTo(s)); cur = s.start + s.dur; if (day.legs[i]) { block(cur, day.legs[i].dur, 'leg', '', `leg · ${day.legs[i].dur} min`); cur += day.legs[i].dur; } }); const ret = returnDur(); if (ret) block(cur, ret, 'hotel', '🏨', 'back to the hotel'); p.append(track); const axis = el('div', 'strip-axis'); for (let t = t0 + 60; t < t1; t += 60) { const h = el('span', 'tick', String(Math.floor(t / 60)).padStart(2, '0')); h.style.left = pos(t) + '%'; axis.append(h); } p.append(axis); } // ---------------- discovery (a drawer, not a mode) ---------------- const CATS = [ { id: 'lunch', label: 'Lunch' }, { id: 'dinner', label: 'Dinner' }, { id: 'visit', label: 'Afternoon' }, { id: 'hotel', label: 'Hotel' }, ]; const excluded = new Set(); // the exclusion list — survives across searches let disc = null; // {cat, region, results} let candMks = {}; const isExcluded = c => [...excluded].some(x => c.name.toLowerCase().includes(x) || x.includes(c.name.toLowerCase())); // where the user is when they make a choice: the stop right before the slot, // else the last planned stop, else the hotel function anchorStop(slot) { const idx = slot ? day.stops.findIndex(x => x.slot === slot) : -1; if (idx > 0) return day.stops[idx - 1]; if (idx === 0) return { name: dayBase().name, at: dayBase().at }; const planned = day.stops.filter(x => x.state === 'planned'); if (planned.length) return planned[planned.length - 1]; return { name: dayBase().name, at: dayBase().at }; } function poolFor(cat, region) { let pool = (M.candidates[cat] || []).filter(c => !isExcluded(c)); if (region) pool = pool.filter(c => haversine(c.at, region.at) <= region.r); return pool; } function openDiscover(cat, region = null) { if (!M.candidates[cat]?.length) { ai(`Nothing to browse for ${cat} here yet — try “lunch”, “afternoon” or “hotel”.`, 500); return; } disc = { cat, region: region || null, results: poolFor(cat, region) }; $('#discover').classList.remove('hidden'); renderDiscover(); renderMap(); } function closeDiscover(note) { if (!disc) return; Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {}; disc = null; $('#discover').classList.add('hidden'); renderMap(); if (note) ai(note, 400); } function renderDiscover() { const chipRow = $('#disc-chips'); chipRow.innerHTML = ''; CATS.forEach(c => { const b = el('button', 'dchip' + (disc.cat === c.id ? ' on' : ''), c.label); b.onclick = () => { disc = { cat: c.id, region: disc.region, results: poolFor(c.id, disc.region) }; renderDiscover(); renderMap(); }; chipRow.append(b); }); const catLabel = CATS.find(c => c.id === disc.cat).label; $('#disc-title').innerHTML = `${catLabel}${disc.region ? ' · ' + disc.region.label : ' · ' + dayLabel()} — ${disc.results.length} option${disc.results.length !== 1 ? 's' : ''}`; // numbered pins on the map Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {}; const anchor = anchorStop(disc.cat); const isHotel = disc.cat === 'hotel'; const list = $('#disc-list'); list.innerHTML = ''; disc.results.forEach((c, i) => { const card = candCard(c, anchor, isHotel); list.append(card); const mk = L.marker(c.at, { icon: L.divIcon({ className: '', html: `
${i + 1}
`, iconSize: [24, 32], iconAnchor: [12, 30] }) }).addTo(map).bindTooltip(c.name); mk.on('click', () => { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); card.classList.add('flash'); setTimeout(() => card.classList.remove('flash'), 1200); focusPoint(c.at); }); candMks[c.id] = mk; }); if (!disc.results.length) list.append(el('div', 'disc-empty', M.candidates[disc.cat]?.length ? 'Nothing left — loosen a filter in chat, or “reset”.' : 'No options in this pool yet — try another category.')); } function findCand(id) { for (const [slot, pool] of Object.entries(M.candidates)) { const c = pool.find(x => x.id === id); if (c) return { c, slot }; } return null; } function candCard(c, anchor, isHotel) { const card = el('div', 'candcard'); card.draggable = true; card.addEventListener('dragstart', e => { dragCand = c; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'cand:' + c.id); }); card.addEventListener('dragend', () => { dragCand = null; document.querySelectorAll('.candcard.dragging, .drop-target').forEach(x => x.classList.remove('dragging', 'drop-target')); }); const head = el('div', 'cc-head'); if (c.img) { const im = el('div', 'cc-img'); im.style.background = c.img[2]; if (c.img[3]) { const i = document.createElement('img'); i.src = c.img[3]; i.alt = c.img[1]; im.append(i); } else im.append(el('span', 'cc-emoji', c.img[0])); head.append(im); } head.append(el('div', 'cc-name', c.name)); card.append(head); if (c.pitch) card.append(el('div', 'cc-pitch', c.pitch)); const chips = el('div', 'cc-chips'); chips.append(el('span', 'pchip', `↦ ${walkMin(anchor.at, c.at)} min from ${anchor.name}`)); if (anchor.name !== dayBase().name) chips.append(el('span', 'pchip', `→ ${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}`)); if (c.dur) chips.append(el('span', 'tc t-search', '~' + fmt(c.dur))); chips.append(el('span', 'pchip', c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free')); card.append(chips); if (c.tags?.length) card.append(el('div', 'cc-tags', c.tags.map(t => '#' + t).join(' '))); const acts = el('div', 'cc-acts'); const pick = el('button', 'btn primary small', isHotel ? 'Make this the hotel' : 'Choose this'); pick.onclick = () => chooseCand(c); const idea = el('button', 'btn ghost small', 'Idea'); idea.onclick = () => holdIdea(c); const cmp = el('button', 'btn ghost small', '⚖ Compare'); cmp.onclick = () => toggleCompare(itemFromCand(c, isHotel ? 'hotel' : disc.cat)); const no = el('button', 'cc-no', 'Not for me'); no.title = 'remember as a preference'; no.onclick = () => { excluded.add(c.name.toLowerCase().includes('—') ? c.name.split('—').pop().trim() : c.name); disc.results.splice(disc.results.indexOf(c), 1); renderDiscover(); renderMap(); }; acts.append(pick, idea, cmp, no); card.append(acts); return card; } function makeStop(c, slot, state) { return { id: c.id, name: c.name, kind: slot === 'visit' ? 'visit' : 'meal', mealKind: slot === 'visit' ? undefined : slot, slot, state, at: c.at, dur: c.dur, durConf: 3, suggested: { value: c.dur, conf: 2, src: 'tripadvisor.com' }, price: c.price ? { amount: c.price, cur: '€', conf: 2, src: 'tripadvisor.com' } : null, img: c.img, url: c.url }; } // one slot, one plan: promote the candidate, demote the incumbent to backup function promoteCand(c, slot, state = 'planned', flash = true) { const existing = day.stops.find(x => x.id === c.id); const stop = existing || makeStop(c, slot, state); let incumbent = null; if (state === 'planned') { incumbent = day.stops.find(x => x.slot === slot && x.state === 'planned' && x !== stop) || null; if (incumbent) incumbent.state = 'alt'; } stop.state = state; if (!existing) { // take the incumbent’s sequence position so the day’s order stays sensible const at = incumbent ? day.stops.indexOf(incumbent) : -1; if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop); } rebuildLegs(); commit(`${stop.name} → ${stateLabel(state)}${incumbent ? ' (swapped for ' + incumbent.name + ')' : ''}`); renderAll(flash ? [stop.id] : []); return { stop, incumbent: stop.slot === slot && stop.state === 'planned' ? day.stops.find(x => x.slot === slot && x.state === 'alt' && x !== stop) : null }; } function chooseCand(c, slot) { slot = slot || disc?.cat; if (slot === 'hotel') { const cur = dayBase(); const inRange = s => s.checkIn <= cur.checkOut && s.checkOut >= cur.checkIn; // same nights stays = stays.filter(s => !inRange(s)); stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place, checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'booked', price: c.price }); renderHotelMks(); renderBaseChip(); closeDiscover(); commit(`hotel → ${c.name}`); renderAll(); ai(`Now staying at ${c.name} for ${stayRange(cur)} — those nights re-anchor on it, and the “back to hotel” legs dropped from worst-case to real times.`, 700); return; } const loose = day.stops.find(x => x.slot === slot && x.kind === 'region'); if (loose) { day.stops[day.stops.indexOf(loose)] = makeStop(c, slot, 'planned'); rebuildLegs(); closeDiscover(); renderAll([c.id]); ai(`Refined: ${c.name} is your ${slot} now — the placeholder is gone and the leg times are real.`, 600); return; } const { incumbent } = promoteCand(c, slot); closeDiscover(); ai(`Locked in ${c.name} for ${dayLabel()}${incumbent ? ` — ${incumbent.name} becomes the backup: one ${slot}, one plan, swap any time.` : '. See it in the day.'}`, 600); } function holdIdea(c, slot) { slot = slot || disc?.cat; if (slot === 'hotel') { if (stays.some(o => o.name === c.name)) { closeDiscover(); return; } const cur = dayBase(); stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place, checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'alt', price: c.price }); renderHotelMks(); renderBaseChip(); closeDiscover(); commit(`hotel option: ${c.name}`); renderAll(); ai(`Keeping ${c.name} open for ${stayRange(cur)} — “back to hotel” now runs worst-case across ${staysForDay().length} candidate hotels until you commit (“make this the hotel” when you decide).`, 800); return; } const loose = day.stops.find(x => x.slot === slot && x.kind === 'region'); if (loose) { const stop = makeStop(c, slot, 'maybe'); day.stops[day.stops.indexOf(loose)] = stop; rebuildLegs(); closeDiscover(); renderAll([stop.id]); ai(`Holding ${c.name} as an idea — the placeholder became a specific option, off the time budget.`, 600); return; } if (day.stops.some(x => x.id === c.id)) { closeDiscover(); return; } day.stops.push(makeStop(c, slot, 'maybe')); rebuildLegs(); closeDiscover(); renderAll([c.id]); ai(`Holding ${c.name} as an idea — dashed on the day, not counted against the time budget.`, 500); } // chat still filters an open drawer: "cheaper", "near Boboli", "no Mario" function drawerFilter(text) { const t = text.toLowerCase(); let res = [...disc.results]; const notes = []; if (/cheaper|cheap|less|budget|under/.test(t)) { const m = t.match(/under\s*€?(\d+)/); const cap = m ? +m[1] : (disc.cat === 'hotel' ? 220 : 25); // “cheaper” means different things per slot res = res.filter(c => c.price <= cap); notes.push(`≤ €${cap}`); } if (/boboli/.test(t)) { res = res.filter(c => haversine(c.at, [43.7579, 11.2463]) < 1200); notes.push('near Boboli'); } if (/pitti|accademia/.test(t)) { res = res.filter(c => haversine(c.at, [43.7639, 11.2518]) < 900); notes.push('near the sights you have planned'); } if (/views|terrace|rooftop/.test(t)) { res = res.filter(c => c.tags.some(x => /view|terrace/.test(x))); notes.push('views'); } if (/veg/.test(t)) { res = res.filter(c => c.tags.some(x => /veg|garden/.test(x))); notes.push('vegetarian-friendly'); } if (/quiet|calm|cozy|cellar/.test(t)) { res = res.filter(c => c.tags.some(x => /quiet|calm|cozy|cellar/.test(x))); notes.push('quieter'); } if (/free/.test(t)) { res = res.filter(c => !c.price); notes.push('free'); } if (/fast|quick|casual/.test(t)) { res = res.filter(c => c.tags.some(x => /fast|casual/.test(x))); notes.push('fast & casual'); } const noM = text.match(/\b(?:no|not|without|skip)\s+([a-zàé'. ]{3,30})/i); if (noM) { const nm = noM[1].trim(); const hit = disc.results.find(c => c.name.toLowerCase().includes(nm)); if (hit) { excluded.add(hit.name); res = res.filter(c => c !== hit); notes.push(`excluded ${hit.name}`); } } disc.results = res; renderDiscover(); if (res.length) { const a = anchorStop(disc.cat); const closest = res.reduce((m, c) => walkMin(a.at, c.at) < walkMin(a.at, m.at) ? c : m, res[0]); ai(`${res.length} left${notes.length ? ' — ' + notes.join(', ') : ''}. Closest to ${a.name}: ${closest.name} (${walkMin(a.at, closest.at)} min walk). Keep filtering or tap a card.`, 600); } else ai(`Nothing left${notes.length ? ' after ' + notes.join(', ') : ''} — loosen a filter, or say “reset” to start from the full list.`, 600); } $('#disc-x').onclick = () => closeDiscover('Back to the plan.'); document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc) closeDiscover(); else closeL3(); } }); // ---------------- rail ---------------- function renderRail(flashIds = []) { layout(); // assign start times before the cards render them const body = $('#rail-body'); body.innerHTML = ''; day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds)); if (day.stops.length && !day.isDeparture) { const multi = staysForDay().length > 1; const ret = el('div', 'leg-row', `🏨back to hotel `); ret.append(tchip(returnDur(), multi ? 1 : 3)); if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`)); const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null }; const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }; ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on))); ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off))); body.append(ret); } renderStrip(); renderBudget(); renderNudges(); renderSugg(); } const POOL_KEY = { lunch: 'lunch', dinner: 'dinner', visit: 'visit' }; // inline alternatives for a planned stop: same-slot candidates, not already in the day function slotAlternatives(s, n = 3) { const key = POOL_KEY[s.slot]; if (!key) return []; const a = anchorStop(s.slot); return [...(M.candidates[key] || [])] .filter(c => c.id !== s.id && !isExcluded(c) && !day.stops.some(x => x.id === c.id)) .sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, n); } function renderStopCard(body, s, i, flashIds) { if (s.kind === 'transit') { // booked external transport: fixed, shown only on its own day const c = el('div', 'stop-card transit-card'); if (flashIds.includes(s.id)) c.classList.add('flash'); const top = el('div', 'sc-top'); top.append(el('span', 'num', String(i + 1))); top.append(el('span', 'sc-name', `${s.transit.name} → ${s.transit.to}`)); top.append(el('span', 'sc-times', `${s.transit.arriveBy}–${s.transit.depart}`)); c.append(top); const meta = el('div', 'sc-meta'); meta.append(el('span', 'tc t-sched', 'booked')); meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`)); c.append(meta); c.append(el('div', 'sc-sub', `arrive by ${s.transit.arriveBy} — ${s.transit.buffer}`)); c.addEventListener('mouseenter', () => flashMarker(trainMks[0], true)); c.addEventListener('mouseleave', () => flashMarker(trainMks[0], false)); c.addEventListener('click', () => focusPoint(s.at)); body.append(c); return; } const c = el('div', 'stop-card st-' + s.state); if (flashIds.includes(s.id)) c.classList.add('flash'); const top = el('div', 'sc-top'); top.append(el('span', 'grip', '⠿')); top.append(el('span', 'num', String(i + 1))); top.append(el('span', 'sc-name', s.name)); top.append(el('span', 'sc-times', `${fmt(s.start)}–${fmt(s.start + s.dur)}`)); c.append(top); const meta = el('div', 'sc-meta'); if (s.state !== 'planned') { const st = el('span', 'sc-state ' + (s.state === 'maybe' ? 't-idea' : 't-backup'), stateLabel(s.state)); meta.append(st); } meta.append(tchip(s.dur, s.durConf)); if (s.suggested && s.suggested.value !== s.dur && s.state === 'planned') meta.append(el('span', 'sug-note', `usually ~${fmt(s.suggested.value)}`)); if (s.price) meta.append(el('span', 'pchip', `~${s.price.amount}${s.price.cur}`)); c.append(meta); // vague placeholder: one tap to browse the area if (s.region) { c.append(el('div', 'region-note', 'placeholder — travel times are worst-case until refined')); const rf = el('button', 'refine-btn', 'See options →'); rf.onclick = e => { e.stopPropagation(); openDiscover(POOL_KEY[s.slot] || s.slot, s.region); }; c.append(rf); } // quick actions: compare + alternatives (planned only) + more menu const acts = el('div', 'sc-acts'); const cmp = el('button', 'btn ghost tiny', '⚖'); cmp.title = 'Add to comparison'; cmp.onclick = e => { e.stopPropagation(); toggleCompare(itemFromStop(s)); }; acts.append(cmp); if (s.state === 'planned') { const alts = slotAlternatives(s); if (alts.length) { const aBtn = el('button', 'btn ghost tiny', `⇄ alternatives (${alts.length})`); const box = el('div', 'alts'); alts.forEach(cand => { const row = el('div', 'altrow'); row.append(el('span', 'ar-name', cand.name)); row.append(el('span', 'pchip', `↦ ${walkMin(anchorStop(s.slot).at, cand.at)} min`)); row.append(el('span', 'pchip', cand.price ? '~' + cand.price + '€' : 'free')); const sw = el('button', 'btn ghost tiny', 'Swap'); sw.onclick = e => { e.stopPropagation(); const { incumbent } = promoteCand(cand, s.slot); ai(`Swapped: ${cand.name} is your ${s.slot} now${incumbent ? `; ${incumbent.name} becomes the backup.` : ''} Times re-sequenced.`, 500); }; const ac = el('button', 'btn ghost tiny', '⚖'); ac.title = 'Compare'; ac.onclick = e => { e.stopPropagation(); toggleCompare(itemFromCand(cand, s.slot)); }; row.append(sw, ac); box.append(row); }); aBtn.onclick = e => { e.stopPropagation(); box.classList.toggle('open'); }; acts.append(aBtn); c.append(box); } } else { // idea / backup: one-tap promote const up = el('button', 'btn ghost tiny', s.state === 'alt' ? '⇄ Swap in' : '✓ Make planned'); up.onclick = e => { e.stopPropagation(); setState(s, 'planned'); }; acts.append(up); } const more = el('button', 'btn ghost tiny', '⋯'); more.title = 'More (move, remove, state)'; more.onclick = e => { e.stopPropagation(); openStateMenu(s, more); }; acts.append(more); c.append(acts); // drag & drop: reorder within day, or drop a candidate card onto this stop c.draggable = true; c.addEventListener('dragstart', e => { if (dragCand) { e.preventDefault(); return; } e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); c.classList.add('dragging'); }); c.addEventListener('dragend', () => c.classList.remove('dragging')); c.addEventListener('dragover', e => { e.preventDefault(); if (dragCand) c.classList.add('drop-target'); }); c.addEventListener('dragleave', () => c.classList.remove('drop-target')); c.addEventListener('drop', e => { e.preventDefault(); c.classList.remove('drop-target'); const data = e.dataTransfer.getData('text/plain'); if (data.startsWith('cand:')) { const found = findCand(data.slice(5)); if (!found) return; const { c: cand, slot } = found; dragCand = null; e.stopPropagation(); if (slot === s.slot) { const { incumbent } = promoteCand(cand, slot); if (incumbent) ai(`Dropped ${cand.name} over its slot — it’s your ${slot} now; ${incumbent.name} becomes the backup.`, 500); else ai(`Dropped ${cand.name} into ${dayLabel()}.`, 500); } else { day.stops.push(makeStop(cand, slot, 'maybe')); rebuildLegs(); commit(`idea: ${cand.name}`); renderAll([cand.id]); ai(`Dropped ${cand.name} into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500); } if (disc) closeDiscover(); return; } const from = parseInt(data, 10); if (isNaN(from) || from === i) return; const [moved] = day.stops.splice(from, 1); day.stops.splice(i, 0, moved); rebuildLegs(); commit(`moved ${moved.name}`); renderAll([moved.id]); }); c.addEventListener('mouseenter', () => highlightStop(s.id, true)); c.addEventListener('mouseleave', () => highlightStop(s.id, false)); c.addEventListener('click', e => { if (e.target.closest('button, a')) return; if (s.detail) openDetail(s, null); else flyTo(s); }); body.append(c); if (day.legs[i]) { const g = day.legs[i]; const lr = el('div', 'leg-row' + (s.state !== 'planned' ? ' dim' : '')); lr.append(el('span', 'leg-ico', MODE_ICO[g.mode] || '•')); if (g.sub) lr.append(el('span', 'leg-sub', g.sub.map(x => `${MODE_ICO[x.mode]} ${x.dur}′`).join(' + '))); if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined')); lr.append(document.createTextNode(' ')); lr.append(tchip(g.dur, g.conf)); const base = { color: g.mode === 'multimodal' ? '#2f6fd6' : g.mode === 'fly' ? '#8a93a6' : '#2e9e5b', weight: 5, opacity: .85, dashArray: g.mode === 'fly' ? '6 6' : undefined }; lr.addEventListener('mouseenter', () => legEls[i] && legEls[i].setStyle({ ...base, weight: 7, opacity: 1 })); lr.addEventListener('mouseleave', () => legEls[i] && legEls[i].setStyle(base)); lr.onclick = () => openL3(i); body.append(lr); } } // state menu (promote / demote / move day / remove) function openStateMenu(s, anchor) { closeMenus(); const menu = el('div', 'pop'); const mk = (label, fn, danger) => { const b = el('button', danger ? 'danger' : '', label); b.onclick = e => { e.stopPropagation(); closeMenus(); fn(); }; menu.append(b); }; if (s.state === 'planned') { mk('→ Idea', () => setState(s, 'maybe')); mk('→ Backup', () => setState(s, 'alt')); } else if (s.state === 'maybe') { mk('→ In plan', () => setState(s, 'planned')); mk('→ Backup', () => setState(s, 'alt')); } else { mk('⇄ Swap in', () => setState(s, 'planned')); mk('→ Idea', () => setState(s, 'maybe')); } M.days.filter(d => d.id !== day.id).forEach(d => { mk(`Move to ${d.label}`, () => moveStopToDay(s, d.id)); }); mk('Remove from day', () => { const slot = s.slot; day.stops.splice(day.stops.indexOf(s), 1); let promoted = null; if (!day.stops.some(x => x.slot === slot && x.state === 'planned')) { promoted = day.stops.find(x => x.slot === slot && x.state === 'alt'); if (promoted) promoted.state = 'planned'; // last backup in the slot becomes the plan } rebuildLegs(); renderAll(); ai(`Removed ${s.name} from ${dayLabel()}${promoted ? ` — nothing else was planned for ${slot}, so ${promoted.name} is now your ${slot}.` : '.'}`, 500); }, true); anchor.parentElement.parentElement.append(menu); // card is position:relative } function closeMenus() { document.querySelectorAll('.pop').forEach(p => p.remove()); } document.addEventListener('click', e => { if (!e.target.closest('.pop')) closeMenus(); }); function setState(s, state) { if (state === 'planned' && s.state !== 'planned') { const cur = day.stops.find(x => x.slot === s.slot && x.state === 'planned' && x !== s); if (cur) { cur.state = 'alt'; ai(`Swapped: ${s.name} is now your ${s.slot}; ${cur.name} becomes the backup.`, 600); } } s.state = state; rebuildLegs(); commit(`${s.name} → ${stateLabel(state)}`); renderAll([s.id]); if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt'))) ai(`${s.name} is now “${stateLabel(state)}”.`, 400); } function moveStopToDay(s, targetId) { day.stops.splice(day.stops.indexOf(s), 1); const t = days[targetId]; t.stops.push(s); rebuildLegs(); if (t !== day) rebuildLegs(t); commit(`${s.name} → ${t.label}`); renderAll(); ai(`Moved ${s.name} to ${t.label} — legs and times re-timed there.`, 500); } const dayLabel = () => M.days.find(d => d.id === day.id).label; // ---------------- time budget (gentle phrasing) ---------------- function renderBudget() { const prim = day.stops.filter(s => s.state === 'planned'); const flex = day.stops.length - prim.length; const planned = prim.reduce((a, s) => a + s.dur, 0) + day.legs.reduce((a, g) => a + g.dur, 0) + (prim.length ? returnDur() : 0); const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0); const end = layout(); const cap = day.wakingHours * 60; const pct = Math.min(100, planned / cap * 100); const fill = $('#budget-fill'); fill.style.width = pct + '%'; fill.classList.toggle('warn', pct > 92); const mood = pct > 92 ? ' — running a bit full, there’s room to trim' : pct > 80 ? '' : ' — comfortably paced'; $('#budget-text').textContent = `${(planned / 60).toFixed(1)} h of ${day.wakingHours} h · ends ~${fmt(end)}${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h of ideas' : ''}${mood}`; } // ---------------- nudges (issues, but gentle) ---------------- // Data-driven (M.nudges); a runtime copy carries the per-day `fixed` flags. const nudgesByDay = deep(M.nudges || {}); Object.values(nudgesByDay).forEach(list => list.forEach(n => n.fixed = false)); function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); } function renderNudges() { const p = $('#nudges'); if (!p) return; p.innerHTML = ''; const list = activeNudges(); if (!list.length) { p.append(el('div', 'nudge-ok', '✓ this day looks good')); } list.forEach(n => { const d = el('div', 'nudge'); d.append(el('div', 'nt', n.title)); d.append(el('div', 'nb', n.body)); const b = el('button', 'btn ghost small', n.fix.label); b.onclick = () => { n.fixed = true; if (n.fix.stop && n.fix.dur) { const st = stopById(n.fix.stop); if (st) st.dur = n.fix.dur; } commit(`nudge: ${n.fix.label}`); renderAll(); if (n.fix.msg) ai(n.fix.msg, 300); }; d.append(b); p.append(d); }); const badge = $('#nudge-badge'); if (badge) { badge.innerHTML = list.length ? `💡 ${list.length} nudge${list.length > 1 ? 's' : ''}` : '✓ looking good'; badge.classList.toggle('ok', !list.length); } } // ---------------- "you might like" (context-aware suggestions in the rail) ---------------- function renderSugg() { const p = $('#sugg-block'); if (!p) return; p.innerHTML = ''; const planned = day.stops.filter(s => s.state === 'planned'); const missing = ['dinner', 'lunch', 'visit'].find(k => !planned.some(s => s.slot === k) && M.candidates[k]?.length); let title, note, pool, slot; if (missing) { slot = missing; title = `No ${missing} planned yet`; note = `· ${dayLabel()}`; pool = M.candidates[slot].filter(c => !isExcluded(c)); } else { slot = 'visit'; title = 'More like this day'; note = ''; pool = (M.candidates.visit || []).filter(c => !isExcluded(c) && !day.stops.some(x => x.id === c.id)); } if (!pool.length) return; const a = anchorStop(slot); const best = [...pool].sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, 3); const head = el('div', 'sugg-title', `💡 ${title} ${note}`); const open = el('button', 'sugg-open', 'open drawer'); open.onclick = () => openDiscover(slot); head.append(open); p.append(head); best.forEach(c => { const row = el('div', 'sugg-row'); row.append(el('span', 'sr-name', c.name)); row.append(el('span', 'pchip', `↦ ${walkMin(a.at, c.at)} min`)); row.append(el('span', 'pchip', c.price ? '~' + c.price + '€' : 'free')); const cmp = el('button', 'btn ghost tiny', '⚖'); cmp.title = 'Compare'; cmp.onclick = () => toggleCompare(itemFromCand(c, slot)); row.append(cmp); p.append(row); }); } // ---------------- comparison dock (multi-scale alternatives) ---------------- function itemFromCand(c, slot) { return { key: 'cand:' + c.id, kind: 'cand', cand: c, slot, name: c.name, at: c.at, price: c.price, dur: c.dur, tags: c.tags, img: c.img }; } function itemFromStop(s) { return { key: 'stop:' + s.id, kind: 'stop', stop: s, slot: s.slot, name: s.name, at: s.at, price: s.price ? s.price.amount : null, dur: s.dur, tags: [s.state === 'planned' ? 'in plan' : stateLabel(s.state), s.kind], img: s.img }; } function toggleCompare(item) { const i = compareItems.findIndex(x => x.key === item.key); if (i >= 0) { compareItems.splice(i, 1); } else { if (compareItems.length >= 4) { ai('The comparison holds up to four — remove one first (✕).', 400); return; } compareItems.push(item); } renderDock(); } function renderDock() { const d = $('#dock'); if (!compareItems.length) { d.classList.add('hidden'); d.innerHTML = ''; return; } d.classList.remove('hidden'); d.innerHTML = ''; const head = el('div', 'dock-head'); head.append(el('span', null, `Comparing ${compareItems.length} — side by side, from the anchor point; choose or hold any of them`)); const clr = el('button', 'dock-clear', 'clear all'); clr.onclick = () => { compareItems = []; renderDock(); }; head.append(clr); d.append(head); const grid = el('div', 'dock-grid'); compareItems.forEach(item => { const isHotel = item.slot === 'hotel'; const a = anchorStop(isHotel ? null : item.slot); const col = el('div', 'dock-col'); const t = el('div', 'dc-name', item.name); if (item.img) { const th = el('div', 'dc-thumb'); th.style.background = item.img[2]; th.textContent = item.img[0]; t.prepend(th); } col.append(t); const chips = el('div', 'dc-chips'); if (item.at) { chips.append(el('span', 'pchip', `↦ ${walkMin(a.at, item.at)} min from ${a.name}`)); if (a.name !== dayBase().name) chips.append(el('span', 'pchip', `→ ${hotelWalkMin(item.at)} min to hotel`)); } if (item.dur) chips.append(el('span', 'tc t-search', '~' + fmt(item.dur))); const price = typeof item.price === 'number' ? item.price : (item.price?.amount ?? null); chips.append(el('span', 'pchip', price ? '~' + price + '€ / ' + (isHotel ? 'night' : 'person') : item.price === 0 ? 'free' : '—')); col.append(chips); if (item.tags?.length) col.append(el('div', 'dc-tags', item.tags.map(t => (t === 'in plan' ? '✓ ' : '') + t).join(' · '))); const acts = el('div', 'dc-acts'); if (item.kind === 'cand') { const pick = el('button', 'btn primary tiny', isHotel ? 'Make this the hotel' : 'Choose this'); pick.onclick = () => { chooseCand(item.cand, item.slot); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); }; const idea = el('button', 'btn ghost tiny', 'Idea'); idea.onclick = () => { holdIdea(item.cand, item.slot); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); }; acts.append(pick, idea); } else { const st = el('span', 'dc-state', item.stop.state === 'planned' ? 'in the plan' : stateLabel(item.stop.state)); if (item.stop.state !== 'planned') { const up = el('button', 'btn primary tiny', '⇄ Swap in'); up.onclick = () => { setState(item.stop, 'planned'); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); }; acts.append(up); } else acts.append(st); } const rm = el('button', 'dc-rm', '✕'); rm.title = 'remove from comparison'; rm.onclick = () => { compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); }; acts.append(rm); col.append(acts); grid.append(col); }); d.append(grid); } // ---------------- L3 route editor ---------------- function haversine(a, b) { const R = 6371e3, toR = d => d * Math.PI / 180; const dLat = toR(b[0] - a[0]), dLon = toR(b[1] - a[1]); const h = Math.sin(dLat / 2) ** 2 + Math.cos(toR(a[0])) * Math.cos(toR(b[0])) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(h)); } function openL3(legIdx) { closeL3(); const a = day.stops[legIdx].at, b = day.stops[legIdx + 1].at; const leg = day.legs[legIdx]; const mid = [(a[0] + b[0]) / 2 + 0.0012, (a[1] + b[1]) / 2 - 0.0018]; const layer = L.layerGroup().addTo(map); const line = L.polyline([a, mid, b], { color: '#b5533c', weight: 5, opacity: .9, dashArray: '6 6' }).addTo(layer); const mk = p => L.marker(p, { draggable: true, icon: L.divIcon({ className: 'wp', html: '
', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(layer); const m1 = mk(a), m2 = mk(mid), m3 = mk(b); const box = $('#l3-editor .l3-time'); let rt = 0; // debounce token for the router round-trip const upd = () => { const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng(); line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]); const dist = haversine([p1.lat, p1.lng], [p2.lat, p2.lng]) + haversine([p2.lat, p2.lng], [p3.lat, p3.lng]); leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // instant straight-line estimate leg.conf = 1; box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1)); renderBudget(); const t = ++rt; routePts([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]], 'foot').then(res => { if (t !== rt || !l3) return; if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; line.setLatLngs(res.geometry); } box.innerHTML = 'leg time: ' + (res ? '' : 'est. '); box.append(tchip(leg.dur, leg.conf)); renderBudget(); }); }; [m1, m2, m3].forEach(m => m.on('drag', upd)); $('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`; box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3)); $('#l3-editor').classList.remove('hidden'); map.fitBounds(L.latLngBounds([a, b]).pad(0.4)); l3 = { layer }; const obs = setInterval(() => { if (!$('#l3-editor').classList.contains('hidden')) return; clearInterval(obs); commit('edited a leg route'); renderAll(); }, 250); } function closeL3() { if (l3) { map.removeLayer(l3.layer); l3 = null; } $('#l3-editor').classList.add('hidden'); } $('#l3-close').onclick = closeL3; // ---------------- version history (every edit = a version) ---------------- // Both editors (user + chat) funnel through commit(): the snapshot captures // days + stays, so undo/redo/revert is a single code path. let version = 0; let history = []; // [{v, label, at, days, stays}] let hIdx = -1; // pointer into history const commit = (label) => { history = history.slice(0, hIdx + 1); // drop any redo branch history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays) }); hIdx = history.length - 1; if (history.length > 60) { history.shift(); hIdx--; } renderVersionPill(); if (typeof persistTrip === 'function') persistTrip(); }; function restore(idx, silent) { const s = history[idx]; if (!s) return; hIdx = idx; Object.keys(days).forEach(k => { Object.assign(days[k], deep(s.days[k])); }); stays = deep(s.stays); staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); closeDiscover(); closeL3(); rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs renderHotelMks(); renderBaseChip(); renderAll(); renderVersionPill(); if (!silent) ai(`↩ Reverted to v${s.v} — ${s.label}. Later edits are gone from the branch; redo can’t bring them back.`, 400); } function undo() { if (hIdx > 0) restore(hIdx - 1); } function redo() { if (hIdx < history.length - 1) restore(hIdx + 1); } function renderVersionPill() { const elV = $('#verlabel'); if (!elV) return; elV.textContent = 'v' + version; $('#ver-undo').classList.toggle('off', hIdx <= 0); $('#ver-redo').classList.toggle('off', hIdx >= history.length - 1); } // what changed between that version and the current plan (stop-level, per day) function diffVsCurrent(snap) { const parts = []; M.days.forEach(md => { const A = (snap.days[md.id] || {}).stops || []; const B = (days[md.id] || {}).stops || []; const aSet = new Set(A.map(x => x.id)), bSet = new Set(B.map(x => x.id)); const added = B.filter(x => !aSet.has(x.id)).map(x => x.name); const removed = A.filter(x => !bSet.has(x.id)).map(x => x.name); if (added.length || removed.length) parts.push(`${md.label.split(' · ')[0]}${added.length ? ' +' + added.join(', +') : ''}${removed.length ? ' −' + removed.join(', −') : ''}`); }); return parts.slice(0, 3); } function toggleVerList() { const p = $('#verlist'); if (!p.classList.contains('hidden')) { p.classList.add('hidden'); return; } p.innerHTML = ''; const rows = history.map((s, i) => ({ s, i })).slice(-14).reverse(); rows.forEach(({ s, i }) => { const r = el('div', 'ver-row' + (i === hIdx ? ' cur' : '')); const when = new Date(s.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const d = diffVsCurrent(s); r.append(el('div', 'vr-main', `v${s.v} · ${when} · ${s.label}` + (i === hIdx ? ' current' : ''))); if (d.length && i !== hIdx) r.append(el('div', 'vr-diff', d.join(' · '))); r.onclick = () => { p.classList.add('hidden'); if (i !== hIdx) restore(i); }; p.append(r); }); p.classList.remove('hidden'); } $('#ver-undo').onclick = undo; $('#ver-redo').onclick = redo; $('#verlabel').onclick = toggleVerList; document.addEventListener('click', e => { if (!e.target.closest('#verpill, #verlist')) $('#verlist')?.classList.add('hidden'); }); document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); } }); // ---------------- day tabs ---------------- function buildDayTabs() { const tabs = $('#daytabs'); tabs.innerHTML = ''; M.days.forEach(d => { const t = el('button', 'daytab' + (d.id === curDay ? ' sel' : ''), d.label); t.onclick = () => setDay(d.id); t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); }); t.addEventListener('dragleave', () => t.classList.remove('over')); t.addEventListener('drop', e => { e.preventDefault(); t.classList.remove('over'); const i = parseInt(e.dataTransfer.getData('text/plain'), 10); const s = day.stops[i]; if (!s) return; day.stops.splice(i, 1); const tDay = days[d.id]; tDay.stops.push(s); rebuildLegs(); if (tDay !== day) rebuildLegs(tDay); setDay(curDay); ai(`Moved ${s.name} to ${tDay.label}.`, 400); }); tabs.append(t); }); } function setDay(id) { curDay = id; day = days[id]; if (!day.legs.length) rebuildLegs(day); document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', M.days[i] && M.days[i].id === id)); closeL3(); // a selected route from another day is stale tempClear(); if (disc) closeDiscover(); // the drawer’s anchor is day-specific renderAll(); } let scope = 'day'; // 'day' (default working view) | 'trip' (L0 macro overview) function renderAll(flashIds = []) { if (scope === 'trip') renderTripRail(); else renderRail(flashIds); renderMap(); } function setScope(sc) { scope = sc; document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === sc)); $('#daystrip').classList.toggle('hidden', sc === 'trip'); $('#rail-foot').classList.toggle('hidden', sc === 'trip'); renderAll(); } $('#scope-day').onclick = () => setScope('day'); $('#scope-trip').onclick = () => setScope('trip'); // ---------------- L0 trip overview (macro scale) ---------------- function dayStats(d) { const planned = d.stops.filter(s => s.state === 'planned'); const dur = planned.reduce((a, s) => a + s.dur, 0) + d.legs.reduce((a, g) => a + g.dur, 0) + (planned.length && !d.isDeparture ? returnDur(d) : 0); return { count: planned.length, dur, end: layout(d), price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0), flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 }; } function renderTripRail() { const body = $('#rail-body'); body.innerHTML = ''; const total = M.days.reduce((a, md) => a + dayStats(days[md.id]).price, 0); const head = el('div', 'trip-head'); head.append(el('div', 'trip-title', M.trip.title)); head.append(el('div', 'trip-sub', `${M.days.length} days · ~${total} spent in-plan · bookings are hard anchors`)); body.append(head); M.days.forEach(md => { const d = days[md.id]; const st = dayStats(d); const pct = Math.min(100, st.dur / st.cap * 100); const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : '')); const top = el('div', 'td-top'); top.append(el('span', 'td-label', md.label + (d.isDeparture ? ' · dep' : ''))); top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`)); card.append(top); const bar = el('div', 'td-bar'); const fill = el('div', 'td-fill'); fill.style.width = pct + '%'; fill.classList.toggle('warn', pct > 92); bar.append(fill); card.append(bar); const meta = el('div', 'td-meta'); meta.append(el('span', null, `${st.count} stops${st.flex ? ' · ' + st.flex + ' ideas' : ''}`)); meta.append(el('span', 'pchip', `~${(st.dur / 60).toFixed(1)} h / ${md.wakingHours} h`)); const cur = (d.stops.find(x => x.price)?.price)?.cur || ''; if (st.price) meta.append(el('span', 'pchip', `~${st.price}${cur} spent`)); card.append(meta); const names = d.stops.filter(s => s.state === 'planned').map(s => s.name); card.append(el('div', 'td-names', names.slice(0, 4).join(' → ') + (names.length > 4 ? ' …' : ''))); card.onclick = () => { setDay(md.id); setScope('day'); }; body.append(card); }); body.append(offlineCard()); } // ---------------- on-trip offline bundle (simulated) ---------------- let offlineState = 'idle'; // idle | working | ready function offlineCard() { const c = el('div', 'offline-card'); if (offlineState === 'ready') { c.append(el('div', 'oc-title', '📦 Offline bundle ready')); c.append(el('div', 'oc-sub', `Itinerary v${version}, stop details (hours, tags) and the trip-area map tiles are on-device — today-view and navigation keep working without signal; re-planning needs connectivity.`)); } else if (offlineState === 'working') { c.append(el('div', 'oc-title', '📦 Preparing offline bundle…')); c.append(el('div', 'oc-sub oc-progress', 'itinerary ✓ → POI details…')); } else { c.append(el('div', 'oc-title', '📶 On-trip: prepare an offline bundle')); c.append(el('div', 'oc-sub', 'Downloads the plan, stop details (opening hours run fully offline) and vector map tiles for the trip area — so the plan keeps working in thin-coverage spots.')); const b = el('button', 'btn ghost small', 'Prepare for trip'); b.onclick = () => prepareOffline(); c.append(b); } return c; } function prepareOffline() { offlineState = 'working'; renderTripRail(); const steps = [ ['itinerary ✓ → POI details…', 500], ['POI details (hours, tags) ✓ → vector tiles…', 1200], [`vector tiles · trip bbox (${120 + M.days.length * 40} tiles) ✓`, 2100], ]; steps.forEach(([txt, t]) => setTimeout(() => { if (offlineState !== 'working') return; const p = document.querySelector('.oc-progress'); if (p) p.textContent = txt; }, t)); setTimeout(() => { offlineState = 'ready'; const badge = $('#offline-badge'); if (badge) { badge.textContent = '📦 offline ready'; badge.classList.add('ok'); badge.title = 'Trip bundle cached on-device'; } if (scope === 'trip') renderTripRail(); ai('Offline bundle prepared — itinerary, stop details and the trip-area map tiles are cached. On-trip it degrades gracefully: plan + navigation keep working, re-planning waits for signal.', 900); }, 2500); } // ---------------- chat ---------------- function msg(role, html) { const m = el('div', 'msg ' + role, html); $('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' }); return m; } function typing() { return msg('ai', ''); } function ai(html, delay = 700) { const t = typing(); setTimeout(() => { t.remove(); msg('ai', html); scriptBusy = false; }, delay); } function chatSend() { const v = $('#chat-input').value.trim(); if (!v) return; $('#chat-input').value = ''; msg('user', v); handleUser(v); } function handleUser(v) { const s = v.toLowerCase(); if (disc) { if (/reset/.test(s)) { disc.results = poolFor(disc.cat, disc.region); renderDiscover(); renderMap(); ai('Back to the full list.', 400); return; } if (/done|exit|never ?mind|back to/.test(s)) { closeDiscover('Back to the plan.'); return; } drawerFilter(v); return; } if (/hotel|where.*sleep|stay/.test(s) && /find|alternative|option|another|compare|look/.test(s)) { openDiscover('hotel'); return; } const slotWord = s.match(/(lunch|dinner|breakfast|snack)/); const regWord = s.match(/\b(oltrarno|smn|duomo|city centre)\b/); if (slotWord && regWord) { // “lunch near Oltrarno” → placeholder · “find a lunch in Oltrarno” → browse if (/find|looking|search|options/.test(s)) openDiscover(slotWord[1], REGION_MAP[regWord[1]]); else addRegionStop(slotWord[1], REGION_MAP[regWord[1]]); return; } if (/find|looking|want|search|options|another|different|discover/.test(s) && slotWord) { openDiscover(slotWord[1]); return; } if (/find|looking|want|search|options|another|different|discover/.test(s) && /activit|afternoon|things to do/.test(s)) { openDiscover('visit'); return; } if (/compare/.test(s)) { ai('Tap the on any candidate, stop card, or suggestion row to line up 2–4 options side by side in the comparison tray at the bottom — travel from the anchor, to the hotel, price, and time, all in one glance.', 900); return; } if (s.includes('later') || s.includes('morning')) { day.startMin += 30; commit('start day 30 min later'); renderAll(day.stops.map(x => x.id)); ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at ${fmt(layout())}.`); } else if (s.includes('lunch') || s.includes('longer')) { const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur); const st = n ? stopById(n.fix.stop) : null; if (n && !n.fixed && st) { n.fixed = true; st.dur = n.fix.dur; commit(`nudge: ${n.fix.label}`); rebuildLegs(day); renderAll([st.id]); ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`); } else ai(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`); } else if (llmOn) { askLLM(v); // free-form → LLM with the live plan as context } else { ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop card’s alternatives or drag a candidate straight onto the plan.', 900); } } $('#chat-send').onclick = chatSend; $('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend()); // ---------------- landing → parse → plan ---------------- // parsed-booking preview, derived from the active dataset (city-aware) function sampleRows() { const hb = M.trip.bookings.find(b => b.type === 'hotel'); const tb = M.trip.bookings.find(b => b.type === 'train'); const rows = [ ['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'], ['name', `${hb.name}, ${M.trip.places[0].name}`, 'geocoded', 'hi'], ['dates', hb.dates + ' ' + (M.trip.places[0].dates[0] || '').slice(0, 4), 'booked stay', 'hi'], ['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'], ['ref', `Confirmation ${hb.ref}`, 'read from footer image — worth a double-check', 'mid'], ]; if (tb) rows.push(['train', tb.name, `${tb.from} → ${tb.to} · dep ${tb.depart}`, 'hi']); return rows; } function showParse() { const t = $('#parse-table'); t.innerHTML = ''; sampleRows().forEach(([k, v, note, c]) => { const tr = document.createElement('tr'); tr.innerHTML = `${k}${v}${c === 'hi' ? 'confirmed' : 'verify'}
${note}
`; t.append(tr); }); $('#parse-modal').classList.remove('hidden'); } $('#parse-cancel').onclick = () => $('#parse-modal').classList.add('hidden'); $('#parse-confirm').onclick = startApp; // ?autostart=plan|map|chat — skips the landing screen (dev/testing) const auto = new URLSearchParams(location.search).get('autostart'); if (auto) setTimeout(() => { startApp(); if (auto !== 'plan' && matchMedia('(max-width: 860px)').matches) document.querySelector(`#mobiletabs button[data-t=${auto}]`)?.click(); }, 120); // ---------------- multi-trip: registry, per-trip persistence, switching -------- // All trips live in window.TRIPS. Each trip's working state (days/stays + // version history) is persisted to localStorage under its own key, so several // trips can be worked on at once and each resumes exactly where it stopped. let tripId = window.MOCK_CITY; const tripStore = { key: id => 'tripstate.v1.' + id, save(id, data) { try { localStorage.setItem(this.key(id), JSON.stringify(data)); } catch {} }, load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } }, }; let persistT = 0; function persistTrip() { clearTimeout(persistT); const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25 persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist }), 250); } function renderTripMenu() { $('#crumb').innerHTML = `🧳 ${M.trip.title} `; const menu = $('#trip-menu'); menu.innerHTML = ''; menu.append(el('div', 'ti-head', 'Trips — each keeps its own plan')); Object.values(window.TRIPS).forEach(t => { const saved = tripStore.load(t.id); const b = el('button', 'trip-item' + (t.id === tripId ? ' cur' : '')); b.innerHTML = `${t.trip.title}${t.id === tripId ? 'current trip' : 'open · v' + (saved?.version || 1)}`; b.onclick = () => { menu.classList.add('hidden'); switchTrip(t.id); }; menu.append(b); }); const more = el('button', 'trip-item'); more.innerHTML = `+ New trip…start from a booking on the landing screen`; more.onclick = () => location.href = location.pathname; menu.append(more); } function enterTrip(id) { tripId = id; M = window.TRIPS[id]; days = {}; M.days.forEach(d => days[d.id] = deep(d)); curDay = 'D1'; day = days[curDay]; stays = deep(M.trip.stays); staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); // resume this trip's saved state (edits + version history), if any const saved = tripStore.load(id); if (saved) { days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx; } // reset per-trip UI state compareItems = []; closeDiscover(); closeL3(); scope = 'day'; document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day')); $('#daystrip').classList.remove('hidden'); $('#rail-foot').classList.remove('hidden'); offlineState = 'idle'; const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = badgeText(); } $('#chat-body').innerHTML = ''; scriptBusy = false; legToken++; // drop in-flight route enrichment from the previous trip map.setView(M.trip.places[0].center, 14); renderTripMenu(); buildDayTabs(); renderHotelMks(); renderBaseChip(); // this trip may live on a different OSRM extract — re-check before routing Promise.all([checkRouter(), checkLLM()]).then(() => { const b = $('#offline-badge'); if (b && offlineState !== 'ready') b.textContent = badgeText(); if (routerOn) enrichLegs(day); }); if (!day.legs.length) rebuildLegs(day); else enrichLegs(day); renderAll(); renderVersionPill(); if (!saved) { commit('initial plan'); script1(); } else { ai(`Welcome back to ${M.trip.title} — you’re at v${version} (“${history[hIdx].label}”). Your earlier edits are kept; switch trips any time from the menu, top-left.`, 600); } } function switchTrip(id) { if (id === tripId) return; persistTrip(); $('#trip-menu').classList.add('hidden'); enterTrip(id); setTimeout(() => map.invalidateSize(), 80); } $('#crumb').onclick = e => { e.stopPropagation(); $('#trip-menu').classList.toggle('hidden'); }; document.addEventListener('click', e => { if (!e.target.closest('#trip-picker')) $('#trip-menu').classList.add('hidden'); }); function startApp() { $('#parse-modal').classList.add('hidden'); $('#landing').classList.add('hidden'); $('#topbar').classList.remove('hidden'); $('#app').classList.remove('hidden'); enterTrip(tripId); } // ---------------- detail sheet ---------------- // the hotel chip: reflects the (possibly multiple) open base options function renderBaseChip() { const tb = $('#transit-block'); tb.innerHTML = ''; const b = dayBase(); const n = staysForDay().length; const h = el('div', 'transit-chip', `🏨${b.name} · ${stayRange(b)} · ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'} · ⚖ to compare hotelsbase`); h.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true))); h.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false))); h.onclick = () => openDiscover('hotel'); tb.append(h); } function openDetail(s, actions) { const d = s.detail; if (!d) return; const oldM = document.getElementById('detail-modal'); if (oldM) oldM.remove(); const modal = el('div', 'modal'); modal.id = 'detail-modal'; const card = el('div', 'modal-card wide'); const img = el('div', 'd-img'); img.style.background = s.img[2]; if (s.img[3]) { const im = document.createElement('img'); im.src = s.img[3]; im.alt = s.img[1]; img.append(im); } else img.append(el('div', 'd-emoji', s.img[0])); img.append(el('div', 'd-attr', s.img[1] + ' · Wikimedia Commons')); const body = el('div', 'd-body'); const t = el('div', 'd-title'); const a = el('a', null, s.name); a.href = s.url; a.target = 'blank'; a.rel = 'noopener'; t.append(a, el('span', 'd-kind', s.kind)); body.append(t); const facts = el('div', 'd-facts'); facts.append( el('span', 'tc ' + CONF_CLS[s.durConf], 'planned ' + fmt(s.dur)), el('span', 'tc t-search', 'typically ' + fmt(s.suggested.value)), s.price ? el('span', 'pchip', '~' + s.price.amount + s.price.cur + (s.mealKind ? ' / person' : '')) : null ); body.append(facts); body.append(el('div', 'd-summary', d.summary)); const links = el('div', 'd-links'); d.links.forEach(l => { const b = el('a', 'd-link', l.label); b.href = l.href; b.target = 'blank'; b.rel = 'noopener'; links.append(b); }); body.append(links); if (actions) { const foot = el('div', 'd-actions'); const a2 = el('button', 'btn primary small', 'Add to day'); a2.onclick = actions.add; const m2 = el('button', 'btn ghost small', 'Idea'); m2.onclick = actions.maybe; const p2 = el('button', 'btn ghost small', 'Not for me'); p2.onclick = actions.pass; foot.append(a2, m2, p2); actions.register([a2, m2, p2]); body.append(foot); } const close = el('button', 'd-close', '✕'); close.onclick = () => modal.remove(); card.append(img, body, close); modal.append(card); modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); }); document.body.append(modal); } // ---------------- adding stops (planned / maybe / alt) ---------------- function addStopToDay(id, requested) { const src = allStops(id); const copy = deep(src); const hasPrimary = day.stops.some(x => x.slot === copy.slot && x.state === 'planned'); let state = requested || (hasPrimary ? 'alt' : 'planned'); if (state === 'planned') { const cur = day.stops.find(x => x.slot === copy.slot && x.state === 'planned'); if (cur) cur.state = 'alt'; } copy.state = state; day.stops.push(copy); rebuildLegs(); commit(`${copy.name} → ${stateLabel(state)}`); renderAll([id]); return state; } function script1() { setTimeout(() => { scriptBusy = true; { const b0 = dayBase(); ai(`Anchored your stay — ${b0.name}, ${stayRange(b0)}; bookings are pinned as hard anchors. The drawer on the map (lunch / afternoon / hotel) is open whenever you want to browse — what kind of trip are you after?`, 900); } setTimeout(() => { const m = msg('ai', 'Pick a vibe — or tell me in your own words:'); const chips = el('div', 'chips'); M.vibes.forEach(v => { const c = el('button', 'chip', v.label); c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(v.label); }; chips.append(c); }); m.append(chips); }, 1800); }, 300); } function script2(vibe) { scriptBusy = true; ai(`Noted — ${vibe || M.vibes[0].label}. A few ideas for your first day:`, 900); setTimeout(() => { const m = msg('ai', ''); const cards = el('div', 'cards'); m.append(cards); let sequenced = false; M.suggestions.forEach((sg, i) => { const s = M.days.flatMap(d => d.stops).find(x => x.id === sg.stop); const card = el('div', 'scard'); const imgBox = el('div', 'scard-img'); imgBox.style.background = s.img[2]; const skel = el('div', 'skel'); const ready = () => { if (skel.isConnected) skel.remove(); imgBox.classList.add('done'); }; imgBox.append(skel, el('div', 'attr', 'Wikimedia Commons')); if (s.img[3]) { const im = document.createElement('img'); im.src = s.img[3]; im.alt = s.img[1]; im.onload = ready; im.onerror = ready; imgBox.append(im); } else { imgBox.append(el('div', 'ph', s.img[0])); setTimeout(ready, 1400 + i * 500); } const body = el('div', 'scard-body'); const title = el('div', 'scard-name'); const tl = el('a', null, s.name); tl.href = s.url; tl.target = '_blank'; tl.rel = 'noopener'; title.append(tl); body.append(title); body.append(el('div', 'scard-pitch', sg.pitch)); const foot = el('div', 'scard-foot'); const add = el('button', 'btn primary small', 'Add to day'); const maybeB = el('button', 'btn ghost small', 'Idea'); const pass = el('button', 'btn ghost small', 'Not for me'); const pc = el('span', 'pchip sp', s.price ? `~${s.price.amount}${s.price.cur}` : ''); const st = { done: false }; const sheetBtns = []; const finish = labels => [add, maybeB, pass, ...sheetBtns].forEach((b, n) => { b.disabled = true; if (labels[n]) b.textContent = labels[n]; }); const doAdd = () => { if (st.done) return; st.done = true; card.classList.add('added'); finish(['Added ✓', '', '', 'Added ✓', '', '']); const state = addStopToDay(s.id, null); if (state === 'alt') ai(`${s.name} is in as the backup for your ${s.slot} — one slot, one plan. Swap them any time.`, 500); else ai(`Added ${s.name} — I’ll sequence the day once the set is done.`, 500); if (!sequenced) { sequenced = true; setTimeout(script3, 1500); } }; const doMaybe = () => { if (st.done) return; st.done = true; card.classList.add('maybe-card'); finish(['', 'Holding', '', '', 'Holding', '']); addStopToDay(s.id, 'maybe'); ai(`Holding ${s.name} as an idea — dashed on the day, not counted against the time budget.`, 500); if (!sequenced) { sequenced = true; setTimeout(script3, 1500); } }; const doPass = () => { if (st.done) return; st.done = true; card.classList.add('added', 'dropped'); finish(['', '', 'Ruled out', '', '', 'Ruled out']); cards.appendChild(card); const dm = document.getElementById('detail-modal'); if (dm) dm.remove(); excluded.add(s.name.split('—').pop().trim()); // feeds future slot searches ai(`Ruled out ${s.name} — noted as a preference.`, 500); }; add.onclick = doAdd; maybeB.onclick = doMaybe; pass.onclick = doPass; card.addEventListener('click', e => { if (!e.target.closest('button, a')) { flyTo(s); openDetail(s, { add: doAdd, maybe: doMaybe, pass: doPass, register: b => sheetBtns.push(...b) }); } }); card.addEventListener('mouseenter', () => tempShow(s)); card.addEventListener('mouseleave', tempClear); foot.append(add, maybeB, pass, pc); body.append(foot); const en = el('div', 'scard-enrich', sg.enrich); body.append(en); card.append(imgBox, body); cards.append(card); setTimeout(() => en.classList.add('show'), 2600 + i * 500); }); m.scrollIntoView({ behavior: 'smooth' }); }, 1900); } function script3() { setTimeout(() => { rebuildLegs(day); renderAll(day.stops.map(s => s.id)); const st = dayStats(day); ai(`Day sequenced — ${(st.dur / 60).toFixed(1)} h of ${day.wakingHours} h waking, ending ~${fmt(st.end)}. Anything that looks tight shows up as a nudge in the rail.`, 1100); }, 1400); } // ---------------- landing wiring ---------------- const dz = $('#dropzone'); ['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('over'); })); ['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('over'); })); dz.addEventListener('drop', e => { if (e.dataTransfer?.files?.length) showParse(); }); $('#dz-sample').onclick = showParse; $('#dest-go').onclick = () => { showParse(); }; $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click()); // ?city= preselects the demo city on the landing screen; the demo chips switch dataset (function () { const c = new URLSearchParams(location.search).get('city'); if (c && M.trip.places[0]) $('#dest').value = M.trip.places[0].name; document.querySelectorAll('#land-demo .chip').forEach(b => b.onclick = () => { location.href = location.pathname + '?city=' + b.dataset.city; }); })(); // drop a candidate onto the plan panel itself → hold it as an idea (function () { const body = $('#rail-body'); if (!body) return; body.addEventListener('dragover', e => { if (dragCand) e.preventDefault(); }); body.addEventListener('drop', e => { const data = e.dataTransfer.getData('text/plain'); if (!data.startsWith('cand:')) return; e.preventDefault(); const found = findCand(data.slice(5)); if (!found) return; const { c, slot } = found; dragCand = null; day.stops.push(makeStop(c, slot, 'maybe')); rebuildLegs(); commit(`idea: ${c.name}`); renderAll([c.id]); if (disc) closeDiscover(); ai(`Dropped ${c.name} into ${dayLabel()} as an idea — promote it whenever you like.`, 500); }); })(); initMap(); // router status + leg enrichment happen in enterTrip() (per-trip extract)