multi-trip mock + Colombia trip with a real walking router

The mock can now work several trips at once: a trip picker in the
topbar, per-trip state in localStorage (edits + version history survive
switching), and a per-trip router key so each trip routes on its own
OSRM extract.

Colombia (Cartagena -> Santa Marta, 8 days) becomes the default working
trip, inside a new walking-profile OSRM instance:
- scripts/crop_pbf.py: streaming 3-pass PBF cropper (no PBF library;
  the country file is too big for osmium extract -s complete_ways on
  this box). Framing validated against osmium test fixtures and
  osrm-extract. Drops relations (foot profile ignores restrictions).
- scripts/setup-osrm-colombia.sh: download -> crop (~4.6 MB) ->
  extract/partition/customize with the official foot.lua -> serve
  :5003. Verified: Getsemaní -> Castillo San Felipe 7.98 km / 96 min.
- server.js: ROUTERS map, ?router=colombia|northeast on /route,
  /table, /router-status (per-router probe points).
- app.js: legs touching a transit stop are fixed (flight/taxi/bus via
  M.multimodal), never re-routed; distance-0 router answers (both
  points on one node) read as adjacent stops, not out-of-coverage.
- multi-hotel fix: day-scope map fit uses the stays covering THAT day,
  so a two-city trip no longer fits the whole country per day.

Also: pre-cache-tiles.sh (Cartagena+Santa Marta z14-16, 2560 tiles),
tile cache write fix (Node rejects flags:'x'; buffer-then-write),
favicon, renderHotelMks load-order guard, tests updated for the new
default trip (all four puppeteer suites green, zero console errors).
This commit is contained in:
Greg Pomerantz 2026-09-09 09:44:29 -04:00
parent 30f3dd9cbf
commit 33ed5d9fe6
10 changed files with 1162 additions and 52 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ osm/build/
# Go # Go
router/router router/router
router/bench router/bench
router/scripts/__pycache__/

View File

@ -13,7 +13,7 @@
* 5. Lower cognitive load states read as "in plan / idea / backup", * 5. Lower cognitive load states read as "in plan / idea / backup",
* strict validators are rephrased as gentle nudges with one-tap fixes. * strict validators are rephrased as gentle nudges with one-tap fixes.
*/ */
const M = window.MOCK; let M = window.MOCK; // active trip dataset (reassigned on trip switch)
const $ = s => document.querySelector(s); 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 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 fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math.round(m % 60)).padStart(2, '0');
@ -26,8 +26,11 @@ const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_
// background to real road-network times + geometry (conf 3) when the point // 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. // pair is inside the router's extract. Out of coverage → stays an estimate.
let routerOn = false; 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() { async function checkRouter() {
try { const r = await fetch('/router-status'); routerOn = !!(await r.json()).router; } try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; }
catch { routerOn = false; } catch { routerOn = false; }
} }
// Google encoded-polyline decoder (OSRM `overview=full` geometry) // Google encoded-polyline decoder (OSRM `overview=full` geometry)
@ -52,14 +55,16 @@ async function routePts(points, mode = 'foot') {
if (!routerOn) return null; if (!routerOn) return null;
const q = points.map(p => `${p[1]},${p[0]}`).join(';'); const q = points.map(p => `${p[1]},${p[0]}`).join(';');
try { try {
const r = await fetch(`/route?mode=${mode}&points=${encodeURIComponent(q)}`); const r = await fetch(`/route?mode=${mode}&router=${routerKey()}&points=${encodeURIComponent(q)}`);
const j = await r.json(); const j = await r.json();
const route = j?.routes?.[0]; const route = j?.routes?.[0];
if (!route || !route.distance) return null; 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 { return {
durMin: Math.max(1, Math.round(route.duration / 60)), durMin: Math.max(1, Math.round(route.duration / 60)),
distance: route.distance, distance: route.distance,
geometry: decodePolyline(route.geometry).map(p => [p[1], p[0]]), // → [lat, lng] geometry: route.geometry ? decodePolyline(route.geometry).map(p => [p[1], p[0]]) : [], // → [lat, lng]
}; };
} catch { return null; } } catch { return null; }
} }
@ -71,6 +76,8 @@ async function enrichLegs(d = day) {
for (let i = 0; i < d.stops.length - 1; i++) { for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1]; const a = d.stops[i], b = d.stops[i + 1];
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue; 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); 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 (token !== legToken) return; // the day changed mid-flight
const leg = d.legs[i]; const leg = d.legs[i];
@ -96,10 +103,9 @@ async function enrichLegs(d = day) {
} }
// ---------------- state ---------------- // ---------------- state ----------------
const days = {}; // (filled by enterTrip() — the app can switch between window.TRIPS at runtime)
M.days.forEach(d => days[d.id] = deep(d)); let days = {};
let curDay = 'D1'; let curDay, day;
let day = days[curDay];
const stopById = id => day.stops.find(s => s.id === id); const stopById = id => day.stops.find(s => s.id === id);
const allStops = id => M.days.flatMap(d => d.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 markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = [];
@ -126,8 +132,7 @@ const stateLabel = st => st === 'planned' ? 'in plan' : st === 'maybe' ? 'idea'
// ---------------- stays / base ---------------- // ---------------- stays / base ----------------
// stays, not a single hotel: each stay anchors the dates it covers. // 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. // >1 candidate stay covering the same dates → worst-case anchoring for those dates.
let stays = deep(M.trip.stays); let stays, staySeq; // filled by enterTrip()
let staySeq = 1;
const covers = (s, date) => s.checkIn <= date && date <= s.checkOut; const covers = (s, date) => s.checkIn <= date && date <= s.checkOut;
const staysForDay = (d = day) => stays.filter(s => covers(s, d.date)); const staysForDay = (d = day) => stays.filter(s => covers(s, d.date));
const dayBase = (d = day) => { const dayBase = (d = day) => {
@ -142,7 +147,7 @@ const stayRange = s => `${s.checkIn.slice(8)}${s.checkOut.slice(8)} Sep`;
function renderHotelMks() { function renderHotelMks() {
hotelMks.forEach(m => map.removeLayer(m)); hotelMks = []; hotelMks.forEach(m => map.removeLayer(m)); hotelMks = [];
stays.forEach(o => { (stays || []).forEach(o => {
const booked = o.state === 'booked'; const booked = o.state === 'booked';
hotelMks.push(L.marker(o.at, { hotelMks.push(L.marker(o.at, {
icon: L.divIcon({ className: 'hotel-ico', html: `<div class="hotel-pin${booked ? '' : ' hotel-alt'}">🏨</div>`, iconSize: [0, 0] }) icon: L.divIcon({ className: 'hotel-ico', html: `<div class="hotel-pin${booked ? '' : ' hotel-alt'}">🏨</div>`, iconSize: [0, 0] })
@ -180,7 +185,7 @@ function tchip(mins, conf, src) {
if (src) e.title = 'source: ' + src; if (src) e.title = 'source: ' + src;
return e; return e;
} }
const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌' }; const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌', fly: '✈️' };
// ---------------- layout / legs ---------------- // ---------------- layout / legs ----------------
// every day ends by returning to the hotel anchor // every day ends by returning to the hotel anchor
@ -208,6 +213,16 @@ function rebuildLegs(d = day) {
if (a.kind === 'region' || b.kind === 'region') { if (a.kind === 'region' || b.kind === 'region') {
d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true }); d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true });
} else { } 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); const mm = (M.multimodal || []).find(x => x.into === b.id);
if (mm) { if (mm) {
d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub }); d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub });
@ -269,8 +284,9 @@ function renderMap() {
// real road geometry once the router has answered, straight line until then // real road geometry once the router has answered, straight line until then
const g = obj.legs[i]?.geometry || [pts[i], pts[i + 1]]; const g = obj.legs[i]?.geometry || [pts[i], pts[i + 1]];
legEls[i] = L.polyline(g, { legEls[i] = L.polyline(g, {
color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b', color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : obj.legs[i]?.mode === 'fly' ? '#8a93a6' : '#2e9e5b',
weight: 5, opacity: .85 weight: 5, opacity: .85,
dashArray: obj.legs[i]?.mode === 'fly' ? '6 6' : undefined
}).addTo(map); }).addTo(map);
} }
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row) if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
@ -302,7 +318,9 @@ function renderMap() {
const pad = matchMedia('(max-width: 860px)').matches const pad = matchMedia('(max-width: 860px)').matches
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width ? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
: { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] }; : { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] };
map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...pts]), pad); // 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) { function highlightStop(id, on) {
@ -783,7 +801,7 @@ function renderStopCard(body, s, i, flashIds) {
if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined')); if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined'));
lr.append(document.createTextNode(' ')); lr.append(document.createTextNode(' '));
lr.append(tchip(g.dur, g.conf)); lr.append(tchip(g.dur, g.conf));
const base = { color: g.mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b', weight: 5, opacity: .85 }; 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('mouseenter', () => legEls[i] && legEls[i].setStyle({ ...base, weight: 7, opacity: 1 }));
lr.addEventListener('mouseleave', () => legEls[i] && legEls[i].setStyle(base)); lr.addEventListener('mouseleave', () => legEls[i] && legEls[i].setStyle(base));
lr.onclick = () => openL3(i); lr.onclick = () => openL3(i);
@ -1070,6 +1088,7 @@ const commit = (label) => {
hIdx = history.length - 1; hIdx = history.length - 1;
if (history.length > 60) { history.shift(); hIdx--; } if (history.length > 60) { history.shift(); hIdx--; }
renderVersionPill(); renderVersionPill();
if (typeof persistTrip === 'function') persistTrip();
}; };
function restore(idx, silent) { function restore(idx, silent) {
const s = history[idx]; if (!s) return; const s = history[idx]; if (!s) return;
@ -1358,18 +1377,97 @@ $('#parse-confirm').onclick = startApp;
const auto = new URLSearchParams(location.search).get('autostart'); 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); 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 = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
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 = `<span class="ti-name">${t.trip.title}</span><span class="ti-sub">${t.id === tripId ? 'current trip' : 'open · v' + (saved?.version || 1)}</span>`;
b.onclick = () => { menu.classList.add('hidden'); switchTrip(t.id); };
menu.append(b);
});
const more = el('button', 'trip-item');
more.innerHTML = `<span class="ti-name">+ New trip…</span><span class="ti-sub">start from a booking on the landing screen</span>`;
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 = routerOn ? '📶 online · router on' : '📶 online'; }
$('#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
checkRouter().then(() => {
const b = $('#offline-badge');
if (b && offlineState !== 'ready') b.textContent = routerOn ? '📶 online · router on' : '📶 online';
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 <b>${M.trip.title}</b> — youre at <b>v${version}</b> (“${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() { function startApp() {
$('#parse-modal').classList.add('hidden'); $('#parse-modal').classList.add('hidden');
$('#landing').classList.add('hidden'); $('#landing').classList.add('hidden');
$('#topbar').classList.remove('hidden'); $('#topbar').classList.remove('hidden');
$('#app').classList.remove('hidden'); $('#app').classList.remove('hidden');
$('#crumb').textContent = M.trip.title; enterTrip(tripId);
renderBaseChip();
buildDayTabs();
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
renderAll();
commit('initial plan');
script1();
} }
// ---------------- detail sheet ---------------- // ---------------- detail sheet ----------------
@ -1587,8 +1685,4 @@ $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').c
})(); })();
initMap(); initMap();
checkRouter().then(() => { // once router status is known, enrich the visible day's legs // router status + leg enrichment happen in enterTrip() (per-trip extract)
if (routerOn) enrichLegs(day);
const b = $('#offline-badge');
if (b && offlineState !== 'ready') b.textContent = routerOn ? '📶 online · router on' : '📶 online';
});

View File

@ -519,7 +519,473 @@ const BOSTON = {
] ]
}; };
// pick the demo city from ?city= (default Florence)
const _city = (new URLSearchParams(location.search).get('city') || 'florence').toLowerCase(); // ---------------------------------------------------------------------------
window.MOCK = _city === 'boston' ? BOSTON : FLORENCE; // COLOMBIA · Cartagena → Santa Marta — the working trip. Inside the local
window.MOCK_CITY = _city; // walking-profile OSRM extract (:5003, `colombia`), so every on-foot leg
// upgrades to real road-network times + geometry; the flights are fixed.
const COLOMBIA = {
id: 'colombia',
router: 'colombia',
trip: {
id: 'trip-2026-09-col',
version: 1,
title: 'Colombia · Cartagena → Santa Marta 2128 Sep',
stays: [
{ id: 'ST1', name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522], place: 'Getsemaní, Cartagena',
checkIn: '2026-09-21', checkOut: '2026-09-24', state: 'booked', price: 120 },
{ id: 'ST2', name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130], place: 'Centro, Santa Marta',
checkIn: '2026-09-25', checkOut: '2026-09-28', state: 'booked', price: 150 },
],
places: [
{ id: 'PL1', name: 'Cartagena', bbox: [10.36, -75.62, 10.48, -75.42],
center: [10.42, -75.52], vibe: 'forts, old town & Caribbean sea',
dates: ['2026-09-21', '2026-09-25'] },
{ id: 'PL2', name: 'Santa Marta', bbox: [11.08, -74.27, 11.28, -74.13],
center: [11.18, -74.20], vibe: 'malecón, muelle & Tayrona',
dates: ['2026-09-25', '2026-09-28'] },
],
bookings: [
{ type: 'hotel', name: 'Hotel Casa San Antonio', ref: 'C-20117',
dates: '2125 Sep', where: [10.3952, -75.5522], status: 'booked', source: 'user_booking' },
{ type: 'hotel', name: 'Hotel Muelle de Bolívar', ref: 'C-20118',
dates: '2528 Sep', where: [11.2390, -74.2130], status: 'booked', source: 'user_booking' },
{ type: 'flight', name: 'AV 1351', ref: 'AV-88213',
from: 'Rafael Núñez (CTG)', to: 'Simón Bolívar (SMR)', date: '2026-09-25',
depart: '08:40', arrive: '09:25', status: 'booked', source: 'user_booking',
stations: { from: [10.4622, -75.4385], to: [11.1165, -74.2330] } },
{ type: 'flight', name: 'AV 1352', ref: 'AV-88214',
from: 'Simón Bolívar (SMR)', to: 'Bogotá (BOG)', date: '2026-09-28',
depart: '15:10', arrive: '15:55', status: 'booked', source: 'user_booking',
stations: { from: [11.1165, -74.2330], to: [4.7016, -74.1469] } },
]
},
days: [
{ id: 'D1', date: '2026-09-21', label: 'Day 1 · Mon 21',
startMin: 9 * 60, wakingHours: 12,
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
legs: [],
stops: [
{ id: 'S1', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [10.3952, -75.5522], dur: 30, durConf: 3,
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S2', name: 'Getsemaní street-art walk', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Getseman%C3%AD,_Cartagena',
at: [10.3932, -75.5520], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' },
price: null,
img: ['🎨', 'Getsemaní street art', 'linear-gradient(135deg,#c98a3c,#8a5c24)'],
detail: {
summary: 'The former port neighbourhood — a hundred walls of murals between narrow lanes. Start at Plaza de los Coches and wander south to the market; mornings are cool and the lanes are empty before 10:00.',
links: [ { label: 'Getsemaní', href: 'https://en.wikipedia.org/wiki/Getseman%C3%AD,_Cartagena' } ] } },
{ id: 'S3', name: 'Lunch — La Casona de San Diego', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
at: [10.3940, -75.5545], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 18, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🍲', 'La Casona de San Diego', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'],
detail: {
summary: 'Caribbean home cooking in a whitewashed casona — sancocho de pescado, mango con arepa. Cash only; the line moves fast at 12:30.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S4', name: 'Mercado de Getsemaní', kind: 'visit', slot: 'visit', state: 'planned',
at: [10.3945, -75.5530], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: null },
price: null,
img: ['🥭', 'Mercado de Getsemaní', 'linear-gradient(135deg,#8f6b3c,#5c441f)'],
detail: {
summary: 'Fish, fruit and dried herbs stacked to the ceiling — the market the neighbourhood actually uses. A coconut and a fresh juice while you look at the catch is the correct way to spend 45 minutes here.',
links: [ { label: 'Cartagena tourism', href: 'https://www.cartagenatourism.com' } ] } },
{ id: 'S5', name: 'Plaza de la Aduana & Cathedral', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Cartagena_Cathedral',
at: [10.4615, -75.5550], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' },
price: null,
img: ['⛪', 'Catedral de Cartagena', 'linear-gradient(135deg,#8f3c5c,#5c1f3a)'],
detail: {
summary: 'The 1594 cathedral is the oldest in Colombia and sits on the square where the old citys life happens. Golden hour: the facade turns pink and the plaza fills with musicians. Entry is cheap; the belfry stairs are the view.',
links: [ { label: 'Cartagena Cathedral', href: 'https://en.wikipedia.org/wiki/Cartagena_Cathedral' } ] } }
]
},
{ id: 'D2', date: '2026-09-22', label: 'Day 2 · Tue 22',
startMin: 8 * 60 + 30, wakingHours: 12,
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
legs: [],
stops: [
{ id: 'S6', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [10.3952, -75.5522], dur: 30, durConf: 3,
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S7', name: 'Casa de la Moneda', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Casa_de_la_Moneda_(Cartagena)',
at: [10.4619, -75.5544], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 5, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['🪙', 'Casa de la Moneda', 'linear-gradient(135deg,#8f8a3c,#5c571f)'],
detail: {
summary: 'The colonial mint — the coin-moulding room and the courtyard where the galleons silver was counted. Small, cool and fast: 45 minutes is exactly right.',
links: [ { label: 'Casa de la Moneda', href: 'https://en.wikipedia.org/wiki/Casa_de_la_Moneda_(Cartagena)' } ] } },
{ id: 'S8', name: 'Palacio de la Inquisición', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Palacio_de_la_Inquisici%C3%B3n_(Cartagena)',
at: [10.4604, -75.5561], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 7, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['🏛️', 'Palacio de la Inquisición', 'linear-gradient(135deg,#3c5c8f,#1f3a5c)'],
detail: {
summary: 'The Inquisitions 16th-century house — the courtyard fountain, the old interrogation rooms and the museums quiet gold room. The 23:00 evening slot with the plaza lit by candles is the famous one; daytime is calm.',
links: [ { label: 'Palacio de la Inquisición', href: 'https://en.wikipedia.org/wiki/Palacio_de_la_Inquisici%C3%B3n_(Cartagena)' } ] } },
{ id: 'S9', name: 'San Pedro Claver', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/San_Pedro_Claver_Church',
at: [10.4645, -75.5557], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: null },
price: null,
img: ['🕊️', 'San Pedro Claver', 'linear-gradient(135deg,#7a7a8f,#4f4f61)'],
detail: {
summary: 'The whitest church in the old city — and the resting place of Pedro Claver, “apostle of the negroes”. Free, quiet, and the Plaza del Chopo behind it is where the old towns afternoon slows down.',
links: [ { label: 'San Pedro Claver', href: 'https://en.wikipedia.org/wiki/San_Pedro_Claver_Church' } ] } },
{ id: 'S10', name: 'Lunch — La Loma de San Diego', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
at: [10.4590, -75.5530], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 25, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🥘', 'La Loma de San Diego', 'linear-gradient(135deg,#c93c3c,#8a1f1f)'],
detail: {
summary: 'The old-town institution for arroz con coco and grilled fish — a big table under the patio, and the kitchen is part of the show. Go at 12:15 to skip the tour-group wave.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S11', name: 'Cerro La Popa — sunset', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa',
at: [10.4257, -75.5523], dur: 75, durConf: 3,
suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 10, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['🌅', 'Cerro La Popa', 'linear-gradient(135deg,#c95c3c,#8a3a1f)'],
detail: {
summary: 'The hilltop hermitage over the city — the panoramic terrace is the best single view in Cartagena: the fort, the old towns pink walls, the Gulf and Bocagrande in one frame. Be up 45 min before sunset or the terrace is elbow to elbow.',
links: [ { label: 'Cerro La Popa', href: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa' } ] } }
]
},
{ id: 'D3', date: '2026-09-23', label: 'Day 3 · Wed 23',
startMin: 8 * 60, wakingHours: 12,
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
legs: [],
stops: [
{ id: 'S12', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [10.3952, -75.5522], dur: 30, durConf: 3,
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S13', name: 'Castillo San Felipe', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://www.castillosanfelipe.gov.co',
at: [10.3930, -75.5565], dur: 90, durConf: 3,
suggested: { value: 120, conf: 2, src: 'castillosanfelipe.gov.co' },
price: { amount: 15, cur: '$', conf: 2, src: 'castillosanfelipe.gov.co' },
img: ['🏰', 'Castillo San Felipe', 'linear-gradient(135deg,#8a8f3c,#565b24)'],
detail: {
summary: 'The largest Spanish fort in the Americas — five levels of ramparts, tunnels and cannon lines around the whole peninsula. A full loop takes 2 h; 90 min gets the tunnels and the sea terrace. Go at opening (08:30) for the cold tunnels and no tour buses.',
links: [
{ label: 'Castillo San Felipe', href: 'https://www.castillosanfelipe.gov.co' },
{ label: 'Tickets', href: 'https://www.castillosanfelipe.gov.co/visita' }
] } },
{ id: 'S14', name: 'Castillo San Diego', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Castillo_San_Diego',
at: [10.4247, -75.5512], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 8, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['⚓', 'Castillo San Diego', 'linear-gradient(135deg,#3c8f8a,#1f5c59)'],
detail: {
summary: 'The smaller fort guarding the harbour mouth from the other side — the 4th-floor terrace looks straight across to San Felipe. Pairs well as the afternoon half of a fort day.',
links: [ { label: 'Castillo San Diego', href: 'https://en.wikipedia.org/wiki/Castillo_San_Diego' } ] } },
{ id: 'S15', name: 'Lunch — Club El Boat', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
at: [10.4355, -75.5375], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 20, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🦐', 'Club El Boat', 'linear-gradient(135deg,#3c6b8f,#1f445c)'],
detail: {
summary: 'Seafood on the Bocagrande malecón — the langosta is the plate, the sunset the side order. Arrive 12:45 for the table with the water view before the 14:00 heat makes the terrace empty.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S16', name: 'Malecón de Bocagrande', kind: 'visit', slot: 'visit', state: 'planned',
at: [10.4365, -75.5390], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: null },
price: null,
img: ['🌊', 'Malecón de Bocagrande', 'linear-gradient(135deg,#4f9ea0,#2c6b6d)'],
detail: {
summary: 'The 5 km seawall walk around the Caribbean — the long straight boardwalk is the citys living room. An hour south of the malecón covers the best stretch without getting sweaty.',
links: [ { label: 'Bocagrande', href: 'https://en.wikipedia.org/wiki/Bocagrande' } ] } }
]
},
{ id: 'D4', date: '2026-09-24', label: 'Day 4 · Thu 24',
startMin: 9 * 60, wakingHours: 12,
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
legs: [],
stops: [
{ id: 'S17', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [10.3952, -75.5522], dur: 30, durConf: 3,
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S18', name: 'Punta Canoa', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Punta_Canoa',
at: [10.4185, -75.5255], dur: 90, durConf: 3,
suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' },
price: null,
img: ['🏖️', 'Punta Canoa', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'],
detail: {
summary: 'The old fishermens point — beach, hammocks and the best cheap seafood on a bench in the city. September mornings are calm; the water is flat glass before the wind comes off the gulf.',
links: [ { label: 'Punta Canoa', href: 'https://en.wikipedia.org/wiki/Punta_Canoa' } ] } },
{ id: 'S19', name: 'Lunch — beachside at Punta Canoa', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
at: [10.4190, -75.5245], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 15, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🍤', 'Punta Canoa beach', 'linear-gradient(135deg,#c9803c,#8a541f)'],
detail: {
summary: 'Whatever the boat landed this morning — fried whole, with coconut rice and yuca. Point to a table, the price is on the chalkboard, and the sea is the view.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S20', name: 'Plaza del Chopo — farewell stroll', kind: 'visit', slot: 'visit', state: 'planned',
at: [10.4638, -75.5560], dur: 60, durConf: 3,
suggested: { value: 45, conf: 2, src: null },
price: null,
img: ['🌇', 'Plaza del Chopo', 'linear-gradient(135deg,#8f3c5c,#5c241f)'],
detail: {
summary: 'The old citys quietest square — a last loop past San Pedro Claver with the light going gold, and one more arepa con queso from the corner stall.',
links: [ { label: 'Cartagena old town', href: 'https://en.wikipedia.org/wiki/Cartagena' } ] } }
]
},
{ id: 'D5', date: '2026-09-25', label: 'Day 5 · Fri 25',
startMin: 7 * 60, wakingHours: 11,
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
legs: [],
stops: [
{ id: 'S21', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [10.3952, -75.5522], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S22', name: 'Vuelo CTG → SMR', kind: 'transit', slot: 'transit', state: 'planned',
at: [10.4622, -75.4385], dur: 45, durConf: 4,
transit: { name: 'AV 1351', ref: 'AV-88213',
from: 'Rafael Núñez (CTG)', to: 'Simón Bolívar (SMR)',
depart: '08:40', arrive: '09:25', arriveBy: '07:40',
buffer: '1 h before departure' },
suggested: null,
price: { amount: 89, cur: '$', conf: 4, src: 'booking' },
img: ['✈️', 'AV 1351', 'linear-gradient(135deg,#274b8f,#16294d)'] },
{ id: 'S23', name: 'Malecón & Plaza Colesio, Santa Marta', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Santa_Marta',
at: [11.2425, -74.2140], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: 'santamarta.gov.co' },
price: null,
img: ['🏙️', 'Malecón de Santa Marta', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'],
detail: {
summary: 'The city Bolívar died in — the malecón runs from the muelle past Plaza Colesio under the palms. Check in, drop the bags, and walk the sea wall to shake the flight: the Caribbean here is warmer and emptier than Cartagenas.',
links: [ { label: 'Santa Marta', href: 'https://en.wikipedia.org/wiki/Santa_Marta' } ] } },
{ id: 'S24', name: 'Basílica de la Virgen del Perpetuo Socorro', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Bas%C3%ADlica_de_la_Virgen_del_Perpetuo_Socorro',
at: [11.2420, -74.2152], dur: 30, durConf: 3,
suggested: { value: 30, conf: 2, src: null },
price: null,
img: ['⛪', 'Basílica del Perpetuo Socorro', 'linear-gradient(135deg,#8f8a3c,#5c441f)'],
detail: {
summary: 'The citys most venerated image — the small baroque church on Plaza de la Aduana where the candles never stop. Ten minutes of quiet is enough; the plaza around it is the evening spot.',
links: [ { label: 'Basílica', href: 'https://en.wikipedia.org/wiki/Bas%C3%ADlica_de_la_Virgen_del_Perpetuo_Socorro' } ] } }
]
},
{ id: 'D6', date: '2026-09-26', label: 'Day 6 · Sat 26',
startMin: 8 * 60 + 30, wakingHours: 11,
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
legs: [],
stops: [
{ id: 'S25', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [11.2390, -74.2130], dur: 30, durConf: 3,
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S26', name: 'Casa de Raquel', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Casa_de_Raquel',
at: [11.2418, -74.2118], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: 'santamarta.gov.co' },
price: { amount: 4, cur: '$', conf: 2, src: 'santamarta.gov.co' },
img: ['📜', 'Casa de Raquel', 'linear-gradient(135deg,#7a5c8f,#4f3a5c)'],
detail: {
summary: 'The house of Raquel Méndez — the girl whose love letters made Bolívars last years. The small museum upstairs has the original letters; the courtyard is the photo.',
links: [ { label: 'Casa de Raquel', href: 'https://en.wikipedia.org/wiki/Casa_de_Raquel' } ] } },
{ id: 'S27', name: 'Lunch — La Terraza del Muelle', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
at: [11.2375, -74.2140], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 20, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🐟', 'La Terraza del Muelle', 'linear-gradient(135deg,#3c6b8f,#1f445c)'],
detail: {
summary: 'Grilled fish on the terrace over the working harbour — the boats come in around noon and the menu follows the catch. The shrimp ceviche is the safe first order.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S28', name: 'El Muelle & fish market', kind: 'visit', slot: 'visit', state: 'planned',
at: [11.2360, -74.2150], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: null },
price: null,
img: ['⚓', 'El Muelle de Santa Marta', 'linear-gradient(135deg,#5c6b8f,#3a445c)'],
detail: {
summary: 'The 19th-century stone pier and the market behind it — ice, line-caught fish, and the same crews who have run these boats for a century. The best 45 minutes in the city if you like to watch work happen.',
links: [ { label: 'El Muelle', href: 'https://en.wikipedia.org/wiki/El_Muelle_(Santa_Marta)' } ] } },
{ id: 'S29', name: 'Parque de los Novios', kind: 'visit', slot: 'visit', state: 'planned',
at: [11.2395, -74.2085], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: 'santamarta.gov.co' },
price: null,
img: ['💑', 'Parque de los Novios', 'linear-gradient(135deg,#6b8f4f,#445c2c)'],
detail: {
summary: 'The lakeside park where the city goes at dusk — the lagoon, the couples (it is in the name), and the walk back along the water to the hotel.',
links: [ { label: 'Santa Marta parks', href: 'https://santamarta.gov.co' } ] } }
]
},
{ id: 'D7', date: '2026-09-27', label: 'Day 7 · Sun 27',
startMin: 7 * 60 + 30, wakingHours: 12,
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
legs: [],
stops: [
{ id: 'S30', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [11.2390, -74.2130], dur: 30, durConf: 3,
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S31', name: 'Bus → Tayrona (El Croado)', kind: 'transit', slot: 'transit', state: 'planned',
at: [11.1975, -74.1760], dur: 40, durConf: 3,
transit: { name: 'Bus Centro → El Croado', ref: 'TAY-01',
from: 'Santa Marta Centro', to: 'Parque Tayrona, El Croado',
depart: '08:30', arrive: '09:10', arriveBy: '08:30',
buffer: 'park gate opens 08:00' },
suggested: null,
price: { amount: 4, cur: '$', conf: 3, src: 'parquetayrona.gov.co' },
img: ['🚌', 'Bus to Tayrona', 'linear-gradient(135deg,#8f6b3c,#5c441f)'] },
{ id: 'S32', name: 'Playa Cangrejos', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://parquetayrona.gov.co',
at: [11.1765, -74.1690], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: 'parquetayrona.gov.co' },
price: { amount: 15, cur: '$', conf: 3, src: 'parquetayrona.gov.co' },
img: ['🌴', 'Playa Cangrejos', 'linear-gradient(135deg,#3cb5c9,#1f5c44)'],
detail: {
summary: 'The first beach on the park walk — jungle right up to the sand line, and the water so clear it looks edited. Two hours here is a long, correct rest after the walk in.',
links: [ { label: 'Parque Tayrona', href: 'https://parquetayrona.gov.co' } ] } },
{ id: 'S33', name: 'Cabo San Juan', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://parquetayrona.gov.co',
at: [11.1390, -74.1455], dur: 120, durConf: 3,
suggested: { value: 120, conf: 2, src: 'parquetayrona.gov.co' },
price: null,
img: ['🏝️', 'Cabo San Juan', 'linear-gradient(135deg,#c95c8a,#8a1f5c)'],
detail: {
summary: 'The parks far tip — the 2.5 km beach walk from Cangrejos is the trail: sand underfoot, jungle walls, the headland where the Caribbean hits the Sierra Nevada. September crowd levels are thin; the last boats out run 15:30, so turn around by 14:00.',
links: [ { label: 'Parque Tayrona', href: 'https://parquetayrona.gov.co' } ] } }
]
},
{ id: 'D8', date: '2026-09-28', label: 'Day 8 · Mon 28', isDeparture: true,
startMin: 9 * 60, wakingHours: 6,
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
legs: [],
stops: [
{ id: 'S34', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast',
slot: 'breakfast', state: 'planned',
at: [11.2390, -74.2130], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S35', name: 'Vuelo SMR → BOG', kind: 'transit', slot: 'transit', state: 'planned',
at: [11.1165, -74.2330], dur: 45, durConf: 4,
transit: { name: 'AV 1352', ref: 'AV-88214',
from: 'Simón Bolívar (SMR)', to: 'Bogotá (BOG)',
depart: '15:10', arrive: '15:55', arriveBy: '14:10',
buffer: '1 h before departure' },
suggested: null,
price: { amount: 95, cur: '$', conf: 4, src: 'booking' },
img: ['✈️', 'AV 1352', 'linear-gradient(135deg,#274b8f,#16294d)'] }
]
}
],
candidates: {
hotel: [
{ id: 'H1', name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522], price: 120, dur: null,
tags: ['Getsemaní', 'colonial casona', 'roof terrace'], pitch: 'Where you are now — a whitewashed casona in Getsemaní with a terrace over the murals.', url: 'https://www.tripadvisor.com' },
{ id: 'H2', name: 'Hotel Casa 1800', at: [10.4606, -75.5547], price: 260, dur: null,
tags: ['old town', 'design', 'pool'], pitch: 'In the old town instead — the 19th-century palace turned design hotel; pricier, but you skip the walk entirely.', url: 'https://www.casa1800.com' },
{ id: 'H3', name: 'Villa El Banderito', at: [10.4340, -75.5365], price: 140, dur: null,
tags: ['Bocagrande', 'beachfront', 'quiet'], pitch: 'On the Bocagrande sand — wake up at the water; a 15-min tuktuk from Getsemaní.', url: 'https://www.tripadvisor.com' }
],
lunch: [
{ id: 'C1', name: 'La Casona de San Diego', at: [10.3940, -75.5545], price: 18, dur: 75,
tags: ['caribbean', 'getsemaní', 'cash only'], pitch: 'Sancocho and mango con arepa in a whitewashed casona — the neighbourhoods own lunch.',
url: 'https://www.tripadvisor.com', img: ['🍲', 'La Casona', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'] },
{ id: 'C2', name: 'La Loma de San Diego', at: [10.4590, -75.5530], price: 25, dur: 75,
tags: ['old town', 'arroz con coco', 'institution'], pitch: 'The old-town classic — big patio table, kitchen as theatre, go at 12:15.',
url: 'https://www.tripadvisor.com', img: ['🥘', 'La Loma', 'linear-gradient(135deg,#c93c3c,#8a1f1f)'] },
{ id: 'C3', name: 'Club El Boat', at: [10.4355, -75.5375], price: 20, dur: 90,
tags: ['bocagrande', 'seafood', 'sunset'], pitch: 'Langosta on the malecón — the sunset is the side order.',
url: 'https://www.tripadvisor.com', img: ['🦐', 'El Boat', 'linear-gradient(135deg,#3c6b8f,#1f445c)'] },
{ id: 'C4', name: 'La Terraza del Muelle', at: [11.2375, -74.2140], price: 20, dur: 75,
tags: ['santa marta', 'harbour', 'catch of the day'], pitch: 'Grilled fish over the working pier — the boats set the menu.',
url: 'https://www.tripadvisor.com', img: ['🐟', 'Terraza del Muelle', 'linear-gradient(135deg,#3c6b8f,#1f445c)'] }
],
visit: [
{ id: 'V1', name: 'Playa Bocagrande', at: [10.4330, -75.5340], price: 0, dur: 90,
tags: ['beach', 'free', 'swim'], pitch: 'The city beach — a proper swim and a hammock, ten minutes from the old town by tuktuk.',
url: 'https://en.wikipedia.org/wiki/Bocagrande', img: ['🏖️', 'Bocagrande', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'] },
{ id: 'V2', name: 'Plaza Peñol', at: [10.4632, -75.5570], price: 0, dur: 30,
tags: ['free', 'old town', 'shades'], pitch: 'The old towns leafy pause — the black umbrellas and the shade are the whole point.',
url: 'https://en.wikipedia.org/wiki/Cartagena', img: ['☂️', 'Plaza Peñol', 'linear-gradient(135deg,#4f7a4f,#2c4f2c)'] },
{ id: 'V3', name: 'Iglesia de la Popa', at: [10.4253, -75.5530], price: 10, dur: 30,
tags: ['view', 'hilltop', 'sunset'], pitch: 'The church itself in 15 min — the terrace does the rest.',
url: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa', img: ['⛪', 'Iglesia de la Popa', 'linear-gradient(135deg,#7a7a8f,#4f4f61)'] },
{ id: 'V4', name: 'Playa Cocal, Tayrona', at: [11.1650, -74.1620], price: 0, dur: 60,
tags: ['tayrona', 'beach', 'jungle'], pitch: 'The quieter of the two main Tayrona beaches — the jungle wall is closer to the sand here.',
url: 'https://parquetayrona.gov.co', img: ['🌴', 'Playa Cocal', 'linear-gradient(135deg,#4f9e63,#2c6b40)'] },
{ id: 'V5', name: 'Cerro de La Popa (Santa Marta)', at: [11.2460, -74.2020], price: 2, dur: 60,
tags: ['view', 'santa marta', 'sunset'], pitch: 'The hilltop over Santa Marta — city, gulf and the Sierra Nevada in one frame.',
url: 'https://en.wikipedia.org/wiki/Santa_Marta', img: ['🌄', 'Cerro La Popa', 'linear-gradient(135deg,#c95c3c,#8a3a1f)'] }
]
},
nudges: {
D1: [
{ id: 'N1', title: 'Getsemaní is best before 10:00',
body: 'The street-art lanes fill with tour groups by late morning. Your 09:30 start is the right call — keep it.',
fix: { label: 'Keep as is' } }
],
D3: [
{ id: 'N2', title: 'Two forts in a day is a lot of stairs',
body: 'Castillo San Diego runs <b>~75 min</b> for the terrace-and-bastion loop <i>(web)</b>; 90 min if you take the rampart walk. As is, youll be tight but fine.',
fix: { label: 'Extend to ~90 min', stop: 'S14', dur: 90,
msg: 'San Diego extended to 90 min — the malecón stroll slides a little later; the day still fits.' } }
],
D7: [
{ id: 'N3', title: 'Tayrona: water, sunscreen, and the 15:30 boats',
body: 'The Cangrejos → Cabo San Juan stretch is <b>2.5 km of beach, no shade and no vendors</b>. The last boats out run 15:30 — your 14:00 turnaround is correct.',
fix: { label: 'Keep as is' } }
],
D4: [], D5: [], D6: [], D8: []
},
multimodal: [
{ into: 'S22', dur: 30, sub: [ { mode: 'car', dur: 25 } ] }, // taxi Getsemaní → CTG airport
{ into: 'S23', dur: 25, sub: [ { mode: 'car', dur: 20 } ] }, // taxi SMR → hotel
{ into: 'S31', dur: 40, sub: [ { mode: 'bus', dur: 35 } ] }, // bus Centro → Tayrona
{ into: 'S35', dur: 25, sub: [ { mode: 'car', dur: 20 } ] } // taxi hotel → SMR
],
vibes: [
{ id: 'v1', label: 'forts & old town' },
{ id: 'v2', label: 'Caribbean beaches & malecón' },
{ id: 'v3', label: 'street art (Getsemaní)' },
{ id: 'v4', label: 'Tayrona day trip' },
{ id: 'v5', label: 'Caribbean food' }
],
suggestions: [
{ stop: 'S2', pitch: 'A hundred walls of murals between narrow lanes — start at Plaza de los Coches and wander to the market.',
enrich: 'free · best before 10:00 · ~60 min' },
{ stop: 'S13', pitch: 'Five levels of ramparts, tunnels and cannon lines — go at opening (08:30) for the cold tunnels and no tour buses.',
enrich: 'open 08:30 · ~$15 · 90120 min' },
{ stop: 'S11', pitch: 'The best single view in Cartagena — be on the terrace 45 min before sunset.',
enrich: 'open 09:0018:00 · ~$10' },
{ stop: 'S33', pitch: 'Sand underfoot, jungle walls, the headland where the Caribbean hits the Sierra Nevada. Last boats out 15:30.',
enrich: 'park 08:0016:00 · entry ~$15 · 2.5 km beach walk' }
]
};
// ---------------- trip registry (multi-trip) ----------------
// Each trip is a full dataset. The app keeps per-trip state in
// localStorage (see app.js), so several trips can be worked on in parallel.
window.TRIPS = {
colombia: Object.assign({}, COLOMBIA),
boston: Object.assign({ id: 'boston' }, BOSTON),
florence: Object.assign({ id: 'florence' }, FLORENCE),
};
// ?trip= (legacy: ?city=) picks the starting trip; default: the one you're working on
const _city = ((new URLSearchParams(location.search).get('trip'))
|| (new URLSearchParams(location.search).get('city'))
|| 'colombia').toLowerCase();
window.MOCK = window.TRIPS[_city] || window.TRIPS.colombia;
window.MOCK_CITY = window.MOCK.id;

View File

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trip</title> <title>Trip</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%97%BA%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
@ -25,9 +26,10 @@
<button id="dz-sample" class="btn ghost small">or start from an example booking</button> <button id="dz-sample" class="btn ghost small">or start from an example booking</button>
</div> </div>
<div class="land-demo"> <div class="land-demo">
<span>or explore a demo trip</span> <span>or pick a trip</span>
<button class="chip" data-city="florence">Florence</button> <button class="chip" data-city="colombia">Colombia · Cartagena → Santa Marta <i>· live walking routing</i></button>
<button class="chip" data-city="boston">Boston <i>· live road routing</i></button> <button class="chip" data-city="boston">Boston <i>· live road routing</i></button>
<button class="chip" data-city="florence">Florence</button>
</div> </div>
</div> </div>
</section> </section>
@ -47,7 +49,10 @@
<!-- ======================= APP ======================= --> <!-- ======================= APP ======================= -->
<header id="topbar" class="hidden"> <header id="topbar" class="hidden">
<div class="tb-left"> <div class="tb-left">
<span class="crumb" id="crumb"></span> <div class="trip-picker" id="trip-picker">
<button class="crumb" id="crumb" title="Switch trip — each keeps its own plan"></button>
<div id="trip-menu" class="tripmenu hidden"></div>
</div>
<div class="scope-toggle" title="Day = working view · Trip = macro overview of all days"> <div class="scope-toggle" title="Day = working view · Trip = macro overview of all days">
<button id="scope-day" class="sct on" data-sc="day">Day</button> <button id="scope-day" class="sct on" data-sc="day">Day</button>
<button id="scope-trip" class="sct" data-sc="trip">Trip</button> <button id="scope-trip" class="sct" data-sc="trip">Trip</button>

63
mock/scripts/pre-cache-tiles.sh Executable file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Pre-cache OSM tiles for the trip's map extent through the local proxy
# (which stores them in mock/.tilecache). Usage:
# pre-cache-tiles.sh # all trips
# pre-cache-tiles.sh colombia z16 # one trip, one zoom
set -euo pipefail
cd "$(dirname "$0")/.."
BASE="${BASE:-http://localhost:8077}"
TRIPS=("$@")
[ ${#TRIPS[@]} -eq 0 ] && TRIPS=(colombia boston florence)
# tile range for a lon/lat box
range() { # z minlon minlat maxlon maxlat -> "x0 x1 y0 y1"
python3 -c "
import math
z, minlo, minla, maxlo, maxla = float('$1'), float('$2'), float('$3'), float('$4'), float('$5')
def tx(lon): return int((lon + 180) / 360 * 2**z)
def ty(lat):
r = math.radians(lat)
return int((1 - math.log(math.tan(r) + 1 / math.cos(r)) / math.pi) / 2 * 2**z)
print(tx(minlo), tx(maxlo), ty(maxla), ty(minla))"
}
fetch() { # trip z
local trip="$1" z="$2"
case "$trip" in
colombia)
# Cartagena + Santa Marta boxes
for box in "-75.62 10.36 -75.42 10.48" "-74.27 11.08 -74.13 11.28"; do
set -- $box
read -r x0 x1 y0 y1 <<< "$(range "$z" "$1" "$2" "$3" "$4")"
echo " $trip z$z box($1,$2,$3,$4): x $x0-$x1 y $y0-$y1" >&2
for ((x=x0; x<=x1; x++)); do for ((y=y0; y<=y1; y++)); do
echo "$BASE/tiles/$z/$x/$y.png"
done; done
done
;;
boston)
set -- -71.16 42.29 -71.00 42.41
read -r x0 x1 y0 y1 <<< "$(range "$z" "$1" "$2" "$3" "$4")"
for ((x=x0; x<=x1; x++)); do for ((y=y0; y<=y1; y++)); do
echo "$BASE/tiles/$z/$x/$y.png"
done; done
;;
florence)
set -- 11.19 43.72 11.32 43.80
read -r x0 x1 y0 y1 <<< "$(range "$z" "$1" "$2" "$3" "$4")"
for ((x=x0; x<=x1; x++)); do for ((y=y0; y<=y1; y++)); do
echo "$BASE/tiles/$z/$x/$y.png"
done; done
;;
esac
}
for trip in "${TRIPS[@]}"; do
for z in 14 15 16; do
echo "== $trip z$z"
# one URL per curl: -o applies to the first URL only, otherwise the rest
# print to stdout
fetch "$trip" "$z" | xargs -P 8 -n 1 curl -s -o /dev/null -m 20 --retry 2 > /dev/null
done
done
echo "done. cache size: $(du -sh .tilecache | cut -f1)"

View File

@ -1,15 +1,27 @@
// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached) // Mock server: static UI + local tile backend (proxy -> OSM, disk-cached)
// + routing backend (proxy -> local OSRM on :5000, NE-US extract). // + routing backend (proxy -> local OSRM instances; per-trip extracts).
// The UI only ever talks to localhost; swap the upstream for a real // The UI only ever talks to localhost; swap the upstream for a real
// tileserver-gl / OSRM later without touching the frontend. // tileserver-gl / OSRM later without touching the frontend.
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { Readable } = require('stream');
const PORT = 8077; const PORT = 8077;
const ROOT = __dirname; const ROOT = __dirname;
const OSRM = process.env.OSRM_URL || 'http://localhost:5000'; // One upstream OSRM per dataset. `probe` is a short routable pair inside the
// extract, used by /router-status (health checks return Ok for 0-distance
// queries only when the graph has edges there).
const ROUTERS = {
northeast: {
url: process.env.OSRM_NORTHEAST || 'http://localhost:5000',
probe: '-71.06,42.35;-71.07,42.36', // Boston, car extract
},
colombia: {
url: process.env.OSRM_COLOMBIA || 'http://localhost:5003',
probe: '-75.5478,10.3954;-75.5400,10.4020', // Cartagena, foot extract
},
};
const routerFor = (u) => ROUTERS[u.searchParams.get('router') || 'northeast'] || ROUTERS.northeast;
const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' }; const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
const CACHE = path.join(__dirname, '.tilecache'); const CACHE = path.join(__dirname, '.tilecache');
fs.mkdirSync(CACHE, { recursive: true }); fs.mkdirSync(CACHE, { recursive: true });
@ -38,7 +50,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 fetchUp = fetch(OSRM + osrmPath).then(r => r.text()).catch(() => null); const up = routerFor(u).url;
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', '*');
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
@ -49,11 +62,11 @@ http.createServer((req, res) => {
} }
// routing availability probe (so the UI can label router vs estimate) // routing availability probe (so the UI can label router vs estimate)
if (u.pathname === '/router-status') { if (u.pathname === '/router-status') {
const probe = '-71.06,42.35;-71.07,42.36'; // must span >0 distance for the health check const rt = routerFor(u);
fetch(OSRM + '/route/v1/walking/' + probe + '?overview=false').then(r => r.text()) fetch(rt.url + '/route/v1/walking/' + rt.probe + '?overview=false').then(r => r.text())
.then(t => { let ok = false; try { ok = JSON.parse(t).code === 'Ok' && JSON.parse(t).routes?.[0]?.distance > 0; } catch (e) {} .then(t => { let ok = false; try { ok = JSON.parse(t).code === 'Ok' && JSON.parse(t).routes?.[0]?.distance > 0; } catch (e) {}
res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: ok, osrm: OSRM })); }) res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: ok, osrm: rt.url, key: rt === ROUTERS.northeast ? 'northeast' : 'colombia' })); })
.catch(() => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: false, osrm: OSRM })); }); .catch(() => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: false, osrm: rt.url })); });
return; return;
} }
@ -70,15 +83,16 @@ http.createServer((req, res) => {
if (fs.existsSync(file)) { res.writeHead(200); fs.createReadStream(file).pipe(res); return; } if (fs.existsSync(file)) { res.writeHead(200); fs.createReadStream(file).pipe(res); return; }
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true });
const fail = (code) => { if (!res.headersSent) { res.writeHead(code); res.end(); } else res.destroy(); }; const fail = (code) => { if (!res.headersSent) { res.writeHead(code); res.end(); } else res.destroy(); };
// buffer the whole tile (≤100 KB), write the cache file, then reply —
// no stream-splitting races with a client that reads fast
fetch(upstream, { headers: { 'User-Agent': 'mapmock-dev/0.1 (local tile proxy)', 'Referer': `http://localhost:${PORT}/` } }) fetch(upstream, { headers: { 'User-Agent': 'mapmock-dev/0.1 (local tile proxy)', 'Referer': `http://localhost:${PORT}/` } })
.then(r => { .then(async r => {
if (!r.ok) { return fail(r.status); } if (!r.ok) { return fail(r.status); }
const body = Readable.fromWeb(r.body); const buf = Buffer.from(await r.arrayBuffer());
body.on('error', () => fail(502)); if (buf.length < 20) { return fail(502); } // empty/garbage tile
res.writeHead(200); try { fs.writeFileSync(file, buf); } catch (e) {}
const ws = fs.createWriteStream(file, { flags: 'x' }); if (res.writableEnded) return;
ws.on('error', () => {}); res.writeHead(200); res.end(buf);
body.pipe(ws); body.pipe(res);
}) })
.catch(() => fail(502)); .catch(() => fail(502));
return; return;

View File

@ -46,6 +46,21 @@ button { font: inherit; }
.brand { font-weight: 800; letter-spacing: .12em; font-size: 13px; color: var(--accent); } .brand { font-weight: 800; letter-spacing: .12em; font-size: 13px; color: var(--accent); }
.crumb { font-size: 13px; font-weight: 600; margin-left: 12px; } .crumb { font-size: 13px; font-weight: 600; margin-left: 12px; }
.crumb.sub { color: var(--ink2); font-weight: 500; } .crumb.sub { color: var(--ink2); font-weight: 500; }
/* trip picker (top-level trip selection) */
.trip-picker { position: relative; }
#crumb { border: none; background: transparent; font: inherit; color: inherit; cursor: pointer;
padding: 4px 8px; border-radius: 8px; display: flex; align-items: center; gap: 6px; max-width: 320px; }
#crumb:hover { background: #f2f5f8; }
#crumb .caret { font-size: 9px; color: var(--ink2); }
#crumb .t-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tripmenu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 60; width: 300px;
background: var(--card); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); padding: 6px; }
.trip-item { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%;
border: 0; background: none; padding: 8px 10px; border-radius: 8px; cursor: pointer; text-align: left; font: inherit; color: inherit; }
.trip-item:hover { background: #f2f5f8; }
.trip-item.cur { background: #eef4ff; }
.trip-item .ti-name { font-weight: 600; font-size: 13px; }
.trip-item .ti-sub { font-size: 11px; color: var(--ink2); }
.tb-left { display: flex; align-items: center; min-width: 0; } .tb-left { display: flex; align-items: center; min-width: 0; }
.tb-right { display: flex; align-items: center; gap: 10px; } .tb-right { display: flex; align-items: center; gap: 10px; }
.badge { font-size: 12.5px; background: #fdf3e3; color: #8a6116; border: 1px solid #f0dcae; padding: 5px 10px; border-radius: 999px; cursor: default; } .badge { font-size: 12.5px; background: #fdf3e3; color: #8a6116; border: 1px solid #f0dcae; padding: 5px 10px; border-radius: 999px; cursor: default; }
@ -359,7 +374,7 @@ button { font: inherit; }
/* topbar width relief: drop the verbose badges first */ /* topbar width relief: drop the verbose badges first */
@media (max-width: 1560px) { #offline-badge { display: none; } } @media (max-width: 1560px) { #offline-badge { display: none; } }
@media (max-width: 1320px) { #nudge-badge { display: none; } .crumb { display: none; } } @media (max-width: 1320px) { #nudge-badge { display: none; } #crumb { max-width: 180px; } }
/* ---------------- landing demo chips ---------------- */ /* ---------------- landing demo chips ---------------- */
.land-demo { display: flex; align-items: center; gap: 8px; margin-top: 16px; font-size: 12px; color: var(--ink2); flex-wrap: wrap; } .land-demo { display: flex; align-items: center; gap: 8px; margin-top: 16px; font-size: 12px; color: var(--ink2); flex-wrap: wrap; }

View File

@ -138,6 +138,30 @@ arrival-time math must use one backend consistently per trip and say
which; (c) "computed" provenance should carry the profile name, which; (c) "computed" provenance should carry the profile name,
because two computed numbers for the same leg can differ by 2025%. because two computed numbers for the same leg can differ by 2025%.
### Second dataset: Colombia walking router (`:5003`) — verified
`scripts/setup-osrm-colombia.sh` builds a second OSRM instance over the
**official `foot.lua` profile** (real walking speeds, ~5 km/h) for the
Colombia trip (Cartagena + Santa Marta boxes).
The country PBF (329 MB) is too big for `osmium extract -s complete_ways`
on this box (~2 GB free RAM), so `crop_pbf.py` streams it in three passes
and keeps only the two city boxes (~4.6 MB, 565k nodes / 118k ways, ~3 min).
Verified: Cartagena Getsemaní → Castillo San Felipe routes 7.98 km / 96 min
(≈ 5 km/h). `mock/server.js` maps `?router=colombia` to `:5003` and
`?router=northeast` (default) to `:5000`, so each trip in the mock is
routed on its own extract.
Notes:
- The cropper **drops relations** (the foot profile ignores turn
restrictions) and node metadata; ways touching a box are kept whole.
- OSRM v26 returns `Ok` with distance 0 for out-of-coverage point pairs
(not `NoTable`); the mock treats a zero-distance answer as "adjacent
stops" and a no-route answer as fallback-to-estimate.
- `crop_pbf.py` is a from-scratch PBF reader/writer (no PBF library
available); framing = `[uint32 BE BlobHeader len][BlobHeader][Blob]`,
validated against osmium's own test fixtures and `osrm-extract`.
## Live traffic (511NY → OSRM segment speeds) — verified ## Live traffic (511NY → OSRM segment speeds) — verified
OSRM v26 has a first-class mechanism for this: `osrm-customize OSRM v26 has a first-class mechanism for this: `osrm-customize

373
router/scripts/crop_pbf.py Normal file
View File

@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""Stream a large OSM PBF and write a cropped copy for one or more bboxes.
Unlike `osmium extract -s complete_ways` this never holds more than one
region's worth of objects in memory (a few sequential streaming passes), so
it works on a full-country PBF on a RAM-constrained machine.
File format (osmium / OSM-binary):
[uint32 BE: BlobHeader length][BlobHeader][Blob: datasize bytes]
BlobHeader { string type=1; bytes indexdata=2; int32 datasize=3 }
Blob { bytes raw=1; int32 raw_size=2; bytes zlib_data=3; bytes zstd_data=7 }
the data field holds a PrimitiveBlock message.
Coordinate encoding: deg = (value + offset) * granularity / 1e9
(default granularity 100, offsets 0 => value / 1e7).
Usage: crop_pbf.py INPUT.pbf OUTPUT.pbf MINLON MINLAT MAXLON MAXLAT [BBOX2 ...]
Ways touching a box are kept whole; their nodes are kept even slightly
outside the box. Relations and node metadata are dropped (the foot profile
ignores turn restrictions).
"""
import sys, zlib, struct
# ---------------- varint / zigzag ----------------
def read_varint(b, i):
r = 0; s = 0
while True:
x = b[i]; i += 1
r |= (x & 0x7f) << s
if not x & 0x80: return r, i
s += 7
def read_sint(b, i):
v, i = read_varint(b, i)
return (~v >> 1) if v & 1 else (v >> 1), i
def packed_sint(b, i, end):
out = []
while i < end:
v, i = read_varint(b, i)
out.append((~v >> 1) if v & 1 else (v >> 1))
return out, i
def packed_uint(b, i, end):
out = []
while i < end:
v, i = read_varint(b, i)
out.append(v)
return out, i
def skip_field(b, i, end, wt, num=None):
if wt == 0:
_, i = read_varint(b, i)
elif wt == 1:
i += 4
elif wt == 2:
l, i = read_varint(b, i); i += l
elif wt == 3:
i += 8
elif wt == 4: # deprecated start-group
while i < end:
f, i = read_varint(b, i)
if (f & 7) == 6 and (f >> 3) == num:
break
i = skip_field(b, i, end, f & 7, f >> 3)
elif wt == 5:
i += 8
elif wt == 6:
pass # end-group
else:
raise ValueError('wire type %d' % wt)
return i
# ---------------- writers ----------------
def varint(n):
out = bytearray()
while True:
x = n & 0x7f; n >>= 7
out.append(x | (0x80 if n else 0))
if not n: return bytes(out)
def zig(n):
return (n << 1) ^ (n >> 63)
def tag(num, wt=0):
return varint((num << 3) | wt)
def f_varint(num, v):
return tag(num) + varint(v)
def f_sint(num, v):
return tag(num) + varint(zig(v))
def f_msg(num, payload):
return tag(num, 2) + varint(len(payload)) + payload
def f_packed_sint(num, vals):
body = b''.join(varint(zig(v)) for v in vals)
return f_msg(num, body)
def f_packed_uint(num, vals):
body = b''.join(varint(v) for v in vals)
return f_msg(num, body)
# ---------------- PrimitiveBlock parsing ----------------
def parse_block(payload):
"""Return list of (group, strings, gran, lato, lono).
group = {'nodes': [(id, lat, lon)], 'dense': [(id, lat, lon)], 'ways': [(id, refs, keys, vals)]}
node coordinates are returned in raw PBF units (not yet decoded to degrees)."""
i = 0; n = len(payload)
strings = ['']
gran = 100; lato = 0; lono = 0
out = []
while i < n:
f, i = read_varint(payload, i); num = f >> 3; wt = f & 7
if num == 1 and wt == 2: # stringtable
l, i = read_varint(payload, i)
j = i; end = i + l; s = []
while j < end:
fj, j = read_varint(payload, j); fn = fj >> 3; fw = fj & 7
if fn == 1 and fw == 2:
sl, j = read_varint(payload, j)
s.append(payload[j:j+sl].decode('utf-8', 'replace')); j += sl
else:
j = skip_field(payload, j, end, fw)
strings = s # the table already starts with the reserved '' entry
i = end
elif num in (17, 19, 20) and wt == 0:
v, i = read_varint(payload, i)
if num == 17: gran = v
elif num == 19: lato = v
else: lono = v
elif num == 2 and wt == 2: # primitivegroup
l, i = read_varint(payload, i)
j = i; end = i + l
group = {'nodes': [], 'dense': [], 'ways': []}
while j < end:
gj, j = read_varint(payload, j); gn = gj >> 3; gw = gj & 7
if gn == 1 and gw == 2: # Node
nl, j = read_varint(payload, j); ne = j + nl
nid = 0; la = 0; lo = 0
while j < ne:
k, j = read_varint(payload, j); kn = k >> 3; kw = k & 7
if kn == 1 and kw == 0:
nid, j = read_sint(payload, j)
elif kn in (8, 9) and kw == 0:
v, j = read_sint(payload, j)
if kn == 8: la = v
else: lo = v
else:
j = skip_field(payload, j, ne, kw)
group['nodes'].append((nid, la, lo))
elif gn == 2 and gw == 2: # DenseNodes
dl, j = read_varint(payload, j); de = j + dl
ids = lats = lons = None
while j < de:
k, j = read_varint(payload, j); kn = k >> 3; kw = k & 7
if kn in (1, 8, 9) and kw == 2:
kl, j = read_varint(payload, j)
arr, j = packed_sint(payload, j, j + kl)
if kn == 1: ids = arr
elif kn == 8: lats = arr
else: lons = arr
else:
j = skip_field(payload, j, de, kw)
if ids:
cols = []
for arr in (ids, lats, lons):
dec = []; pid = 0
for v in arr:
pid += v; dec.append(pid)
cols.append(dec)
group['dense'].extend(zip(cols[0], cols[1], cols[2]))
elif gn == 3 and gw == 2: # Way
wl, j = read_varint(payload, j); we = j + wl
wid = 0; refs = keys = vals = None
while j < we:
k, j = read_varint(payload, j); kn = k >> 3; kw = k & 7
if kn == 1 and kw == 0:
wid, j = read_varint(payload, j)
elif kn == 2 and kw == 2:
kl, j = read_varint(payload, j); keys, j = packed_uint(payload, j, j + kl)
elif kn == 3 and kw == 2:
kl, j = read_varint(payload, j); vals, j = packed_uint(payload, j, j + kl)
elif kn == 8 and kw == 2:
kl, j = read_varint(payload, j); r, j = packed_sint(payload, j, j + kl)
dec = []; pid = 0
for v in r:
pid += v; dec.append(pid)
refs = dec
else:
j = skip_field(payload, j, we, kw)
if refs:
group['ways'].append((wid, refs, keys or [], vals or []))
else:
j = skip_field(payload, j, end, gw)
i = end
out.append((group, strings, gran, lato, lono))
else:
i = skip_field(payload, i, n, wt)
return out
# ---------------- file-level ----------------
def extract_blob_payload(blob):
"""Blob { raw=1; raw_size=2; zlib_data=3; zstd_data=7 } -> message bytes"""
i = 0; n = len(blob)
while i < n:
f, i = read_varint(blob, i); num = f >> 3; wt = f & 7
if num == 1 and wt == 2:
l, i = read_varint(blob, i)
return blob[i:i+l]
elif num == 3 and wt == 2:
l, i = read_varint(blob, i)
return zlib.decompress(blob[i:i+l])
elif num == 7 and wt == 2:
l, i = read_varint(blob, i)
try:
import zstandard
return zstandard.ZstdDecompressor().decompress(blob[i:i+l])
except Exception:
return None
else:
i = skip_field(blob, i, n, wt)
return None
def iter_blocks(data):
"""yield (group, strings, gran, lato, lono) for each OSMData group"""
off = 0
while off < len(data):
(hlen,) = struct.unpack('>I', data[off:off+4]); off += 4
i = off; end = off + hlen
btype = ''; dsz = 0
while i < end:
f, i = read_varint(data, i); num = f >> 3; wt = f & 7
if num == 1 and wt == 2:
l, i = read_varint(data, i); btype = data[i:i+l].decode(); i += l
elif num == 3 and wt == 0:
dsz, i = read_varint(data, i)
else:
i = skip_field(data, i, end, wt)
off += hlen
blob = data[off:off+dsz]; off += dsz
if btype != 'OSMData':
continue
payload = extract_blob_payload(blob)
if payload is None:
continue
for group, strings, gran, lato, lono in parse_block(payload):
yield group, strings, gran, lato, lono
# ---------------- main ----------------
def main():
src, dst = sys.argv[1], sys.argv[2]
boxes = [tuple(float(x) for x in a.split(',')) for a in sys.argv[3:]]
data = open(src, 'rb').read()
print(f'file {len(data)/1e6:.0f} MB, {len(boxes)} box(es)', file=sys.stderr)
def in_any(lat, lon):
return any(minlo <= lon <= maxlo and minla <= lat <= maxla
for minlo, minla, maxlo, maxla in boxes)
# pass 1: node ids inside the boxes
print('pass 1: scanning nodes', file=sys.stderr)
inside = set()
for group, strings, gran, lato, lono in iter_blocks(data):
conv = gran / 1e9
for nid, la, lo in group['nodes']:
if in_any((la + lato) * conv, (lo + lono) * conv):
inside.add(nid)
for nid, la, lo in group['dense']:
if in_any((la + lato) * conv, (lo + lono) * conv):
inside.add(nid)
print(f' {len(inside)} nodes in box(es)', file=sys.stderr)
# pass 2: ways touching the boxes (tags decoded to strings now —
# string indices are only valid inside their own block)
print('pass 2: scanning ways', file=sys.stderr)
ways = []
referenced = set()
for group, strings, gran, lato, lono in iter_blocks(data):
for wid, refs, keys, vals in group['ways']:
if any(r in inside for r in refs):
tags = tuple((strings[k], strings[v]) for k, v in zip(keys, vals))
ways.append((wid, refs, tags))
referenced.update(refs)
print(f' {len(ways)} ways kept, {len(referenced)} referenced nodes', file=sys.stderr)
# pass 3: positions of the kept nodes (raw PBF units)
print('pass 3: collecting kept node positions', file=sys.stderr)
kept = {}
gran, lato, lono = 100, 0, 0
for group, strings, gran_b, lato_b, lono_b in iter_blocks(data):
gran, lato, lono = gran_b, lato_b, lono_b
for nid, la, lo in group['nodes']:
if nid in referenced: kept[nid] = (la, lo)
for nid, la, lo in group['dense']:
if nid in referenced: kept[nid] = (la, lo)
print(f' {len(kept)} positions (gran {gran}, offset {lato}/{lono})', file=sys.stderr)
if len(kept) != len(referenced):
print(f' warning: {len(referenced) - len(kept)} referenced nodes not found', file=sys.stderr)
# build a fresh string table from the strings actually used
used = set()
for _, _, tags in ways:
for k, v in tags:
used.add(k); used.add(v)
strings_out = [''] + sorted(used)
stridx = {t: i for i, t in enumerate(strings_out)}
print(f' string table: {len(strings_out)} strings', file=sys.stderr)
# ---- write output ----
out = bytearray()
def fileblock(btype, payload):
nonlocal out
body = f_varint(2, len(payload)) + f_msg(3, zlib.compress(payload))
hdr = f_msg(1, btype.encode()) + f_varint(3, len(body))
out += struct.pack('>I', len(hdr)) + hdr + body
# header block with combined bbox (nanodegrees)
minlo = min(b[0] for b in boxes); minla = min(b[1] for b in boxes)
maxlo = max(b[2] for b in boxes); maxla = max(b[3] for b in boxes)
hdr = f_msg(1, f_sint(1, int(minlo * 1e9)) + f_sint(2, int(maxlo * 1e9))
+ f_sint(3, int(maxla * 1e9)) + f_sint(4, int(minla * 1e9)))
fileblock('OSMHeader', hdr)
def string_table_msg():
return f_msg(1, b''.join(f_msg(1, s.encode()) for s in strings_out))
# nodes as dense blocks (columns delta-coded)
ids_sorted = sorted(kept)
BLOCK = 2_000_000
for b0 in range(0, len(ids_sorted), BLOCK):
chunk = ids_sorted[b0:b0+BLOCK]
d_ids = []; pid = 0
for nid in chunk:
d_ids.append(nid - pid); pid = nid
d_la = []; pid = 0
for nid in chunk:
la, lo = kept[nid]; d_la.append(la - pid); pid = la
d_lo = []; pid = 0
for nid in chunk:
la, lo = kept[nid]; d_lo.append(lo - pid); pid = lo
dense = f_packed_sint(1, d_ids) + f_packed_sint(8, d_la) + f_packed_sint(9, d_lo)
pb = string_table_msg() + f_msg(2, f_msg(2, dense)) + f_varint(17, 100)
fileblock('OSMData', pb)
# ways blocks: chunk of 50k ways per PrimitiveGroup
CHUNK = 50_000
def way_msg(wid, refs, tags):
d = []; pid = 0
for r in refs:
d.append(r - pid); pid = r
w = f_varint(1, wid)
if tags:
w += f_packed_uint(2, [stridx[k] for k, v in tags])
w += f_packed_uint(3, [stridx[v] for k, v in tags])
w += f_packed_sint(8, d)
return w
for w0 in range(0, len(ways), CHUNK):
chunk = ways[w0:w0+CHUNK]
body = string_table_msg()
body += f_msg(2, b''.join(f_msg(3, way_msg(*w)) for w in chunk))
body += f_varint(17, 100)
fileblock('OSMData', body)
open(dst, 'wb').write(bytes(out))
print(f'wrote {dst}: {len(ids_sorted)} nodes, {len(ways)} ways, {len(out)/1e6:.1f} MB', file=sys.stderr)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Build + serve the COLOMBIA walking router (Cartagena + Santa Marta boxes) on :5003.
#
# The full-country PBF (329 MB) is too big for `osmium extract -s complete_ways`
# on this box (~2 GB free RAM), so crop_pbf.py streams it in 3 passes and keeps
# only the two city boxes (~4.6 MB). The dataset is built with the official
# foot.lua profile (real walking speeds, ~5 km/h).
#
# Prereq: scripts/setup-osrm.sh has run once (OSRM binaries + osmium exist in
# $HOME/osm-build). The Colombia PBF is downloaded from geofabrik if missing.
#
# Usage: scripts/setup-osrm-colombia.sh # build + serve :5003
# scripts/setup-osrm-colombia.sh --no-serve # build only
set -euo pipefail
cd "$(dirname "$0")"
W="${W:-$HOME/osm-build}"
OSM_DIR="$(cd ../.. && pwd)/osm"
DATA_DIR="$W/data/colombia"
OSRM_BIN="$W/build"
OSRM_SRC="$W/osrm-backend-26.4.1"
SERVE=1
[ "${1:-}" = "--no-serve" ] && SERVE=0
export LD_LIBRARY_PATH="$W/prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
mkdir -p "$DATA_DIR"
PBF="$OSM_DIR/colombia.osm.pbf"
echo "=== PBF"
if [ ! -s "$PBF" ]; then
echo "downloading colombia-latest.osm.pbf from geofabrik (~330 MB)..."
curl -sL -o "$PBF" https://download.geofabrik.de/south-america/colombia-latest.osm.pbf
fi
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)
CARTAGENA="-75.66,10.26,-75.36,10.56"
SAMARTA="-74.36,10.84,-74.02,11.22"
python3 crop_pbf.py "$PBF" /tmp/colombia-trip.pbf "$CARTAGENA" "$SAMARTA"
echo "=== extract (foot.lua) + partition + customize"
rm -f "$DATA_DIR"/colombia-trip.osrm.*
"$OSRM_BIN/osrm-extract" /tmp/colombia-trip.pbf \
-p "$OSRM_SRC/profiles/foot.lua" -o "$DATA_DIR/colombia-trip"
"$OSRM_BIN/osrm-partition" "$DATA_DIR/colombia-trip"
"$OSRM_BIN/osrm-customize" "$DATA_DIR/colombia-trip"
if [ "$SERVE" = 0 ]; then
echo "done (not serving; start with: $OSRM_BIN/osrm-routed --algorithm mld --port 5003 $DATA_DIR/colombia-trip.osrm)"
exit 0
fi
echo "=== serve on :5003 (Ctrl-C to stop)"
exec "$OSRM_BIN/osrm-routed" --algorithm mld --port 5003 "$DATA_DIR/colombia-trip.osrm"