Fix arrival drop-off, taxi routing, and Santa Marta coverage

Four user-reported bugs from the Colombia trip:

1. Santa Marta legs not routing (extract gap). The SAMARTA OSM box had
   MAXLAT 11.22, which clipped the old town (~11.24 N) so every city
   point snapped to the same boundary node. Raised it to 11.28 (covers
   the Malecón + Plaza Colesio). Rebuilt the Colombia extract.

2. "Can't take a taxi / wrong times." The :5003 Colombia router is a
   *walking* build (foot.lua) and cannot return a driving route, so 'car'
   legs silently came back as long walks (CTG->hotel showed 105 min).
   Added a second, *driving* build (car.lua, same cropped OSM) on :5004
   and made the /route proxy send driving-profile requests there. Now
   the airport drop-off is a real 16-min drive, and the walk<->taxi
   toggle changes the estimate (16 min car vs 105 min walk).

3. Arrival timeline inconsistent. The arrival day's startMin was set from
   a static ~20-min estimate and never updated when the drop-off actually
   routed. Added syncArrivalStart(): the start tracks landing + the
   transfer's (asynchronously routed, mode-dependent) duration, and every
   stop follows. Lunch no longer lands before the traveller reaches the
   hotel.

4. Hovering a hotel card zoomed to the other city. renderHotelCard
   flashed EVERY stay's marker, so a multi-city trip flew the map to the
   off-screen hotel (and alternated on each hover). Now it flashes only
   the current day's hotel(s).

Also: enrichment used a single global token, so when apply_flight_anchors
enriched all days in one pass only the LAST day's legs actually routed
(the earlier days bailed on the token check). Made the token per-day so
every day's legs route independently, and scoped the background
re-renders to the on-screen day.

Verified via CDP: 11/11 fix checks + cdp_smoke, cdp_struct (13),
cdp_dates (7), cdp_leg (17), cdp_coords (6) all green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Greg Pomerantz 2026-09-09 22:13:00 -04:00
parent 04076294bd
commit ca32d20a50
3 changed files with 43 additions and 15 deletions

View File

@ -164,6 +164,19 @@ function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time
const km = haversine(ap.at, hotelAt) / 1000; const km = haversine(ap.at, hotelAt) / 1000;
return Math.min(90, Math.max(20, Math.round(km / 35 * 60))); 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) { function relabelDay(d, idx, dateStr) {
const [y, mo, da] = String(dateStr).split('-').map(Number); const [y, mo, da] = String(dateStr).split('-').map(Number);
const dt = new Date(Date.UTC(y, mo - 1, da)); const dt = new Date(Date.UTC(y, mo - 1, da));
@ -216,6 +229,7 @@ function healTravelAnchors() {
first.isArrival = true; 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.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.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) { if (dep && !last.departureFlight) {
const ap = dep.stations && dep.stations.from; const ap = dep.stations && dep.stations.from;
@ -455,11 +469,10 @@ const TOOLS = {
// 3) arrival day: start after landing + transfer; drop the meaningless // 3) arrival day: start after landing + transfer; drop the meaningless
// breakfast; shorten the waking window; trim what no longer fits // 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--) 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); } 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); trimDayTo(first, first.startMin + first.wakingHours * 60, removed);
// 4) departure day: keep only check-out, and REPLACE the (placeholder) // 4) departure day: keep only check-out, and REPLACE the (placeholder)
@ -628,11 +641,11 @@ async function routePts(points, mode = 'foot') {
}; };
} catch { return null; } } 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 // background enrichment: upgrade each straight-line leg to a real route
async function enrichLegs(d = day) { async function enrichLegs(d = day) {
if (!routerOn) return; 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 // 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 // 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 // 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) { if (d.isArrival && d.transferIn && d.transferIn.at && d.transferIn.hotelAt) {
const t = d.transferIn, want = t.mode || 'car'; const t = d.transferIn, want = t.mode || 'car';
if (!(t.conf === 3 && t.geometry && t._routedMode === want)) { if (!(t.conf === 3 && t.geometry && t._routedMode === want)) {
const before = t.dur;
const res = await routePts([t.at, t.hotelAt], 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; } if (res) { t.dur = res.durMin; t.conf = 3; t.geometry = res.geometry; t._routedMode = want; }
else { t.conf = 1; } 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 (l3 && l3.leg === t) l3.refreshCard && l3.refreshCard();
if (d === day) renderAll(); 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 // legs touching a transit stop are fixed (flight/taxi/bus), never re-routed
if (a.kind === 'transit' || b.kind === 'transit') continue; 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); 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]; const leg = d.legs[i];
if (res) { if (res) {
const changed = leg.dur !== res.durMin || !leg.geometry; const changed = leg.dur !== res.durMin || !leg.geometry;
leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry;
leg.src = 'local router'; leg.src = 'local router';
if (changed) renderAll(); if (changed && d === day) renderAll(); // only re-render if it's still the day on screen
} else { } else {
leg.conf = 1; leg.src = 'estimate — outside router coverage'; 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) // 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]; const last = d.stops[d.stops.length - 1];
if (last.kind !== 'region' && last.kind !== 'transit') { if (last.kind !== 'region' && last.kind !== 'transit') {
const res = await routePts([last.at, dayBase(d).at], travelMode); const res = await routePts([last.at, dayBase(d).at], travelMode);
if (token !== legToken) return; if (legTokens[key] !== token) return;
if (res) { d.retRouted = res; renderAll(); } if (res) { d.retRouted = res; if (d === day) renderAll(); }
} }
} }
} }
@ -1292,8 +1307,12 @@ function renderHotelCard(body) {
meta.append(el('span', 'leg-sub', stayRange(b))); meta.append(el('span', 'leg-sub', stayRange(b)));
if (n > 1) meta.append(el('span', 'leg-sub', `${n} options — worst-case anchor`)); if (n > 1) meta.append(el('span', 'leg-sub', `${n} options — worst-case anchor`));
c.append(meta); c.append(meta);
c.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true))); // flash only THIS day's hotel(s) — flashing every stay's marker makes a
c.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false))); // 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')); c.addEventListener('click', () => openDiscover('hotel'));
body.append(c); 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))) ? 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); : estLegMin(m, l3.from, l3.to);
leg.conf = 1; leg.conf = 1;
if (l3.kind === 'arrival') syncArrivalStart(day); // reflect the new (estimated) transfer right away
renderRail(); renderL3Mode(leg); renderRail(); renderL3Mode(leg);
if (l3.kind === 'arrival') renderMap(); // keep the drop-off line colour (walk=green, taxi=blue) in sync 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); const t = (leg._rt = (leg._rt || 0) + 1);
routePts(leg._pts || [l3.from, l3.to], m).then(res => { routePts(leg._pts || [l3.from, l3.to], m).then(res => {
if (leg._rt !== t || !l3 || l3.leg !== leg) return; 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 (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(); renderRail();
if (l3.kind === 'arrival') renderMap(); if (l3.kind === 'arrival') renderMap();
l3.refreshCard(); l3.refreshCard();
@ -2338,7 +2359,7 @@ function enterTrip(id) {
const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = badgeText(); } const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = badgeText(); }
$('#chat-body').innerHTML = ''; $('#chat-body').innerHTML = '';
scriptBusy = false; 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); map.setView(M.trip.places[0].center, 14);
renderTripMenu(); renderTripMenu();
buildDayTabs(); buildDayTabs();

View File

@ -20,6 +20,10 @@ const ROUTERS = {
}, },
colombia: { colombia: {
url: process.env.OSRM_COLOMBIA || 'http://localhost:5003', 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 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(','); const targets = Array.from({ length: n }, (_, i) => i).join(',');
osrmPath += `?annotations=duration,distance&sources=0&targets=${targets}`; 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); const fetchUp = fetch(up + osrmPath).then(r => r.text()).catch(() => null);
fetchUp.then(body => { fetchUp.then(body => {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');

View File

@ -35,8 +35,10 @@ ls -la "$PBF"
echo "=== crop to Cartagena + Santa Marta boxes (3 streaming passes, ~3 min)" echo "=== crop to Cartagena + Santa Marta boxes (3 streaming passes, ~3 min)"
# boxes: MINLON MINLAT MAXLON MAXLAT (Cartagena city+beaches, Santa Marta+Tayrona) # 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" 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" python3 crop_pbf.py "$PBF" /tmp/colombia-trip.pbf "$CARTAGENA" "$SAMARTA"
echo "=== extract (foot.lua) + partition + customize" echo "=== extract (foot.lua) + partition + customize"