diff --git a/mock/app.js b/mock/app.js index 9d371ba..8a4c433 100644 --- a/mock/app.js +++ b/mock/app.js @@ -215,7 +215,7 @@ function healTravelAnchors() { const ap = arr.stations && arr.stations.to; first.isArrival = true; first.arrivalFlight = { code: codeOf(arr.to) || codeOf(arr.name) || arr.to || 'airport', at: ap || null, time: arr.arrive || '', flight: String(arr.name || '').split(' · ')[0], from: arr.from || '', to: arr.to || '' }; - first.transferIn = { at: ap || null, hotelAt: hotelFirst, dur: (ap && hotelFirst) ? (driveMin({ at: ap }, hotelFirst) || 60) : 60 }; + first.transferIn = { at: ap || null, hotelAt: hotelFirst, dur: (ap && hotelFirst) ? (driveMin({ at: ap }, hotelFirst) || 60) : 60, mode: 'car', conf: 2, geometry: null, hotelName: hotelFirst ? hotelFirst.name : 'hotel' }; } if (dep && !last.departureFlight) { const ap = dep.stations && dep.stations.from; @@ -380,7 +380,7 @@ const TOOLS = { // bookings (hard anchors), not stops — the day starts/ends with them. first.isArrival = true; first.arrivalFlight = { code: arrAp ? arrAp.code : (arr.airport || arr.city || 'airport'), at: arrAp ? arrAp.at : null, time: arr.time, flight: arr.flight || '', from: arr.from || '', to: arr.city || '' }; - first.transferIn = { at: arrAp ? arrAp.at : null, hotelAt: hotelFirst, dur: arrTransfer }; + first.transferIn = { at: arrAp ? arrAp.at : null, hotelAt: hotelFirst, dur: arrTransfer, mode: 'car', conf: 2, geometry: null, hotelName: hotelFirst ? hotelFirst.name : 'hotel' }; last.isDeparture = true; last.departureFlight = { code: depAp ? depAp.code : (dep.airport || dep.city || 'airport'), at: depAp ? depAp.at : null, time: dep.time, flight: dep.flight || '', from: dep.city || '', to: dep.to || '' }; last.transferOut = { at: depAp ? depAp.at : null, hotelAt: hotelLast, dur: depTransfer }; @@ -607,6 +607,20 @@ let legToken = 0; async function enrichLegs(d = day) { if (!routerOn) return; const token = ++legToken; + // the arrival drop-off (airport→hotel) is a first-class leg — route it FIRST, before the + // stop-leg loop, so the stop-leg token bails below can't starve it. The value is day-scoped + // and idempotent (apply even if a newer enrich started); skip when already routed in this + // mode. Only re-render if it's still the day on screen. + if (d.isArrival && d.transferIn && d.transferIn.at && d.transferIn.hotelAt) { + const t = d.transferIn, want = t.mode || 'car'; + if (!(t.conf === 3 && t.geometry && t._routedMode === want)) { + const res = await routePts([t.at, t.hotelAt], want); + if (res) { t.dur = res.durMin; t.conf = 3; t.geometry = res.geometry; t._routedMode = want; } + else { t.conf = 1; } + if (l3 && l3.leg === t) l3.refreshCard && l3.refreshCard(); + if (d === day) renderAll(); + } + } 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; @@ -743,13 +757,15 @@ function layout(d = day) { return t + returnDur(d); } function rebuildLegs(d = day, enrich = true) { + const prevModes = (d.legs || []).map(g => (g && (g.mode === 'foot' || g.mode === 'car')) ? g.mode : null); // keep each leg's walk/taxi choice across rebuilds + const pm = i => prevModes[i] || travelMode; 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: travelMode, dur: travelMode === 'car' ? 9 : 17, conf: 1, vague: true }); + d.legs.push({ mode: pm(i), dur: pm(i) === 'car' ? 9 : 17, conf: 1, vague: true }); } else { // legs touching a transit stop: fixed duration (flight + taxi/bus), // optionally broken down via M.multimodal entries @@ -772,7 +788,7 @@ function rebuildLegs(d = day, enrich = true) { } else { // straight-line estimate at the current travel mode's speed, until the // router upgrades it (or proves out of coverage) - d.legs.push({ mode: travelMode, dur: estLegMin(travelMode, a.at, b.at), conf: 1 }); + d.legs.push({ mode: pm(i), dur: estLegMin(pm(i), a.at, b.at), conf: 1 }); } } } @@ -839,13 +855,12 @@ function renderMap() { retLines.push(retLine); }); } - if (obj.isArrival && obj.arrivalFlight && obj.arrivalFlight.at) { // airport → hotel drop-off (hoverable via its rail row, like any other route) - const h = dayBase(obj); - if (h) { - arrLine = L.polyline([obj.arrivalFlight.at, h.at], { color: '#274b8f', weight: 5, opacity: .85, dashArray: '6 4' }).addTo(map); - otherLayers.push(arrLine); - arrLines.push(arrLine); - } + if (obj.isArrival && obj.transferIn && obj.transferIn.at && obj.transferIn.hotelAt) { // airport → hotel drop-off: a first-class leg, drawn from its own routed geometry + const t = obj.transferIn; + const pts = t.geometry || [t.at, t.hotelAt]; + arrLine = L.polyline(pts, { color: t.mode === 'foot' ? '#5a7d5a' : '#274b8f', weight: 5, opacity: .85, dashArray: '6 4' }).addTo(map); + otherLayers.push(arrLine); + arrLines.push(arrLine); } obj.stops.forEach((s, i) => { const dim = s.state !== 'planned'; @@ -1206,20 +1221,20 @@ function renderArrivalHeader(body) { c.append(meta); if (f.at) c.addEventListener('click', () => focusPoint(f.at)); body.append(c); - // the ride to the hotel to drop bags — always by car, before the first stop + // the ride to the hotel to drop bags — a first-class leg (walk or taxi), before the first stop. + // Click it to edit the route / toggle walk vs taxi, exactly like any stop-to-stop leg. const t = day.transferIn || {}; - const tr = el('div', 'leg-row struct-leg'); - tr.append(el('span', 'leg-ico', '🚗')); - tr.append(document.createTextNode('to ' + (dayBase() ? dayBase().name : 'hotel') + ' — drop bags')); + const tr = el('div', 'leg-row'); + tr.append(el('span', 'leg-ico', t.mode === 'foot' ? '🚶' : '🚗')); + tr.append(document.createTextNode('to ' + (t.hotelName || (dayBase() ? dayBase().name : 'hotel')) + ' — drop bags')); tr.append(document.createTextNode(' ')); - tr.append(tchip(t.dur || 30, 2)); - // a real route like any other: hovering highlights it on the map, clicking - // flies there. (Always by car — you taxi in from the airport.) - const on = { weight: 7, opacity: 1, color: '#274b8f', dashArray: null }; - const off = { color: '#274b8f', weight: 5, opacity: .85, dashArray: '6 4' }; + tr.append(tchip(t.dur || 30, t.conf || 2)); + const ac = t.mode === 'foot' ? '#5a7d5a' : '#274b8f'; + const on = { weight: 7, opacity: 1, color: ac, dashArray: null }; + const off = { color: ac, weight: 5, opacity: .85, dashArray: '6 4' }; tr.addEventListener('mouseenter', () => arrLines.forEach(l => l.setStyle(on))); tr.addEventListener('mouseleave', () => arrLines.forEach(l => l.setStyle(off))); - tr.addEventListener('click', () => { const h = dayBase(); if (f.at && h) map.fitBounds(L.latLngBounds([f.at, h.at]).pad(0.5)); }); + tr.addEventListener('click', () => openArrivalEditor()); body.append(tr); } @@ -1644,41 +1659,50 @@ function haversine(a, b) { 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) { +// Generic leg editor. Works on ANY leg described by an explicit from/to pair, so the +// airport→hotel drop-off is editable with the same machinery as a stop-to-stop leg: +// draggable waypoints, a walk/taxi toggle that re-routes THIS leg, and a live +// itinerary pane (the rail re-renders as you edit — no more stale times behind the +// card). The map is left to the editor's own preview line; renderAll syncs it on close. +function openLegEditor(leg, from, to, title, kind, idx) { closeL3(); - const a = day.stops[legIdx].at, b = day.stops[legIdx + 1].at; - const leg = day.legs[legIdx]; + const a = from, b = to; 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 line = L.polyline(leg.geometry || [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 + let rt = 0, railT = 0; // router debounce token + rail re-render debounce + const refreshCard = () => { box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, leg.conf || 3)); renderL3Mode(leg); }; + const railSoon = () => { clearTimeout(railT); railT = setTimeout(() => { if (l3 && l3.leg === leg) renderRail(); }, 180); }; 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._pts = [[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]; + line.setLatLngs(leg._pts); + const dist = haversine(leg._pts[0], leg._pts[1]) + haversine(leg._pts[1], leg._pts[2]); leg.dur = Math.max(4, Math.round(dist / 1000 / (legSpeedKmh(leg.mode) / 60))); // instant straight-line estimate at the leg's mode leg.conf = 1; box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1)); - renderBudget(); + renderBudget(); railSoon(); const t = ++rt; - routePts([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]], leg.mode).then(res => { - if (t !== rt || !l3) return; + routePts(leg._pts, leg.mode).then(res => { + if (t !== rt || !l3 || l3.leg !== leg) 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(); + renderBudget(); renderRail(); // keep the itinerary pane in sync live }); }; [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-title').textContent = title; + refreshCard(); renderL3Mode(leg); + $('#l3-foot').onclick = () => setLegMode(leg, 'foot'); + $('#l3-car').onclick = () => setLegMode(leg, 'car'); $('#l3-editor').classList.remove('hidden'); map.fitBounds(L.latLngBounds([a, b]).pad(0.4)); - l3 = { layer, idx: legIdx }; + l3 = { layer, leg, from: a, to: b, kind, idx, refreshCard }; const obs = setInterval(() => { if (!$('#l3-editor').classList.contains('hidden')) return; clearInterval(obs); @@ -1686,25 +1710,55 @@ function openL3(legIdx) { renderAll(); }, 250); } +// Stop-to-stop leg: day.legs[i] between stops i and i+1. +function openL3(legIdx) { + const leg = day.legs[legIdx]; if (!leg) return; + openLegEditor(leg, day.stops[legIdx].at, day.stops[legIdx + 1].at, + `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`, 'stop', legIdx); +} +// The airport→hotel drop-off: a first-class leg with its own mode + geometry. +function openArrivalEditor() { + const t = day.transferIn; if (!t || !t.at || !t.hotelAt) return; + openLegEditor(t, t.at, t.hotelAt, 'Airport → ' + (t.hotelName || 'hotel'), 'arrival'); +} function closeL3() { if (l3) { map.removeLayer(l3.layer); l3 = null; } $('#l3-editor').classList.add('hidden'); } $('#l3-close').onclick = closeL3; -// the walk/taxi toggle lives on the route card itself — the popup a leg click -// opens. It's shown only for on-ground legs (a flight or train+bus segment has -// a fixed mode); flipping it re-estimates every on-ground leg in the trip. +// the walk/taxi toggle lives on the route card and changes the MODE OF THE LEG +// BEING EDITED (per-leg) — so the airport→hotel drop-off can be walked or taxi'd +// independently of the rest of the day. Flipping it re-routes just that leg and keeps +// the card, the itinerary pane, and the map in sync. function renderL3Mode(leg) { const box = $('#l3-mode'); if (!box) return; const ground = !!leg && (leg.mode === 'foot' || leg.mode === 'car'); box.classList.toggle('hidden', !ground); if (!ground) return; - $('#l3-foot').classList.toggle('on', travelMode === 'foot'); - $('#l3-car').classList.toggle('on', travelMode === 'car'); + $('#l3-foot').classList.toggle('on', leg.mode === 'foot'); + $('#l3-car').classList.toggle('on', leg.mode === 'car'); +} +// Change one leg's mode: re-estimate it straight-line for instant feedback, then +// re-route it for the real road time. Only this leg is touched. +function setLegMode(leg, m) { + if (!leg || !l3 || l3.leg !== leg || m === leg.mode) return; + leg.mode = m; + leg.dur = leg._pts + ? Math.max(4, Math.round((haversine(leg._pts[0], leg._pts[1]) + haversine(leg._pts[1], leg._pts[2])) / 1000 / (legSpeedKmh(m) / 60))) + : estLegMin(m, l3.from, l3.to); + leg.conf = 1; + renderRail(); renderL3Mode(leg); + if (l3.kind === 'arrival') renderMap(); // keep the drop-off line colour (walk=green, taxi=blue) in sync + const t = (leg._rt = (leg._rt || 0) + 1); + routePts(leg._pts || [l3.from, l3.to], m).then(res => { + if (leg._rt !== t || !l3 || l3.leg !== leg) return; + if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = m; } + renderRail(); + if (l3.kind === 'arrival') renderMap(); + l3.refreshCard(); + }); } -$('#l3-foot').onclick = () => setTravelMode('foot'); -$('#l3-car').onclick = () => setTravelMode('car'); // ---------------- version history (every edit = a version) ---------------- // Both editors (user + chat) funnel through commit(): the snapshot captures @@ -2299,20 +2353,26 @@ function startApp() { // the toggle UI lives on the route card (openL3); this just refreshes its // state whenever the mode changes or the rail re-renders function renderModeToggle() { - renderL3Mode(l3 && day && day.legs[l3.idx] ? day.legs[l3.idx] : null); + renderL3Mode(l3 ? l3.leg : null); } function setTravelMode(m) { if (m === travelMode) return; travelMode = m; try { localStorage.setItem('trips.travemode', m); } catch {} - // re-estimate every day's on-ground legs at the new speed. Enrichment is not - // queued here — one fetch per day would cancel the token out — only the day - // you're looking at is re-routed in the background - M.days.forEach(md => { const d = days[md.id]; if (d) rebuildLegs(d, false); }); + // global mode: force every on-ground leg (and the airport drop-off) to the new mode, + // then rebuild — which now preserves each leg's mode, so the override sticks. Enrichment + // is not queued per day (one fetch per day would cancel the token out); only the day you're + // looking at is re-routed in the background. + M.days.forEach(md => { + const d = days[md.id]; if (!d) return; + (d.legs || []).forEach(g => { if (g && (g.mode === 'foot' || g.mode === 'car' || g.vague)) g.mode = m; }); + if (d.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; } + rebuildLegs(d, false); + }); renderAll(); if (day) enrichLegs(day); renderModeToggle(); - if (l3) openL3(l3.idx); // rebind the open route card to the re-estimated leg + if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg } function openDetail(s, actions) {