diff --git a/README.md b/README.md index 3aff76b..de1e3db 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,11 @@ npm test # API tests (node:test + supertest) | `PORT` | `3000` | HTTP port the server listens on. | | `DATA_DIR` | `./data` (`/app/data` in Docker) | Directory holding the SQLite database file. | | `SESSION_SECRET` | dev value (warns) | Secret used to sign session cookies. **Set this in production.** | +| `OSRM_URL` | `https://router.project-osrm.org` | Base URL for the driving-directions proxy (`/api/directions`). The default is the public OSRM demo server — fine for light personal use. Self-hosters can point this at their own OSRM instance. | ## Data -All state lives in a single SQLite file under `DATA_DIR`. In Docker this is `/app/data`, bind-mounted from `./data` next to the compose file — back up that directory to back up all trips. No external database or services are required (geocoding calls OpenStreetMap's Nominatim, which needs no API key; airport lookups use a bundled offline dataset). +All state lives in a single SQLite file under `DATA_DIR`. In Docker this is `/app/data`, bind-mounted from `./data` next to the compose file — back up that directory to back up all trips. No external database or services are required (geocoding calls OpenStreetMap's Nominatim, which needs no API key; airport lookups use a bundled offline dataset; road directions call the public OSRM demo server, also no API key). ## Documentation diff --git a/docs/API.md b/docs/API.md index 16c2c4b..06f7233 100644 --- a/docs/API.md +++ b/docs/API.md @@ -243,6 +243,17 @@ Computation (see Cost semantics above): Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=…` server-side with header `User-Agent: trip-plan-app/0.1 (self-hosted)` (`accept-language=en` so results come back in Latin script, not the local language). Map Nominatim's `display_name`→`name`, parse lat/lon to numbers. On upstream failure return `502 {"error":"geocoding unavailable"}`. Cache identical queries in-memory for 10 minutes. +### Directions proxy (OSRM) + +`GET /api/directions?from=,&to=,` → `200 {km, geometry}` (requires auth). + +- Proxies `${OSRM_URL}/route/v1/driving/{fromLng},{fromLat};{toLng},{toLat}?overview=full&geometries=geojson` server-side. `OSRM_URL` env var, default `https://router.project-osrm.org` (the public demo server — fine for light personal use; self-hosters can point it at their own OSRM). Send the same `User-Agent` header as the geocode proxy. +- Validation: `from`/`to` must each be `lat,lng` with finite numbers in range (lat [-90,90], lng [-180,180]) → else `400`. Upstream failure, non-Ok OSRM code, or no route → `502 {"error":"directions unavailable"}`. +- Response `km` = route distance / 1000 rounded to 1 decimal; `geometry` = the GeoJSON coordinates converted to `[[lat,lng], …]` (Leaflet order). +- In-memory cache: key = both coord pairs rounded to 5 decimals, TTL 24 h, cap ~500 entries (evict oldest). + +**Consumer contract (map)**: the frontend requests directions for each `mode: "ground"` route leg and, on success, draws the road-following polyline instead of the straight line and shows the routed km for that leg ("via road"); on 502/failure it keeps the straight great-circle line silently. Air legs stay straight/dashed. `/route` itself (and `summary.kmDriven`) remains great-circle — the server does not call OSRM during route computation. + ## Frontend contract notes - SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed. diff --git a/public/js/api.js b/public/js/api.js index 4700464..fcc52b1 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -73,6 +73,7 @@ export const api = { costs: (id) => get(`/api/trips/${id}/costs`), regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}), }, + directions: (from, to) => get(`/api/directions?from=${from.lat},${from.lng}&to=${to.lat},${to.lng}`), entries: { create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload), update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody), diff --git a/public/js/views/map.js b/public/js/views/map.js index d85587f..76ce1e7 100644 --- a/public/js/views/map.js +++ b/public/js/views/map.js @@ -3,6 +3,7 @@ // trip has no located entries yet. Uses the /route response from tctx.route. import { el } from '../dom.js'; import { typeInfo, entryIcon } from '../format.js'; +import { api } from '../api.js'; export function renderMap(tctx) { const route = tctx.route || { stops: [], legs: [], totalKm: 0 }; @@ -35,16 +36,38 @@ export function renderMap(tctx) { const mapDiv = el('div', { class: 'leaflet-map', id: `map-${tctx.tripId}` }); section.appendChild(mapDiv); - section.appendChild(legList(route, stops)); + + // Legs and the "Legs" list below share the same filtered (leg, fromStop, + // toStop) triples so the map's polylines/labels and the list rows stay in + // lockstep by index. + const legs = visibleLegs(route, stops); + const { wrap: legListEl, kmEls } = legList(legs); + section.appendChild(legListEl); // Leaflet needs the container attached with a real size, so init on the // next tick after this section is mounted into the page. - setTimeout(() => initMap(mapDiv, route, stops), 0); + setTimeout(() => initMap(mapDiv, stops, legs, kmEls), 0); return section; } -function initMap(mapDiv, route, stops) { +// Consecutive non-coincident stop pairs (skip <0.05km, the server's rule), +// paired with their matching /route leg. +function visibleLegs(route, stops) { + const legs = route.legs || []; + const result = []; + let li = 0; + for (let i = 0; i < stops.length - 1 && li < legs.length; i++) { + const a = stops[i]; + const b = stops[i + 1]; + if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue; + result.push({ leg: legs[li], a, b }); + li += 1; + } + return result; +} + +function initMap(mapDiv, stops, legs, kmEls) { const L = window.L; if (!L) { mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.')); @@ -69,26 +92,20 @@ function initMap(mapDiv, route, stops) { }); // One polyline per measured leg, styled by mode (air = dashed blue, ground = - // solid teal), each with a km label at its midpoint. Legs are matched to - // consecutive non-coincident stop pairs (skip <0.05km, the server's rule). - const legs = route.legs || []; - let li = 0; - for (let i = 0; i < stops.length - 1 && li < legs.length; i++) { - const a = points[i]; - const b = points[i + 1]; - if (haversineKm(a[0], a[1], b[0], b[1]) < 0.05) continue; - const leg = legs[li]; - li += 1; + // solid teal), each with a km label at its midpoint (straight great-circle + // line to start — the map is never empty while road geometry loads). + const legLayers = legs.map(({ leg, a, b }) => { const air = leg.mode === 'air'; - L.polyline([a, b], { + const line = L.polyline([[a.lat, a.lng], [b.lat, b.lng]], { color: air ? '#2563eb' : '#0f766e', weight: 3, opacity: 0.75, dashArray: air ? '6 6' : null, }).addTo(map); - const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; - L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map); - } + const mid = [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2]; + const label = L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map); + return { leg, a, b, line, label }; + }); if (points.length === 1) { map.setView(points[0], 10); @@ -96,6 +113,36 @@ function initMap(mapDiv, route, stops) { map.fitBounds(L.latLngBounds(points).pad(0.2)); } map.invalidateSize(); + + fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls); +} + +// Async road-following geometry for ground legs, per the /api/directions +// "Consumer contract (map)" in docs/API.md. Straight great-circle lines are +// already drawn, so a slow/failed fetch just leaves those in place — no +// error UI. Each leg fetches (and fails) independently. +function fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls) { + legLayers.forEach(({ leg, a, b, line, label }, i) => { + if (leg.mode !== 'ground') return; + api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng }) + .then((res) => { + // The trip view may have been re-rendered (refreshTrip) while this + // was in flight — the old map/section is detached from the DOM and + // its layers must not be touched. + if (!mapDiv.isConnected || !map.hasLayer(line) || !map.hasLayer(label)) return; + line.setLatLngs(res.geometry); + label.setLatLng(pathMidpoint(res.geometry) || label.getLatLng()); + label.setIcon(kmLabel(L, res.km, true)); + const kmEl = kmEls[i]; + if (kmEl) kmEl.textContent = `${fmtKm(res.km)} km · road`; + }) + .catch(() => {}); + }); +} + +function pathMidpoint(coords) { + if (!coords || !coords.length) return null; + return coords[Math.floor(coords.length / 2)]; } // Great-circle km — mirrors the server rule for skipping zero-distance legs. @@ -119,10 +166,10 @@ function numberedIcon(L, n, color) { }); } -function kmLabel(L, km) { +function kmLabel(L, km, road) { return L.divIcon({ className: 'km-label-wrap', - html: `${fmtKm(km)} km`, + html: `${fmtKm(km)} km${road ? ' · road' : ''}`, iconSize: [0, 0], }); } @@ -149,32 +196,35 @@ function stopLabel(stop) { return stop.location_name || stop.title || '?'; } -function legList(route, stops) { - const legs = route.legs || []; - if (!legs.length) return el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.'); +// legs: [{ leg, a, b }, …] from visibleLegs — same order/filtering the map +// polylines use. Returns the list element plus the per-row km s (by +// the same index) so fetchRoadGeometry can update them once routed km lands. +function legList(legs) { + if (!legs.length) { + return { + wrap: el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.'), + kmEls: [], + }; + } const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs')); - let li = 0; - let shown = 0; - for (let i = 0; i < stops.length - 1 && li < legs.length; i++) { - const a = stops[i]; - const b = stops[i + 1]; - if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue; - const leg = legs[li++]; - shown += 1; + const kmEls = []; + legs.forEach(({ leg, a, b }, i) => { const air = leg.mode === 'air'; + const kmEl = el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`); + kmEls.push(kmEl); wrap.appendChild( el( 'div', { class: 'leg-row' }, - el('span', { class: 'leg-index' }, String(shown)), + el('span', { class: 'leg-index' }, String(i + 1)), el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'), el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)), - el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`), + kmEl, ), ); - } - return wrap; + }); + return { wrap, kmEls }; } function fmtKm(n) { diff --git a/src/server/app.js b/src/server/app.js index 887b441..1cb90aa 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -9,6 +9,7 @@ import tripsRoutes from './routes/trips.js'; import entriesRoutes from './routes/entries.js'; import geocodeRoutes from './routes/geocode.js'; import airportsRoutes from './routes/airports.js'; +import directionsRoutes from './routes/directions.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public'); @@ -51,6 +52,7 @@ export function createApp(options = {}) { app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id app.use('/api/geocode', requireAuth, geocodeRoutes(db)); app.use('/api/airports', requireAuth, airportsRoutes()); + app.use('/api/directions', requireAuth, directionsRoutes()); // JSON 404 for any unmatched /api route. app.use('/api', (req, res) => { diff --git a/src/server/routes/directions.js b/src/server/routes/directions.js new file mode 100644 index 0000000..d72c0c4 --- /dev/null +++ b/src/server/routes/directions.js @@ -0,0 +1,75 @@ +import express from 'express'; + +const USER_AGENT = 'trip-plan-app/0.1 (self-hosted)'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const MAX_CACHE_ENTRIES = 500; + +// Parses a "lat,lng" query param into {lat, lng}, or null if malformed/out of range. +function parseLatLng(value) { + if (typeof value !== 'string') return null; + const parts = value.split(','); + if (parts.length !== 2) return null; + const lat = Number(parts[0]); + const lng = Number(parts[1]); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return { lat, lng }; +} + +function round1(n) { + return Math.round(n * 10) / 10; +} + +export default function directionsRoutes() { + const router = express.Router(); + const cache = new Map(); // "lat,lng;lat,lng" (rounded) -> { at, data } + + // GET /api/directions?from=lat,lng&to=lat,lng + router.get('/', async (req, res) => { + const from = parseLatLng(req.query.from); + const to = parseLatLng(req.query.to); + if (!from || !to) { + return res.status(400).json({ error: 'from and to must be lat,lng' }); + } + + const osrmUrl = process.env.OSRM_URL || 'https://router.project-osrm.org'; + const key = `${from.lat.toFixed(5)},${from.lng.toFixed(5)};${to.lat.toFixed(5)},${to.lng.toFixed(5)}`; + + const cached = cache.get(key); + if (cached && Date.now() - cached.at < CACHE_TTL_MS) { + return res.status(200).json(cached.data); + } + + const url = `${osrmUrl}/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`; + let data; + try { + const upstream = await fetch(url, { + headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, + }); + if (!upstream.ok) throw new Error(`upstream status ${upstream.status}`); + data = await upstream.json(); + } catch { + return res.status(502).json({ error: 'directions unavailable' }); + } + + const route = data && data.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null; + if (!route) { + return res.status(502).json({ error: 'directions unavailable' }); + } + + const result = { + km: round1(route.distance / 1000), + geometry: route.geometry.coordinates.map(([lng, lat]) => [lat, lng]), + }; + + if (cache.size >= MAX_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + cache.delete(oldestKey); + } + cache.set(key, { at: Date.now(), data: result }); + + res.status(200).json(result); + }); + + return router; +} diff --git a/tests/api.test.js b/tests/api.test.js index 5093815..f794e75 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -514,6 +514,99 @@ test('geocode requires auth and a query', async () => { assert.equal(noQuery.status, 400); }); +// --------------------------------------------------------------------------- +// Directions (mocked upstream — never hits the real OSRM) +// --------------------------------------------------------------------------- + +test('directions proxies and caches results (mocked fetch)', async () => { + const { agent } = await createAccount(); + const original = global.fetch; + let calls = 0; + global.fetch = async () => { + calls += 1; + return { + ok: true, + json: async () => ({ + code: 'Ok', + routes: [ + { + distance: 12345.6, + geometry: { + coordinates: [ + [98.9853, 18.7883], + [99.0, 18.8], + ], + }, + }, + ], + }), + }; + }; + try { + const first = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0'); + assert.equal(first.status, 200); + assert.equal(first.body.km, 12.3); + assert.deepEqual(first.body.geometry, [ + [18.7883, 98.9853], + [18.8, 99.0], + ]); + + // Second identical request is served from cache -> fetch not called again. + const second = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0'); + assert.equal(second.status, 200); + assert.equal(calls, 1); + } finally { + global.fetch = original; + } +}); + +test('directions validates from/to params', async () => { + const { agent } = await createAccount(); + + const missing = await agent.get('/api/directions?from=18.7883,98.9853'); + assert.equal(missing.status, 400); + assert.deepEqual(missing.body, { error: 'from and to must be lat,lng' }); + + const malformed = await agent.get('/api/directions?from=abc&to=18.8,99.0'); + assert.equal(malformed.status, 400); + + const outOfRange = await agent.get('/api/directions?from=999,98.9853&to=18.8,99.0'); + assert.equal(outOfRange.status, 400); +}); + +test('directions returns 502 on non-Ok OSRM code and on fetch failure', async () => { + const { agent } = await createAccount(); + const original = global.fetch; + + global.fetch = async () => ({ + ok: true, + json: async () => ({ code: 'NoRoute', routes: [] }), + }); + try { + const noRoute = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0'); + assert.equal(noRoute.status, 502); + assert.deepEqual(noRoute.body, { error: 'directions unavailable' }); + } finally { + global.fetch = original; + } + + global.fetch = async () => { + throw new Error('network down'); + }; + try { + const res = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0'); + assert.equal(res.status, 502); + assert.deepEqual(res.body, { error: 'directions unavailable' }); + } finally { + global.fetch = original; + } +}); + +test('directions requires auth', async () => { + const noAuth = await request(app).get('/api/directions?from=18.7883,98.9853&to=18.8,99.0'); + assert.equal(noAuth.status, 401); +}); + // --------------------------------------------------------------------------- // Unknown /api route -> JSON 404 // ---------------------------------------------------------------------------