diff --git a/src/routing-osrm-instructions.ts b/src/routing-osrm-instructions.ts new file mode 100644 index 0000000..4a1bc40 --- /dev/null +++ b/src/routing-osrm-instructions.ts @@ -0,0 +1,165 @@ +// Translate OSRM maneuvers into: +// 1. A Valhalla-compatible numeric `type` so guidance.ts's icon picker +// doesn't need provider-specific code paths. +// 2. A short English instruction string. OSRM doesn't generate prose — +// consumers are expected to assemble it from `type` + `modifier` + `name`. +// +// Localization is out of scope (issue #189). + +/** Subset of OSRM maneuver types we explicitly recognise. */ +export type OsrmManeuverType = + | 'turn' + | 'new name' + | 'depart' + | 'arrive' + | 'merge' + | 'on ramp' + | 'off ramp' + | 'fork' + | 'end of road' + | 'continue' + | 'roundabout' + | 'rotary' + | 'roundabout turn' + | 'notification' + | 'use lane'; + +/** OSRM turn-modifier strings. */ +export type OsrmModifier = + | 'uturn' + | 'sharp right' + | 'right' + | 'slight right' + | 'straight' + | 'slight left' + | 'left' + | 'sharp left'; + +/** + * Map an OSRM maneuver onto a Valhalla maneuver-type number. + * + * Valhalla numbering reference (kept in sync with guidance.ts's icon table): + * 1=start, 4=destination, 8=continue, 9=slight_right, 10=right, + * 11=sharp_right, 12=u_turn_right, 13=u_turn_left, 14=sharp_left, + * 15=left, 16=slight_left, 17=ramp_straight, 18=ramp_right, + * 19=ramp_left, 20=exit_right, 21=exit_left, 22=stay_straight, + * 25=merge, 26=roundabout_enter. + */ +export function osrmManeuverToValhallaType(type: string, modifier?: string): number { + switch (type) { + case 'depart': + return 1; + case 'arrive': + return 4; + case 'turn': + case 'end of road': + return turnModifierToValhalla(modifier); + case 'continue': + case 'new name': + case 'notification': + return modifier === undefined || modifier === 'straight' + ? 8 + : turnModifierToValhalla(modifier); + case 'merge': + return 25; + case 'on ramp': + return modifier === 'left' ? 19 : 18; + case 'off ramp': + return modifier === 'left' ? 21 : 20; + case 'fork': + if (modifier === 'left' || modifier === 'slight left') return 24; + if (modifier === 'right' || modifier === 'slight right') return 23; + return 22; + case 'roundabout': + case 'rotary': + case 'roundabout turn': + return 26; + case 'use lane': + return 8; + default: + return 0; + } +} + +function turnModifierToValhalla(modifier?: string): number { + switch (modifier) { + case 'uturn': return 12; + case 'sharp right': return 11; + case 'right': return 10; + case 'slight right':return 9; + case 'slight left': return 16; + case 'left': return 15; + case 'sharp left': return 14; + case 'straight': return 8; + default: return 8; + } +} + +/** + * Synthesize a short English instruction for an OSRM maneuver. The `name` + * argument is the OSRM step's `name` (street name) — empty string is fine. + */ +export function synthesizeOsrmInstruction( + type: string, + modifier: string | undefined, + name: string, +): string { + const onto = name ? ` onto ${name}` : ''; + const on = name ? ` on ${name}` : ''; + + switch (type) { + case 'depart': + return name ? `Head out on ${name}` : 'Head out'; + case 'arrive': + return 'Arrive at destination'; + case 'turn': + return `${turnVerb(modifier)}${onto}`; + case 'new name': + return name ? `Continue on ${name}` : 'Continue'; + case 'continue': + return modifier && modifier !== 'straight' + ? `${turnVerb(modifier)}${onto}` + : name ? `Continue on ${name}` : 'Continue'; + case 'merge': + return `Merge${onto}`; + case 'on ramp': + return `Take the ramp${onto}`; + case 'off ramp': + return `Take the exit${onto}`; + case 'fork': + return `Keep ${forkSide(modifier)} at the fork${onto}`; + case 'end of road': + return `${turnVerb(modifier)} at the end of the road${onto}`; + case 'roundabout': + case 'rotary': + return `Enter the ${type === 'rotary' ? 'rotary' : 'roundabout'}${onto}`; + case 'roundabout turn': + return `At the roundabout, ${turnVerb(modifier).toLowerCase()}${onto}`; + case 'use lane': + return name ? `Continue on ${name}` : 'Continue in lane'; + case 'notification': + return name ? `Continue${on}` : ''; + default: + return name ? `Continue${on}` : 'Continue'; + } +} + +function turnVerb(modifier?: string): string { + switch (modifier) { + case 'uturn': return 'Make a U-turn'; + case 'sharp right': return 'Turn sharp right'; + case 'right': return 'Turn right'; + case 'slight right': return 'Bear right'; + case 'straight': return 'Continue straight'; + case 'slight left': return 'Bear left'; + case 'left': return 'Turn left'; + case 'sharp left': return 'Turn sharp left'; + default: return 'Continue'; + } +} + +function forkSide(modifier?: string): string { + if (modifier === 'left' || modifier === 'slight left' || modifier === 'sharp left') return 'left'; + if (modifier === 'right' || modifier === 'slight right' || modifier === 'sharp right') return 'right'; + return 'straight'; +} diff --git a/src/routing-osrm.ts b/src/routing-osrm.ts new file mode 100644 index 0000000..85a04ac --- /dev/null +++ b/src/routing-osrm.ts @@ -0,0 +1,137 @@ +// OSRM client targeting the OSM-DE public backends at +// https://routing.openstreetmap.de. Each profile has its own sub-path +// (routed-car / routed-bike / routed-foot) — within a backend, the URL still +// carries the standard OSRM profile token even though the backend only serves +// one. +// +// Unlike Valhalla, OSRM returns no prose: we synthesize instructions from +// `maneuver.type` + `maneuver.modifier` + `step.name` (routing-osrm-instructions.ts). + +import L from 'leaflet'; +import { decodePolyline6, type Costing, type Route, type RouteRequest, type RouteStep } from './routing'; +import { + osrmManeuverToValhallaType, + synthesizeOsrmInstruction, +} from './routing-osrm-instructions'; + +export const OSRM_BASE_URL = 'https://routing.openstreetmap.de' as const; + +interface OsrmManeuver { + type: string; + modifier?: string; + location: [number, number]; // [lon, lat] +} + +interface OsrmStep { + geometry: string; + maneuver: OsrmManeuver; + distance: number; // metres + duration: number; // seconds + name: string; + ref?: string; +} + +interface OsrmLeg { + steps: OsrmStep[]; + distance: number; + duration: number; +} + +interface OsrmRoute { + geometry: string; + legs: OsrmLeg[]; + distance: number; + duration: number; +} + +interface OsrmResponse { + code: string; + message?: string; + routes?: OsrmRoute[]; +} + +/** OSM-DE backend path for a costing profile. */ +function osrmBackend(c: Costing): string { + switch (c) { + case 'auto': return 'routed-car'; + case 'bicycle': return 'routed-bike'; + case 'pedestrian': return 'routed-foot'; + } +} + +/** Standard OSRM profile token within a backend's URL. */ +function osrmProfile(c: Costing): string { + switch (c) { + case 'auto': return 'driving'; + case 'bicycle': return 'cycling'; + case 'pedestrian': return 'walking'; + } +} + +export function buildOsrmUrl(req: RouteRequest): string { + const backend = osrmBackend(req.costing); + const profile = osrmProfile(req.costing); + const coords = + `${req.start.lng},${req.start.lat};${req.dest.lng},${req.dest.lat}`; + const params = 'overview=full&geometries=polyline6&steps=true'; + return `${OSRM_BASE_URL}/${backend}/route/v1/${profile}/${coords}?${params}`; +} + +export async function fetchRouteOsrm(req: RouteRequest): Promise { + const url = buildOsrmUrl(req); + let res: Response; + try { + res = await fetch(url, { signal: req.signal }); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') throw err; + throw new Error('routing service unavailable — check your connection or try again later'); + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const json = (await res.json()) as OsrmResponse; + if (json.code !== 'Ok') { + throw new Error(`Routing failed: ${json.code}${json.message ? ` — ${json.message}` : ''}`); + } + const route = json.routes?.[0]; + if (!route) throw new Error('Routing returned no routes'); + if (route.legs.length > 1) { + // Two-point requests should always return exactly one leg. + throw new Error('Routing returned multi-leg trip — only single leg supported'); + } + const leg = route.legs[0]; + if (!leg) throw new Error('Routing returned no legs'); + + const coords: L.LatLng[] = []; + const steps: RouteStep[] = []; + for (const step of leg.steps) { + const segCoords = decodePolyline6(step.geometry); + // beginShapeIndex points to where this step starts in the unified coords + // array. When concatenating, the last coord of the previous step is the + // same point as the first coord of this step — dedupe and reuse its index. + const beginShapeIndex = coords.length === 0 ? 0 : coords.length - 1; + if (coords.length > 0 && segCoords.length > 0) { + for (let i = 1; i < segCoords.length; i++) coords.push(segCoords[i]!); + } else { + for (const c of segCoords) coords.push(c); + } + const name = step.name ?? ''; + const streetNames: string[] = []; + if (name) streetNames.push(name); + steps.push({ + instruction: synthesizeOsrmInstruction(step.maneuver.type, step.maneuver.modifier, name), + type: osrmManeuverToValhallaType(step.maneuver.type, step.maneuver.modifier), + lengthM: step.distance, + durationS: step.duration, + streetNames, + beginShapeIndex, + }); + } + + return { + coords, + steps, + distanceM: route.distance, + durationS: route.duration, + }; +} diff --git a/src/routing-valhalla.ts b/src/routing-valhalla.ts new file mode 100644 index 0000000..6d47fd0 --- /dev/null +++ b/src/routing-valhalla.ts @@ -0,0 +1,87 @@ +// FOSSGIS Valhalla client. POST /route with JSON; response is verbose but +// already carries prose instruction strings, so no synthesis is needed. + +import { decodePolyline6, type Route, type RouteRequest, type RouteStep } from './routing'; +import { unitSystem, valhallaUnits, metersPerValhallaUnit } from './units'; + +export const VALHALLA_URL = 'https://valhalla1.openstreetmap.de/route' as const; + +interface ValhallaManeuver { + type: number; + instruction: string; + length: number; // in the units requested via directions_options + time: number; // seconds + street_names?: string[]; + begin_shape_index: number; +} + +interface ValhallaResponse { + trip: { + legs: Array<{ + shape: string; + maneuvers: ValhallaManeuver[]; + }>; + summary: { + length: number; // in the units requested via directions_options + time: number; // seconds + }; + }; +} + +export async function fetchRouteValhalla(req: RouteRequest): Promise { + const system = req.units ?? unitSystem(); + const body = { + locations: [ + { lat: req.start.lat, lon: req.start.lng }, + { lat: req.dest.lat, lon: req.dest.lng }, + ], + costing: req.costing, + directions_options: { units: valhallaUnits(system) }, + }; + let res: Response; + try { + res = await fetch(VALHALLA_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: req.signal, + }); + } catch (err) { + // An aborted request must propagate unchanged so callers can detect it. + if (err instanceof DOMException && err.name === 'AbortError') throw err; + // fetch() rejects with a TypeError for any network-level failure (DNS, + // connection refused, TLS, blocked CORS preflight). The browser surfaces + // these in the console as an opaque "CORS request did not succeed" with a + // null status — there is no HTTP response to inspect. Convert it into a + // message a user can act on instead of leaking the raw TypeError. + throw new Error('routing service unavailable — check your connection or try again later'); + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const json = (await res.json()) as ValhallaResponse; + if (json.trip.legs.length > 1) { + // Two-point requests should always return exactly one leg. Guard against + // a future change that introduces multi-waypoint without updating + // distance/duration aggregation. + throw new Error('Routing returned multi-leg trip — only single leg supported'); + } + const leg = json.trip.legs[0]; + if (!leg) throw new Error('Routing returned no legs'); + const mPerUnit = metersPerValhallaUnit(system); + const coords = decodePolyline6(leg.shape); + const steps: RouteStep[] = leg.maneuvers.map((m) => ({ + instruction: m.instruction, + type: m.type, + lengthM: m.length * mPerUnit, + durationS: m.time, + streetNames: m.street_names ?? [], + beginShapeIndex: m.begin_shape_index, + })); + return { + coords, + steps, + distanceM: json.trip.summary.length * mPerUnit, + durationS: json.trip.summary.time, + }; +} diff --git a/src/routing.test.ts b/src/routing.test.ts index a7ad861..ef57e90 100644 --- a/src/routing.test.ts +++ b/src/routing.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import L from 'leaflet'; import { decodePolyline6, fetchRoute, VALHALLA_URL } from './routing'; +import { fetchRouteValhalla } from './routing-valhalla'; +import { fetchRouteOsrm, buildOsrmUrl, OSRM_BASE_URL } from './routing-osrm'; +import { + osrmManeuverToValhallaType, + synthesizeOsrmInstruction, +} from './routing-osrm-instructions'; // Encode a list of [lat, lng] pairs to polyline6 — used to generate round-trip // fixtures for the decoder. Mirrors the algorithm Valhalla uses. @@ -82,7 +88,50 @@ describe('decodePolyline6', () => { }); }); -describe('fetchRoute', () => { +describe('fetchRoute — provider dispatch + shared guards', () => { + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws before fetch when coordinates are not finite', async () => { + // Cast around L.latLng's NaN check to exercise routing's own guard. + const bad = { lat: NaN, lng: 0 } as L.LatLng; + await expect( + fetchRoute({ + start: bad, + dest: L.latLng(1, 1), + costing: 'auto', + }), + ).rejects.toThrow(/invalid coordinates/); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('dispatches to the default provider (Valhalla) via fetchRoute', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + trip: { + legs: [{ shape: '', maneuvers: [] }], + summary: { length: 0, time: 0 }, + }, + }), + } as Response); + await fetchRoute({ + start: L.latLng(0, 0), + dest: L.latLng(1, 1), + costing: 'auto', + }); + const fetchMock = globalThis.fetch as ReturnType; + const [url, opts] = fetchMock.mock.calls[0]!; + expect(url).toBe(VALHALLA_URL); + expect(opts.method).toBe('POST'); + }); +}); + +describe('fetchRouteValhalla', () => { beforeEach(() => { globalThis.fetch = vi.fn(); }); @@ -108,7 +157,7 @@ describe('fetchRoute', () => { it('POSTs to Valhalla with the expected body shape', async () => { mockOk(emptyTrip()); - await fetchRoute({ + await fetchRouteValhalla({ start: L.latLng(40, -74), dest: L.latLng(40.1, -73.9), costing: 'auto', @@ -141,7 +190,7 @@ describe('fetchRoute', () => { summary: { length: 2, time: 120 }, }, }); - const r = await fetchRoute({ + const r = await fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -159,7 +208,7 @@ describe('fetchRoute', () => { 'passes costing=%s through to the request body', async (costing) => { mockOk(emptyTrip()); - await fetchRoute({ + await fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing, @@ -176,7 +225,7 @@ describe('fetchRoute', () => { status: 500, } as Response); await expect( - fetchRoute({ + fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -189,7 +238,7 @@ describe('fetchRoute', () => { new TypeError('Failed to fetch'), ); await expect( - fetchRoute({ + fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -202,7 +251,7 @@ describe('fetchRoute', () => { new DOMException('aborted', 'AbortError'), ); await expect( - fetchRoute({ + fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -210,19 +259,6 @@ describe('fetchRoute', () => { ).rejects.toThrow(/aborted/); }); - it('throws before fetch when coordinates are not finite', async () => { - // Cast around L.latLng's NaN check to exercise routing's own guard. - const bad = { lat: NaN, lng: 0 } as L.LatLng; - await expect( - fetchRoute({ - start: bad, - dest: L.latLng(1, 1), - costing: 'auto', - }), - ).rejects.toThrow(/invalid coordinates/); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - it('throws when Valhalla returns multiple legs', async () => { mockOk({ trip: { @@ -234,7 +270,7 @@ describe('fetchRoute', () => { }, }); await expect( - fetchRoute({ + fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -245,7 +281,7 @@ describe('fetchRoute', () => { it('throws when Valhalla returns no legs', async () => { mockOk({ trip: { legs: [], summary: { length: 0, time: 0 } } }); await expect( - fetchRoute({ + fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -256,7 +292,7 @@ describe('fetchRoute', () => { it('passes AbortSignal through to fetch', async () => { mockOk(emptyTrip()); const ac = new AbortController(); - await fetchRoute({ + await fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -273,7 +309,7 @@ describe('fetchRoute', () => { summary: { length: 1.5, time: 300 }, }, }); - const r = await fetchRoute({ + const r = await fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -309,7 +345,7 @@ describe('fetchRoute', () => { summary: { length: 0.2, time: 30 }, }, }); - const r = await fetchRoute({ + const r = await fetchRouteValhalla({ start: L.latLng(0, 0), dest: L.latLng(1, 1), costing: 'auto', @@ -322,3 +358,312 @@ describe('fetchRoute', () => { expect(r.steps[1]?.streetNames).toEqual([]); }); }); + +describe('buildOsrmUrl', () => { + it('serialises lon,lat coordinate pairs in OSRM order', () => { + const url = buildOsrmUrl({ + start: L.latLng(40.7, -74.0), + dest: L.latLng(40.8, -73.9), + costing: 'auto', + }); + expect(url).toContain('/-74,40.7;-73.9,40.8?'); + expect(url).toContain('overview=full'); + expect(url).toContain('geometries=polyline6'); + expect(url).toContain('steps=true'); + expect(url.startsWith(OSRM_BASE_URL)).toBe(true); + }); + + it.each([ + ['auto', 'routed-car', 'driving'], + ['bicycle', 'routed-bike', 'cycling'], + ['pedestrian', 'routed-foot', 'walking'], + ] as const)('maps costing=%s to backend=%s, profile=%s', (costing, backend, profile) => { + const url = buildOsrmUrl({ + start: L.latLng(0, 0), + dest: L.latLng(1, 1), + costing, + }); + expect(url).toContain(`/${backend}/route/v1/${profile}/`); + }); +}); + +describe('fetchRouteOsrm', () => { + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mockOk(json: unknown): void { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => json, + } as Response); + } + + function osrmOk(steps: Array<{ + geometry: string; + type: string; + modifier?: string; + distance: number; + duration: number; + name?: string; + }>, summary: { distance: number; duration: number }) { + return { + code: 'Ok', + routes: [{ + geometry: '', + legs: [{ + steps: steps.map((s) => ({ + geometry: s.geometry, + maneuver: { type: s.type, modifier: s.modifier, location: [0, 0] }, + distance: s.distance, + duration: s.duration, + name: s.name ?? '', + })), + distance: summary.distance, + duration: summary.duration, + }], + distance: summary.distance, + duration: summary.duration, + }], + }; + } + + it('GETs the OSRM URL (no body) and passes AbortSignal', async () => { + mockOk(osrmOk([], { distance: 0, duration: 0 })); + const ac = new AbortController(); + await fetchRouteOsrm({ + start: L.latLng(40, -74), + dest: L.latLng(40.1, -73.9), + costing: 'auto', + signal: ac.signal, + }); + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, opts] = fetchMock.mock.calls[0]!; + expect(String(url)).toContain('/routed-car/route/v1/driving/'); + expect(opts?.method).toBeUndefined(); + expect(opts?.body).toBeUndefined(); + expect(opts.signal).toBe(ac.signal); + }); + + it('throws when OSRM returns a non-Ok code', async () => { + mockOk({ code: 'NoRoute', message: 'no route found' }); + await expect( + fetchRouteOsrm({ + start: L.latLng(0, 0), + dest: L.latLng(1, 1), + costing: 'auto', + }), + ).rejects.toThrow(/NoRoute/); + }); + + it('throws on non-ok HTTP response', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 503, + } as Response); + await expect( + fetchRouteOsrm({ + start: L.latLng(0, 0), + dest: L.latLng(1, 1), + costing: 'auto', + }), + ).rejects.toThrow(/^HTTP 503$/); + }); + + it('converts a network-level fetch failure into an actionable message', async () => { + (globalThis.fetch as ReturnType).mockRejectedValue( + new TypeError('Failed to fetch'), + ); + await expect( + fetchRouteOsrm({ + start: L.latLng(0, 0), + dest: L.latLng(1, 1), + costing: 'auto', + }), + ).rejects.toThrow(/routing service unavailable/); + }); + + it('propagates an AbortError unchanged', async () => { + (globalThis.fetch as ReturnType).mockRejectedValue( + new DOMException('aborted', 'AbortError'), + ); + await expect( + fetchRouteOsrm({ + start: L.latLng(0, 0), + dest: L.latLng(1, 1), + costing: 'auto', + }), + ).rejects.toThrow(/aborted/); + }); + + it('throws when OSRM returns multiple legs', async () => { + mockOk({ + code: 'Ok', + routes: [{ + geometry: '', + legs: [ + { steps: [], distance: 0, duration: 0 }, + { steps: [], distance: 0, duration: 0 }, + ], + distance: 0, + duration: 0, + }], + }); + await expect( + fetchRouteOsrm({ + start: L.latLng(0, 0), + dest: L.latLng(1, 1), + costing: 'auto', + }), + ).rejects.toThrow(/multi-leg/); + }); + + it('builds coords by concatenating step geometries with shared-endpoint dedup', async () => { + const segA = encodePolyline6([[40.0, -74.0], [40.001, -74.0]]); + const segB = encodePolyline6([[40.001, -74.0], [40.002, -74.0]]); + const segArrive = encodePolyline6([[40.002, -74.0]]); + mockOk(osrmOk( + [ + { geometry: segA, type: 'depart', distance: 110, duration: 12, name: 'Main St' }, + { geometry: segB, type: 'turn', modifier: 'right', distance: 110, duration: 12, name: '2nd Ave' }, + { geometry: segArrive, type: 'arrive', distance: 0, duration: 0, name: '' }, + ], + { distance: 220, duration: 24 }, + )); + const r = await fetchRouteOsrm({ + start: L.latLng(40.0, -74.0), + dest: L.latLng(40.002, -74.0), + costing: 'auto', + }); + expect(r.coords).toHaveLength(3); + expect(r.coords[0]?.lat).toBeCloseTo(40.0, 5); + expect(r.coords[1]?.lat).toBeCloseTo(40.001, 5); + expect(r.coords[2]?.lat).toBeCloseTo(40.002, 5); + expect(r.steps).toHaveLength(3); + // Step 0 starts at coord index 0 (depart). + expect(r.steps[0]?.beginShapeIndex).toBe(0); + // Step 1 starts at the shared endpoint (index 1 in the concatenated coords). + expect(r.steps[1]?.beginShapeIndex).toBe(1); + // Arrive step sits at the last coord. + expect(r.steps[2]?.beginShapeIndex).toBe(2); + expect(r.distanceM).toBe(220); + expect(r.durationS).toBe(24); + }); + + it('maps OSRM maneuvers to Valhalla type numbers + synthesizes instructions', async () => { + const seg = encodePolyline6([[40.0, -74.0], [40.001, -74.0]]); + mockOk(osrmOk( + [ + { geometry: seg, type: 'depart', distance: 50, duration: 6, name: 'Main St' }, + { geometry: seg, type: 'turn', modifier: 'left', distance: 50, duration: 6, name: 'Elm St' }, + { geometry: seg, type: 'arrive', distance: 0, duration: 0, name: '' }, + ], + { distance: 100, duration: 12 }, + )); + const r = await fetchRouteOsrm({ + start: L.latLng(40.0, -74.0), + dest: L.latLng(40.002, -74.0), + costing: 'auto', + }); + expect(r.steps[0]?.type).toBe(1); // start + expect(r.steps[0]?.instruction).toBe('Head out on Main St'); + expect(r.steps[0]?.streetNames).toEqual(['Main St']); + expect(r.steps[1]?.type).toBe(15); // left + expect(r.steps[1]?.instruction).toBe('Turn left onto Elm St'); + expect(r.steps[2]?.type).toBe(4); // destination + expect(r.steps[2]?.instruction).toBe('Arrive at destination'); + }); + + it('uses OSRM step distance/duration directly (already metric)', async () => { + const seg = encodePolyline6([[0, 0], [0.001, 0]]); + mockOk(osrmOk( + [ + { geometry: seg, type: 'depart', distance: 123.4, duration: 56, name: '' }, + { geometry: seg, type: 'arrive', distance: 0, duration: 0, name: '' }, + ], + { distance: 123.4, duration: 56 }, + )); + const r = await fetchRouteOsrm({ + start: L.latLng(0, 0), + dest: L.latLng(0.001, 0), + costing: 'auto', + }); + expect(r.steps[0]?.lengthM).toBe(123.4); + expect(r.steps[0]?.durationS).toBe(56); + expect(r.distanceM).toBe(123.4); + expect(r.durationS).toBe(56); + }); +}); + +describe('osrmManeuverToValhallaType', () => { + it.each([ + ['depart', undefined, 1], + ['arrive', undefined, 4], + ['turn', 'left', 15], + ['turn', 'right', 10], + ['turn', 'slight left', 16], + ['turn', 'slight right', 9], + ['turn', 'sharp left', 14], + ['turn', 'sharp right', 11], + ['turn', 'uturn', 12], + ['continue', 'straight', 8], + ['continue', undefined, 8], + ['new name', undefined, 8], + ['merge', undefined, 25], + ['on ramp', 'right', 18], + ['on ramp', 'left', 19], + ['off ramp', 'right', 20], + ['off ramp', 'left', 21], + ['fork', 'left', 24], + ['fork', 'right', 23], + ['fork', 'straight', 22], + ['roundabout', undefined, 26], + ['rotary', undefined, 26], + ] as const)('maps %s/%s → %i', (type, modifier, expected) => { + expect(osrmManeuverToValhallaType(type, modifier)).toBe(expected); + }); + + it('falls back to 0 for unknown maneuver types', () => { + expect(osrmManeuverToValhallaType('something-new')).toBe(0); + }); +}); + +describe('synthesizeOsrmInstruction', () => { + it('includes the street name when provided', () => { + expect(synthesizeOsrmInstruction('turn', 'right', 'Main St')).toBe('Turn right onto Main St'); + }); + + it('omits the onto-clause when name is empty', () => { + expect(synthesizeOsrmInstruction('turn', 'left', '')).toBe('Turn left'); + }); + + it('synthesizes a depart maneuver', () => { + expect(synthesizeOsrmInstruction('depart', undefined, 'Highway 5')).toBe('Head out on Highway 5'); + expect(synthesizeOsrmInstruction('depart', undefined, '')).toBe('Head out'); + }); + + it('synthesizes an arrive maneuver', () => { + expect(synthesizeOsrmInstruction('arrive', undefined, '')).toBe('Arrive at destination'); + }); + + it('synthesizes continue with straight modifier', () => { + expect(synthesizeOsrmInstruction('continue', 'straight', 'Highway 5')).toBe('Continue on Highway 5'); + }); + + it('synthesizes a U-turn', () => { + expect(synthesizeOsrmInstruction('turn', 'uturn', 'Main St')).toBe('Make a U-turn onto Main St'); + }); + + it('synthesizes a roundabout', () => { + expect(synthesizeOsrmInstruction('roundabout', undefined, 'A1')).toBe('Enter the roundabout onto A1'); + }); + + it('falls back to a continue instruction for unknown types', () => { + expect(synthesizeOsrmInstruction('mystery', undefined, 'Foo')).toBe('Continue on Foo'); + expect(synthesizeOsrmInstruction('mystery', undefined, '')).toBe('Continue'); + }); +}); diff --git a/src/routing.ts b/src/routing.ts index 3adbab8..3b6f8bf 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1,18 +1,31 @@ -// Privacy: each request sends start + destination to the public FOSSGIS Valhalla -// instance. ADR-006 + the consent flow document the tradeoff before any nav action. +// Public routing API + provider dispatch. +// +// Two backends sit behind the same `fetchRoute()` signature: +// - Valhalla (FOSSGIS) — POST /route, full prose instructions +// - OSRM (OSM-DE) — GET /route/v1, instructions synthesized client-side +// +// Provider is selected by ROUTING_PROVIDER. Runtime fallback is a separate +// issue (#TBD); today this is a const so a swap is one line + rebuild. +// +// Privacy: each request sends start + destination to a public routing service. +// ADR-006 + the consent flow document the tradeoff before any nav action. + import L from 'leaflet'; -import { - type UnitSystem, - unitSystem, - valhallaUnits, - metersPerValhallaUnit, -} from './units'; +import type { UnitSystem } from './units'; +import { fetchRouteValhalla, VALHALLA_URL } from './routing-valhalla'; +import { fetchRouteOsrm, OSRM_BASE_URL } from './routing-osrm'; export type Costing = 'auto' | 'pedestrian' | 'bicycle'; export interface RouteStep { instruction: string; - /** Valhalla maneuver type number — see https://valhalla.github.io/valhalla/api/turn-by-turn/api-reference/#maneuver-types */ + /** + * Valhalla maneuver type number — see + * https://valhalla.github.io/valhalla/api/turn-by-turn/api-reference/#maneuver-types + * + * OSRM responses are mapped onto this same numeric space so guidance.ts's + * icon picker keeps working regardless of provider. + */ type: number; lengthM: number; durationS: number; @@ -33,14 +46,20 @@ export interface RouteRequest { dest: L.LatLng; costing: Costing; signal?: AbortSignal; - /** Unit system for Valhalla's instruction text. Defaults to the session locale. */ + /** Unit system for instruction text. Only consulted by Valhalla. */ units?: UnitSystem; } -export const VALHALLA_URL = 'https://valhalla1.openstreetmap.de/route' as const; +export type RoutingProvider = 'valhalla' | 'osrm'; + +/** Active routing backend. Change here + rebuild to swap providers. */ +export const ROUTING_PROVIDER: RoutingProvider = 'valhalla'; + +export { VALHALLA_URL, OSRM_BASE_URL }; /** - * Decode Valhalla's polyline6 (precision 6 — twice the resolution of standard Google polyline5). + * Decode polyline6 (precision-6 polyline — same format Valhalla returns and + * what OSRM returns when `geometries=polyline6` is requested). * Reference: https://valhalla.github.io/valhalla/decoding/ */ export function decodePolyline6(s: string): L.LatLng[] { @@ -71,29 +90,7 @@ export function decodePolyline6(s: string): L.LatLng[] { return out; } -interface ValhallaManeuver { - type: number; - instruction: string; - length: number; // in the units requested via directions_options - time: number; // seconds - street_names?: string[]; - begin_shape_index: number; -} - -interface ValhallaResponse { - trip: { - legs: Array<{ - shape: string; - maneuvers: ValhallaManeuver[]; - }>; - summary: { - length: number; // in the units requested via directions_options - time: number; // seconds - }; - }; -} - -/** Fetch a route from Valhalla. AbortController-friendly. */ +/** Fetch a route from the active provider. AbortController-friendly. */ export async function fetchRoute(req: RouteRequest): Promise { if ( !isFinite(req.start.lat) || !isFinite(req.start.lng) || @@ -101,59 +98,7 @@ export async function fetchRoute(req: RouteRequest): Promise { ) { throw new Error('Routing failed: invalid coordinates'); } - const system = req.units ?? unitSystem(); - const body = { - locations: [ - { lat: req.start.lat, lon: req.start.lng }, - { lat: req.dest.lat, lon: req.dest.lng }, - ], - costing: req.costing, - directions_options: { units: valhallaUnits(system) }, - }; - let res: Response; - try { - res = await fetch(VALHALLA_URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - signal: req.signal, - }); - } catch (err) { - // An aborted request must propagate unchanged so callers can detect it. - if (err instanceof DOMException && err.name === 'AbortError') throw err; - // fetch() rejects with a TypeError for any network-level failure (DNS, - // connection refused, TLS, blocked CORS preflight). The browser surfaces - // these in the console as an opaque "CORS request did not succeed" with a - // null status — there is no HTTP response to inspect. Convert it into a - // message a user can act on instead of leaking the raw TypeError. - throw new Error('routing service unavailable — check your connection or try again later'); - } - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const json = (await res.json()) as ValhallaResponse; - if (json.trip.legs.length > 1) { - // Two-point requests should always return exactly one leg. Guard against - // a future change that introduces multi-waypoint without updating - // distance/duration aggregation. - throw new Error('Routing returned multi-leg trip — only single leg supported'); - } - const leg = json.trip.legs[0]; - if (!leg) throw new Error('Routing returned no legs'); - const mPerUnit = metersPerValhallaUnit(system); - const coords = decodePolyline6(leg.shape); - const steps: RouteStep[] = leg.maneuvers.map((m) => ({ - instruction: m.instruction, - type: m.type, - lengthM: m.length * mPerUnit, - durationS: m.time, - streetNames: m.street_names ?? [], - beginShapeIndex: m.begin_shape_index, - })); - return { - coords, - steps, - distanceM: json.trip.summary.length * mPerUnit, - durationS: json.trip.summary.time, - }; + return ROUTING_PROVIDER === 'osrm' + ? fetchRouteOsrm(req) + : fetchRouteValhalla(req); }