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
+84 -34
View File
@@ -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: `<span class="km-label">${fmtKm(km)} km</span>`,
html: `<span class="km-label">${fmtKm(km)} km${road ? ' · road' : ''}</span>`,
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 <span>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) {