Files
gods-eye-view/src/data/routeSteps.js
T
Sameh Khamis d14205160a Format every metre/kilometre distance through one shared helper
Directions, the Contacts marker and panel, placed annotation labels, the
drawn-line measure, the Cockpit and formatRouteDistance each chose the unit
and precision on their own, and most chose before rounding: #1450, #1433 and
formatDistanceText or kilometreDigits from src/data/distanceFormat.js, which
cut after rounding. Each keeps its own metre step and rendering.

That also fixes the 10 km cut the Contacts marker and panel, the drawn-line
measure and the Cockpit still had (9,960 m read 10.0 km). The helper returns
kilometres already rounded, so the plain marker and the localized panel no
longer disagree on half-tenths such as 1,450 m.

A negative annotation distance now leaves the label without a distance, and
a drawn line with a coordinate that is not a number shows no measure, rather
than printing 0 m or NaN km. The HUD altitude tag and the spoken view height
keep their own unit choice.
2026-11-01 22:40:28 -08:00

238 lines
8.1 KiB
JavaScript

/**
* @module routeSteps
* @description Turn-by-turn steps from an OSRM route, in plain English.
*
* OSRM (the FOSSGIS servers behind `/api/route`) returns each step as a
* maneuver `type` + `modifier` (+ roundabout `exit`) and the road name; it
* does not phrase instructions. This module does, with the same vocabulary
* OSRM's own text-instructions package uses so a reader of either feels at
* home. Pure: used by the Vite proxy and node:test.
*
* OSRM maneuver types handled: depart, arrive, turn, new name, continue,
* end of road, fork, merge, on ramp, off ramp, roundabout, rotary,
* roundabout turn, exit roundabout, exit rotary, notification, use lane.
*/
import { formatDistanceText } from './distanceFormat.js';
/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
export const ROUTE_STEPS_MAX = 200;
const DIRECTION_WORDS = Object.freeze({
left: 'left',
right: 'right',
'slight left': 'slightly left',
'slight right': 'slightly right',
'sharp left': 'sharply left',
'sharp right': 'sharply right',
straight: 'straight',
uturn: 'around',
});
function cleanText(value) {
const text = typeof value === 'string' ? value.trim() : '';
return text;
}
/**
* Road label for a step: "Name (Ref)", "Name", "Ref", or "".
* @param {{name?: string, ref?: string}} step
* @returns {string}
*/
export function roadLabel(step) {
const name = cleanText(step?.name);
const ref = cleanText(step?.ref);
if (name && ref && !name.includes(ref)) return `${name} (${ref})`;
return name || ref;
}
function ontoRoad(step) {
const road = roadLabel(step);
return road ? ` onto ${road}` : '';
}
function direction(modifier) {
const key = cleanText(modifier).toLowerCase();
return DIRECTION_WORDS[key] || key;
}
/**
* "on the left" only fits a bare side. "slight left" is the adverb
* "slightly left", and "on the slightly left" is not an instruction.
* @param {string} prefix "Take the ramp" or "Take the exit".
* @param {string} modifier OSRM maneuver modifier.
* @param {string} onto " onto …", or "".
*/
function directedWay(prefix, modifier, onto) {
const dir = direction(modifier);
if (dir === 'left' || dir === 'right')
return `${prefix} on the ${dir}${onto}`;
if (dir && dir !== 'straight') return `${prefix} ${dir}${onto}`;
return `${prefix}${onto}`;
}
function ordinal(n) {
if (!Number.isInteger(n) || n < 1) return null;
const suffix =
n % 100 >= 11 && n % 100 <= 13
? 'th'
: { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
return `${n}${suffix}`;
}
/**
* One English sentence for an OSRM step.
* @param {{type?: string, modifier?: string, exit?: number, name?: string, ref?: string}} step
* @returns {string}
*/
export function instructionFor(step) {
const type = cleanText(step?.type).toLowerCase();
const modifier = cleanText(step?.modifier).toLowerCase();
const dir = direction(modifier);
const onto = ontoRoad(step);
const road = roadLabel(step);
switch (type) {
case 'depart':
return road ? `Head out on ${road}` : 'Head out';
case 'arrive':
if (modifier === 'left' || modifier === 'right')
return `Arrive at your destination, on the ${modifier}`;
return 'Arrive at your destination';
case 'turn':
if (modifier === 'uturn') return `Make a U-turn${onto}`;
if (modifier === 'straight') return `Continue straight${onto}`;
return dir ? `Turn ${dir}${onto}` : `Turn${onto}`;
case 'new name':
return `Continue${onto}`;
case 'continue':
if (modifier === 'uturn') return `Make a U-turn${onto}`;
return dir && dir !== 'straight'
? `Continue ${dir}${onto}`
: `Continue straight${onto}`;
case 'end of road':
return dir
? `At the end of the road, turn ${dir}${onto}`
: `At the end of the road, continue${onto}`;
case 'fork':
return dir
? `Keep ${dir} at the fork${onto}`
: `Continue at the fork${onto}`;
case 'merge':
return dir ? `Merge ${dir}${onto}` : `Merge${onto}`;
case 'on ramp':
return directedWay('Take the ramp', modifier, onto);
case 'off ramp':
return directedWay('Take the exit', modifier, onto);
case 'roundabout':
case 'rotary': {
const exit = ordinal(step?.exit);
return exit
? `At the roundabout, take the ${exit} exit${onto}`
: `Enter the roundabout${onto}`;
}
case 'roundabout turn':
return dir
? `At the roundabout, turn ${dir}${onto}`
: `At the roundabout, continue${onto}`;
case 'exit roundabout':
case 'exit rotary':
return `Exit the roundabout${onto}`;
case 'use lane': {
const lane = modifier.includes('left')
? 'left'
: modifier.includes('right')
? 'right'
: '';
return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`;
}
case 'notification':
default:
return `Continue${onto}`;
}
}
/**
* Flatten an OSRM route's legs into the compact step list the proxy serves.
* "exit roundabout" / "exit rotary" steps are folded into the roundabout
* step before them (their distance is the road after the roundabout), so a
* reader gets one instruction per decision.
* A route with more maneuvers than `ROUTE_STEPS_MAX` is cut off there and
* reported as cut off, because the last step of a route is its arrival: a
* silently truncated list reads as a complete set of directions that simply
* stops in the middle of a motorway.
* @param {{legs?: Array<{steps?: object[]}>}} route OSRM route object.
* @returns {{steps: Array<{index:number, type:string, modifier:string|null, exit:number|null,
* name:string, ref:string|null, distanceM:number, durationS:number, lon:number, lat:number,
* instruction:string}>, truncated: boolean}}
*/
export function normalizeOsrmSteps(route) {
const out = [];
for (const leg of route?.legs || []) {
for (const raw of leg?.steps || []) {
const location = raw?.maneuver?.location;
const lon = Number(location?.[0]);
const lat = Number(location?.[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const type = cleanText(raw.maneuver?.type).toLowerCase() || 'continue';
const step = {
index: out.length,
type,
modifier: cleanText(raw.maneuver?.modifier).toLowerCase() || null,
exit: Number.isInteger(raw.maneuver?.exit) ? raw.maneuver.exit : null,
name: cleanText(raw.name),
ref: cleanText(raw.ref) || null,
distanceM: Math.max(0, Math.round(Number(raw.distance) || 0)),
durationS: Math.max(0, Math.round(Number(raw.duration) || 0)),
lon: Number(lon.toFixed(6)),
lat: Number(lat.toFixed(6)),
instruction: '',
};
const previous = out[out.length - 1];
if (
(type === 'exit roundabout' || type === 'exit rotary') &&
previous &&
(previous.type === 'roundabout' ||
previous.type === 'rotary' ||
previous.type === 'roundabout turn')
) {
previous.distanceM += step.distanceM;
previous.durationS += step.durationS;
if (!roadLabel(previous) && roadLabel(step)) {
previous.name = step.name;
previous.ref = step.ref;
previous.instruction = instructionFor(previous);
}
continue;
}
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
step.instruction = instructionFor(step);
out.push(step);
}
}
return { steps: out, truncated: false };
}
/**
* "850 m", "1.2 km", "21 km".
* @param {number} meters
* @returns {string}
*/
export function formatRouteDistance(meters) {
return formatDistanceText(meters, { metreStep: 10, exactBelowM: 100 }) ?? '';
}
/**
* "40 s", "12 min", "1 h 5 min".
* @param {number} seconds
* @returns {string}
*/
export function formatRouteDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return `${Math.round(seconds)} s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours} h ${rest} min` : `${hours} h`;
}