Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/routing-osrm-instructions.ts
Original file line number Diff line number Diff line change
@@ -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';
}
137 changes: 137 additions & 0 deletions src/routing-osrm.ts
Original file line number Diff line number Diff line change
@@ -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<Route> {
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,
};
}
87 changes: 87 additions & 0 deletions src/routing-valhalla.ts
Original file line number Diff line number Diff line change
@@ -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<Route> {
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,
};
}
Loading
Loading