diff --git a/mock/app.js b/mock/app.js index 5b5f380..315de93 100644 --- a/mock/app.js +++ b/mock/app.js @@ -164,6 +164,19 @@ function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time const km = haversine(ap.at, hotelAt) / 1000; return Math.min(90, Math.max(20, Math.round(km / 35 * 60))); } +// An arrival day's activities can't start until the taxi from the airport has +// dropped the traveller at the hotel, so its start tracks (landing + transfer). +// The transfer is routed asynchronously and can change (routing resolves, or the +// walk<->taxi toggle), so re-sync whenever its duration changes — this keeps the +// day tab, rail start and every stop time agreeing (previously the day started +// ~20 min after landing while the drop-off took an hour, so "lunch" was scheduled +// before the traveller had even reached the hotel). +function syncArrivalStart(d) { + if (!d || !d.isArrival || d.arrLandingMin == null || !d.transferIn) return; + d.startMin = d.arrLandingMin + d.transferIn.dur; + const arrEnd = Math.min(ARR_DAY_END, d.startMin + (d.wakingHours || 12) * 60); + d.wakingHours = Math.max(2, Math.round((arrEnd - d.startMin) / 60)); +} function relabelDay(d, idx, dateStr) { const [y, mo, da] = String(dateStr).split('-').map(Number); const dt = new Date(Date.UTC(y, mo - 1, da)); @@ -216,6 +229,7 @@ function healTravelAnchors() { 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, mode: 'car', conf: 2, geometry: null, hotelName: hotelFirst ? hotelFirst.name : 'hotel' }; + first.arrLandingMin = parseHM(arr.arrive); } if (dep && !last.departureFlight) { const ap = dep.stations && dep.stations.from; @@ -455,11 +469,10 @@ const TOOLS = { // 3) arrival day: start after landing + transfer; drop the meaningless // breakfast; shorten the waking window; trim what no longer fits - first.startMin = arrMin + arrTransfer; + first.arrLandingMin = arrMin; // landing time — startMin is derived from it + the transfer + syncArrivalStart(first); // startMin = landing + transferIn.dur (arrTransfer for now) for (let i = first.stops.length - 1; i >= 0; i--) if (first.stops[i].slot === 'breakfast') { removed.push(first.stops[i].name); first.stops.splice(i, 1); } - const arrEnd = Math.min(ARR_DAY_END, first.startMin + (first.wakingHours || 12) * 60); - first.wakingHours = Math.max(2, Math.round((arrEnd - first.startMin) / 60)); trimDayTo(first, first.startMin + first.wakingHours * 60, removed); // 4) departure day: keep only check-out, and REPLACE the (placeholder) @@ -628,11 +641,11 @@ async function routePts(points, mode = 'foot') { }; } catch { return null; } } -let legToken = 0; +let legTokens = {}; // per-day enrichment token — each day enriches independently // background enrichment: upgrade each straight-line leg to a real route async function enrichLegs(d = day) { if (!routerOn) return; - const token = ++legToken; + const key = d.id, token = (legTokens[key] = (legTokens[key] || 0) + 1); // only a newer enrich OF THIS DAY cancels it // 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 @@ -640,9 +653,11 @@ async function enrichLegs(d = day) { 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 before = t.dur; 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 (t.dur !== before) syncArrivalStart(d); // the arrival day starts after this drop-off if (l3 && l3.leg === t) l3.refreshCard && l3.refreshCard(); if (d === day) renderAll(); } @@ -653,16 +668,16 @@ async function enrichLegs(d = day) { // 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 + if (legTokens[key] !== token) return; // this day was re-enriched 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(); + if (changed && d === day) renderAll(); // only re-render if it's still the day on screen } else { leg.conf = 1; leg.src = 'estimate — outside router coverage'; - renderAll(); + if (d === day) renderAll(); } } // return-to-hotel leg when a single stay anchors the day (uses the travel mode) @@ -670,8 +685,8 @@ async function enrichLegs(d = day) { const last = d.stops[d.stops.length - 1]; if (last.kind !== 'region' && last.kind !== 'transit') { const res = await routePts([last.at, dayBase(d).at], travelMode); - if (token !== legToken) return; - if (res) { d.retRouted = res; renderAll(); } + if (legTokens[key] !== token) return; + if (res) { d.retRouted = res; if (d === day) renderAll(); } } } } @@ -1292,8 +1307,12 @@ function renderHotelCard(body) { meta.append(el('span', 'leg-sub', stayRange(b))); if (n > 1) meta.append(el('span', 'leg-sub', `${n} options — worst-case anchor`)); c.append(meta); - c.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true))); - c.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false))); + // flash only THIS day's hotel(s) — flashing every stay's marker makes a + // multi-city trip fly the map to the other city's off-screen hotel (and it + // alternated back and forth on every hover). + const hm = hotelMks.filter((_, i) => staysForDay().some(s => s === stays[i])); + c.addEventListener('mouseenter', () => hm.forEach(m => flashMarker(m, true))); + c.addEventListener('mouseleave', () => hm.forEach(m => flashMarker(m, false))); c.addEventListener('click', () => openDiscover('hotel')); body.append(c); } @@ -1774,12 +1793,14 @@ function setLegMode(leg, m) { ? 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; + if (l3.kind === 'arrival') syncArrivalStart(day); // reflect the new (estimated) transfer right away 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; } + if (l3.kind === 'arrival') syncArrivalStart(day); // and again once the real route lands renderRail(); if (l3.kind === 'arrival') renderMap(); l3.refreshCard(); @@ -2338,7 +2359,7 @@ function enterTrip(id) { 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 + legTokens = {}; // drop in-flight route enrichment from the previous trip map.setView(M.trip.places[0].center, 14); renderTripMenu(); buildDayTabs(); diff --git a/mock/server.js b/mock/server.js index 386ecd7..894c4b2 100644 --- a/mock/server.js +++ b/mock/server.js @@ -20,6 +20,10 @@ const ROUTERS = { }, colombia: { url: process.env.OSRM_COLOMBIA || 'http://localhost:5003', + // The :5003 extract is a *walking* build (foot.lua) — it cannot produce a + // driving route, so 'car' legs would silently come back as a long walk. The + // :5004 build (car.lua, same cropped OSM) serves taxis/airport transfers. + drivingUrl: process.env.OSRM_COLOMBIA_DRIVE || 'http://localhost:5004', probe: '-75.5478,10.3954;-75.5400,10.4020', // Cartagena, foot extract }, }; @@ -264,7 +268,8 @@ http.createServer((req, res) => { const targets = Array.from({ length: n }, (_, i) => i).join(','); osrmPath += `?annotations=duration,distance&sources=0&targets=${targets}`; } - const up = routerFor(u).url; + const rt = routerFor(u); + const up = (profile === 'driving' && rt.drivingUrl) ? rt.drivingUrl : rt.url; const fetchUp = fetch(up + osrmPath).then(r => r.text()).catch(() => null); fetchUp.then(body => { res.setHeader('Access-Control-Allow-Origin', '*'); diff --git a/router/scripts/setup-osrm-colombia.sh b/router/scripts/setup-osrm-colombia.sh index 9d62436..ba7f73a 100644 --- a/router/scripts/setup-osrm-colombia.sh +++ b/router/scripts/setup-osrm-colombia.sh @@ -35,8 +35,10 @@ ls -la "$PBF" echo "=== crop to Cartagena + Santa Marta boxes (3 streaming passes, ~3 min)" # boxes: MINLON MINLAT MAXLON MAXLAT (Cartagena city+beaches, Santa Marta+Tayrona) +# NOTE: SAMARTA MAXLAT must stay north of the old town (~11.24 N); 11.22 clipped it and +# made every city point snap to the same boundary node. 11.28 covers Malecón + Plaza Colesio. CARTAGENA="-75.66,10.26,-75.36,10.56" -SAMARTA="-74.36,10.84,-74.02,11.22" +SAMARTA="-74.36,10.84,-74.02,11.28" python3 crop_pbf.py "$PBF" /tmp/colombia-trip.pbf "$CARTAGENA" "$SAMARTA" echo "=== extract (foot.lua) + partition + customize"