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).
111 lines
5.6 KiB
JavaScript
111 lines
5.6 KiB
JavaScript
// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached)
|
|
// + 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 PORT = 8077;
|
|
const ROOT = __dirname;
|
|
// 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 });
|
|
|
|
const mime = {
|
|
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
|
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
};
|
|
|
|
http.createServer((req, res) => {
|
|
const u = new URL(req.url, 'http://x');
|
|
|
|
// ---- routing backend (proxy -> local OSRM) ------------------------
|
|
// /route?mode=foot&points=lng,lat;lng,lat[;...]
|
|
// /table?mode=foot&points=lng,lat;lng,lat[;...]
|
|
if (u.pathname === '/route' || u.pathname === '/table') {
|
|
const kind = u.pathname.slice(1);
|
|
const mode = u.searchParams.get('mode') || 'foot';
|
|
const points = u.searchParams.get('points') || '';
|
|
const profile = PROFILE[mode] || 'driving';
|
|
let osrmPath = `/${kind}/v1/${profile}/${encodeURIComponent(points)}`;
|
|
if (kind === 'route') osrmPath += '?overview=full&alternatives=false&steps=false';
|
|
else {
|
|
// table: durations from the first point (the anchor) to every point
|
|
const n = points.split(';').filter(Boolean).length;
|
|
const targets = Array.from({ length: n }, (_, i) => i).join(',');
|
|
osrmPath += `?annotations=duration,distance&sources=0&targets=${targets}`;
|
|
}
|
|
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');
|
|
if (body == null) { res.writeHead(502); return res.end(JSON.stringify({ code: 'ProxyError' })); }
|
|
res.writeHead(200); res.end(body);
|
|
});
|
|
return;
|
|
}
|
|
// routing availability probe (so the UI can label router vs estimate)
|
|
if (u.pathname === '/router-status') {
|
|
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: 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;
|
|
}
|
|
|
|
// ---- tile backend -------------------------------------------------
|
|
if (u.pathname.startsWith('/tiles/')) {
|
|
const parts = u.pathname.split('/');
|
|
const [z, x, y] = [parts[2], parts[3], (parts[4] || '').replace(/\.png$/, '')];
|
|
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { res.writeHead(400); return res.end(); }
|
|
const file = path.join(CACHE, `${z}/${x}`, `${y}.png`);
|
|
const upstream = `https://tile.openstreetmap.org/${z}/${x}/${y}.png`;
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
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(async r => {
|
|
if (!r.ok) { return fail(r.status); }
|
|
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;
|
|
}
|
|
|
|
// ---- static -------------------------------------------------------
|
|
let p = u.pathname === '/' ? '/index.html' : u.pathname;
|
|
const file = path.join(ROOT, p);
|
|
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
|
|
fs.readFile(file, (e, d) => {
|
|
if (e) { res.writeHead(404); return res.end('not found'); }
|
|
res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' });
|
|
res.end(d);
|
|
});
|
|
}).listen(PORT, '0.0.0.0', () => console.log(`map mock: http://localhost:${PORT} (tiles: /tiles/{z}/{x}/{y}.png)`));
|