-
…
+
+
+
+
diff --git a/mock/scripts/pre-cache-tiles.sh b/mock/scripts/pre-cache-tiles.sh
new file mode 100755
index 0000000..733724f
--- /dev/null
+++ b/mock/scripts/pre-cache-tiles.sh
@@ -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)"
diff --git a/mock/server.js b/mock/server.js
index 0513495..74c3241 100644
--- a/mock/server.js
+++ b/mock/server.js
@@ -1,15 +1,27 @@
// 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
// tileserver-gl / OSRM later without touching the frontend.
const http = require('http');
const fs = require('fs');
const path = require('path');
-const { Readable } = require('stream');
const PORT = 8077;
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 CACHE = path.join(__dirname, '.tilecache');
fs.mkdirSync(CACHE, { recursive: true });
@@ -38,7 +50,8 @@ http.createServer((req, res) => {
const targets = Array.from({ length: n }, (_, i) => i).join(',');
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 => {
res.setHeader('Access-Control-Allow-Origin', '*');
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)
if (u.pathname === '/router-status') {
- const probe = '-71.06,42.35;-71.07,42.36'; // must span >0 distance for the health check
- fetch(OSRM + '/route/v1/walking/' + probe + '?overview=false').then(r => r.text())
+ const rt = routerFor(u);
+ 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) {}
- res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: ok, osrm: OSRM })); })
- .catch(() => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: false, 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: rt.url })); });
return;
}
@@ -70,15 +83,16 @@ http.createServer((req, res) => {
if (fs.existsSync(file)) { res.writeHead(200); fs.createReadStream(file).pipe(res); return; }
fs.mkdirSync(path.dirname(file), { recursive: true });
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}/` } })
- .then(r => {
+ .then(async r => {
if (!r.ok) { return fail(r.status); }
- const body = Readable.fromWeb(r.body);
- body.on('error', () => fail(502));
- res.writeHead(200);
- const ws = fs.createWriteStream(file, { flags: 'x' });
- ws.on('error', () => {});
- body.pipe(ws); body.pipe(res);
+ const buf = Buffer.from(await r.arrayBuffer());
+ if (buf.length < 20) { return fail(502); } // empty/garbage tile
+ try { fs.writeFileSync(file, buf); } catch (e) {}
+ if (res.writableEnded) return;
+ res.writeHead(200); res.end(buf);
})
.catch(() => fail(502));
return;
diff --git a/mock/styles.css b/mock/styles.css
index 66739ce..c3025b9 100644
--- a/mock/styles.css
+++ b/mock/styles.css
@@ -46,6 +46,21 @@ button { font: inherit; }
.brand { font-weight: 800; letter-spacing: .12em; font-size: 13px; color: var(--accent); }
.crumb { font-size: 13px; font-weight: 600; margin-left: 12px; }
.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-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; }
@@ -359,7 +374,7 @@ button { font: inherit; }
/* topbar width relief: drop the verbose badges first */
@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 ---------------- */
.land-demo { display: flex; align-items: center; gap: 8px; margin-top: 16px; font-size: 12px; color: var(--ink2); flex-wrap: wrap; }
diff --git a/router/README.md b/router/README.md
index 359297b..f679b56 100644
--- a/router/README.md
+++ b/router/README.md
@@ -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,
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
OSRM v26 has a first-class mechanism for this: `osrm-customize
diff --git a/router/scripts/crop_pbf.py b/router/scripts/crop_pbf.py
new file mode 100644
index 0000000..b29b83b
--- /dev/null
+++ b/router/scripts/crop_pbf.py
@@ -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()
diff --git a/router/scripts/setup-osrm-colombia.sh b/router/scripts/setup-osrm-colombia.sh
new file mode 100644
index 0000000..9d62436
--- /dev/null
+++ b/router/scripts/setup-osrm-colombia.sh
@@ -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"