Route ground legs along real roads via an OSRM directions proxy

- GET /api/directions proxies OSRM (OSRM_URL env, default public demo
  server) with validation, Leaflet-order geometry, 24h capped cache
- Map draws straight lines first, then upgrades ground legs to
  road-following polylines with routed km (marked road) as directions
  arrive; silent fallback to great-circle on failure; air legs unchanged
- README documents OSRM_URL
This commit is contained in:
2026-07-20 10:54:04 +07:00
parent 65480fd892
commit 4cedab0cf6
7 changed files with 268 additions and 35 deletions
+2
View File
@@ -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) => {
+75
View File
@@ -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;
}