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:
parent
30f3dd9cbf
commit
33ed5d9fe6
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ osm/build/
|
||||||
# Go
|
# Go
|
||||||
router/router
|
router/router
|
||||||
router/bench
|
router/bench
|
||||||
|
router/scripts/__pycache__/
|
||||||
|
|
|
||||||
152
mock/app.js
152
mock/app.js
|
|
@ -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> — you’re 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';
|
|
||||||
});
|
|
||||||
|
|
|
||||||
474
mock/data.js
474
mock/data.js
|
|
@ -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 21–28 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: '21–25 Sep', where: [10.3952, -75.5522], status: 'booked', source: 'user_booking' },
|
||||||
|
{ type: 'hotel', name: 'Hotel Muelle de Bolívar', ref: 'C-20118',
|
||||||
|
dates: '25–28 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 city’s 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 Inquisition’s 16th-century house — the courtyard fountain, the old interrogation rooms and the museum’s 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 town’s 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 town’s 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 city’s 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 fishermen’s 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 city’s 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 Cartagena’s.',
|
||||||
|
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 city’s 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ívar’s 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 park’s 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 neighbourhood’s 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 town’s 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, you’ll 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 · 90–120 min' },
|
||||||
|
{ stop: 'S11', pitch: 'The best single view in Cartagena — be on the terrace 45 min before sunset.',
|
||||||
|
enrich: 'open 09:00–18: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:00–16: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;
|
||||||
|
|
|
||||||
|
|
@ -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
63
mock/scripts/pre-cache-tiles.sh
Executable 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)"
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 20–25%.
|
because two computed numbers for the same leg can differ by 20–25%.
|
||||||
|
|
||||||
|
### 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
373
router/scripts/crop_pbf.py
Normal 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()
|
||||||
55
router/scripts/setup-osrm-colombia.sh
Normal file
55
router/scripts/setup-osrm-colombia.sh
Normal 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"
|
||||||
Loading…
Reference in New Issue
Block a user