feat(routing): street-aware turn-by-turn (road names) + one-way/roundabout support; widen graph to named local streets
All checks were successful
CI / test-and-build (push) Successful in 47s

- osm-dump.mjs: capture way name/ref as edgeNames; honor oneway & roundabout as directed edges; graphHighways widened to include named local roads
- routing.ts: buildTurnInstructions emits 'Head north on Pine St' / 'Turn right onto 21st St'; merges short legs to avoid phantom turns; nameResolverFor() maps node path to street names
- Rebuilt tulsa-dump-graph.json: 225,956 nodes / 227,468 edges (was 55k); ~27MB (was 4.7MB)
- tests, README updated
This commit is contained in:
hermes-explorigin 2026-08-10 21:11:20 +00:00
parent 397979ac78
commit fbf939d236
6 changed files with 128 additions and 29 deletions

View File

@ -254,9 +254,15 @@ From the opened **Directions** panel:
or choosing a **Saved Point**.
2. **Calculate route** enables once both points are set. The computation runs in
a **Web Worker** (off the UI thread) with a live **progress bar**.
3. On completion the panel shows the **turn-by-turn** directions (*Head north*,
*Turn right*, *Arrive at destination*, ...) for **Drive** mode, plus distance,
estimated travel time, and the drawn route.
3. On completion the panel shows the **turn-by-turn** directions for **Drive**
mode, plus distance, estimated travel time, and the drawn route.
Turn-by-turn is **street-aware**: the routing graph stores road names, so steps
read like *"Head north on Pine St"*, *"Turn right onto 21st St"*, *"Sharp
left onto OK-20"* (falling back to a bare compass/turn label when a way is
unnamed). Tiny micro-jogs on a straight street are merged so you don't get noisy
phantom turns. The graph is **directed**: one-way streets and roundabouts are
respected, so routes don't travel the wrong way down a one-way road.
## Saved Points (browser-local)

View File

@ -48,7 +48,17 @@ export const DUMPS = [
// Road classes included in the routing graph. Major classes keep the graph
// small enough to fetch in the browser; service/paths/appended local roads
// are excluded by default.
graphHighways: ['motorway', 'trunk', 'primary', 'secondary', 'tertiary'],
// Road classes included in the routing graph. Includes local streets so
// street names appear in turn-by-turn instructions. One-way/roundabout
// handling below makes the graph directed where the data says so.
graphHighways: [
'motorway', 'motorway_link',
'trunk', 'trunk_link',
'primary', 'primary_link',
'secondary', 'secondary_link',
'tertiary', 'tertiary_link',
'unclassified', 'residential', 'living_street'
],
poiTags: {
amenity: ['school', 'fire_station', 'hospital', 'restaurant', 'cafe', 'fuel', 'parking'],
shop: ['supermarket', 'convenience'],
@ -111,6 +121,7 @@ async function buildFromDump(cfg) {
const nodeIdx = new Map(); // osm nodeId -> compact index
const coords = []; // coords[i] = [lat, lon]
const adj = []; // adj[i] = [[neighborIdx, meters], ...]
const roadNames = new Map(); // "a|b" -> street name (for turn-by-turn)
const poiTagSet = new Set(
Object.entries(cfg.poiTags).flatMap(([k, vals]) => vals.map((v) => `${k}=${v}`))
);
@ -171,16 +182,37 @@ async function buildFromDump(cfg) {
}
pts.push(nodeIdx.get(rid));
}
// Street name for turn-by-turn (name prefered, ref as fallback).
const streetName = it.tags?.name || it.tags?.ref || '';
// One-way? OSM oneway: yes/true/1 = forward (ref order), -1 = reverse.
// Roundabouts & circular junctions are one-way by convention.
const ow = String(it.tags?.oneway ?? '').toLowerCase();
const roundabout = it.tags?.junction === 'roundabout' || it.tags?.junction === 'circular';
const onewayFwd = roundabout || ['yes', 'true', '1'].includes(ow);
const onewayRev = ow === '-1';
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i];
const b = pts[i + 1];
if (a === b) continue;
const d = haversine(coords[a], coords[b]);
if (streetName) {
roadNames.set(a < b ? `${a}|${b}` : `${b}|${a}`, streetName);
}
// Two-way: add both directions. One-way: only the legal direction.
if (onewayRev) {
adj[b].push([a, d]); // travel opposite to ref order
} else if (onewayFwd) {
adj[a].push([b, d]); // travel in ref order
} else {
adj[a].push([b, d]);
adj[b].push([a, d]);
}
}
}
}
})
.on('end', resolvePromise)
.on('error', reject);
@ -204,7 +236,8 @@ async function buildFromDump(cfg) {
const graph = {
meta: { source: cfg.url, bbox: cfg.bbox, nodeCount: coords.length, edgeCount },
coords,
adj
adj,
edgeNames: Object.fromEntries(roadNames)
};
const graphPath = resolve(OUT_DIR, `${cfg.id}-graph.json`);
await writeFile(graphPath, JSON.stringify(graph));

View File

@ -173,6 +173,11 @@ async function run() {
check(turns.length >= 3, `buildTurnInstructions emits head + turn + arrive (got ${turns.length})`);
check(turns.some((t) => t.instruction.toLowerCase().includes('head')), 'has a "Head …" first step');
check(turns.some((t) => t.instruction.toLowerCase().includes('turn right')), 'has a "Turn right" maneuver for 90° turn');
// Street names appear when a name resolver is supplied.
const namedTurns = buildTurnInstructions(path, (i) => (i < 10 ? 'Pine St' : '21st St'));
check(namedTurns[0].instruction.includes('Pine St'), 'first step includes street name ("Head north on Pine St")');
check(namedTurns.some((t) => t.instruction.toLowerCase().includes('onto 21st st')), 'turn step includes destination street ("Turn right onto 21st St")');
check(turns[turns.length - 1].instruction === 'Arrive at destination', 'last step is "Arrive at destination"');
const { route } = await import(resolve(__dirname, '..', 'src', 'lib', 'routing.ts'));

View File

@ -23,6 +23,8 @@ export interface RouteGraph {
};
coords: [number, number][];
adj: [number, number][][];
/** Street names keyed by canonical edge key "minIdx|maxIdx", e.g. "Pine St". */
edgeNames?: Record<string, string>;
}
export interface AvoidRule {
@ -101,6 +103,22 @@ function compassLabel(deg: number): string {
return dirs[idx];
}
// Most common street name across segments [start, end] (a street can have a
// few unnamed/ref-typed segments, but the majority of a leg shares one name).
function dominantName(fn: (i: number) => string | undefined, start: number, end: number): string | undefined {
const counts = new Map<string, number>();
let best: string | undefined;
let bestN = 0;
for (let i = start; i <= end; i++) {
const n = fn(i);
if (!n) continue;
const c = (counts.get(n) ?? 0) + 1;
counts.set(n, c);
if (c > bestN) { bestN = c; best = n; }
}
return best;
}
function maneuverLabel(angle: number): string {
const a = Math.abs(angle);
if (a < 30) return 'Continue straight';
@ -114,7 +132,10 @@ function maneuverLabel(angle: number): string {
* Merges near-collinear segments into legs; emits a maneuver at each vertex
* where the heading change is significant.
*/
export function buildTurnInstructions(path: [number, number][]): TurnStep[] {
export function buildTurnInstructions(
path: [number, number][],
nameForSegment?: (segIndex: number) => string | undefined
): TurnStep[] {
if (path.length < 2) return [];
const MIN_TURN = 30; // degrees of heading change that count as a turn
@ -144,18 +165,39 @@ export function buildTurnInstructions(path: [number, number][]): TurnStep[] {
}
if (!legs.length) return [];
const steps: TurnStep[] = [];
// Merge very short legs into the previous one to avoid noisy micro-jog
// "turns" (a 20m zigzag on an otherwise straight street shouldn't produce
// multiple instructions).
const MIN_LEG_M = 45;
const merged: typeof legs = [];
for (let li = 0; li < legs.length; li++) {
const leg = legs[li];
// Never merge the final leg (it must remain a distinct arrival step).
const keepLast = li === legs.length - 1;
if (merged.length && leg.dist < MIN_LEG_M && !keepLast) {
// absorb into the preceding leg (extend its segment range)
const prev = merged[merged.length - 1];
prev.dist += leg.dist;
} else {
merged.push({ ...leg });
}
}
const steps: TurnStep[] = [];
function legEnd(li: number): number {
return li + 1 < merged.length ? merged[li + 1].segStart - 1 : segBearing.length - 1;
}
for (let li = 0; li < merged.length; li++) {
const leg = merged[li];
const bearing = segBearing[leg.segStart];
const name = nameForSegment ? dominantName(nameForSegment, leg.segStart, legEnd(li)) : undefined;
let instruction: string;
if (li === 0) {
instruction = `Head ${compassLabel(bearing)}`;
instruction = name ? `Head ${compassLabel(bearing)} on ${name}` : `Head ${compassLabel(bearing)}`;
} else {
// Turn angle from the previous leg's heading into this leg's heading.
const prevBearing = segBearing[legs[li - 1].segStart];
const prevBearing = segBearing[merged[li - 1].segStart];
const angle = turnAngle(prevBearing, bearing);
instruction = maneuverLabel(angle);
instruction = name ? `${maneuverLabel(angle)} onto ${name}` : maneuverLabel(angle);
}
steps.push({ distanceM: Math.round(leg.dist), instruction, bearing });
}
@ -410,6 +452,18 @@ function coordsFromPath(graph: RouteGraph, nodePath: number[]): [number, number]
return nodePath.map((i) => graph.coords[i] as [number, number]);
}
// Build a per-segment street-name lookup from a node path + graph.edgeNames.
// segment i runs from nodePath[i] to nodePath[i+1]; names are canonical bitch key.
function nameResolverFor(graph: RouteGraph, nodePath: number[]): (seg: number) => string | undefined {
const names = graph.edgeNames ?? {};
return (seg) => {
if (seg < 0 || seg >= nodePath.length - 1) return undefined;
const a = nodePath[seg];
const b = nodePath[seg + 1];
return names[a < b ? `${a}|${b}` : `${b}|${a}`];
};
}
export function route(
graph: RouteGraph,
fromLatLon: [number, number],
@ -441,13 +495,14 @@ export function route(
if (full) {
const path = coordsFromPath(graph, full.path);
const names = nameResolverFor(graph, full.path);
const result: RouteResult = {
...base,
found: true,
path,
distanceM: Math.round(full.distance),
nodesVisited: full.visited,
turns: buildTurnInstructions(path)
turns: buildTurnInstructions(path, names)
};
// Warn if the snap points are far from real roads (weak data near A/B).
if (fromSnapM > 800 || toSnapM > 800) {
@ -469,7 +524,7 @@ export function route(
distanceM: Math.round(partial.distance),
nodesVisited: partial.visited,
reason: 'Could not find a complete route (the destination appears disconnected or fully blocked). Showing the closest reachable point on the network.',
turns: buildTurnInstructions(path)
turns: buildTurnInstructions(path, nameResolverFor(graph, partial.path))
};
}
@ -551,7 +606,7 @@ export async function routeWithProgress(
const finish = (reached: boolean, nodePath: number[], distance: number): RouteResult => {
const path = coordsFromPath(graph, nodePath);
const res: RouteResult = { ...base, found: reached, path, distanceM: Math.round(distance), nodesVisited: visited };
if (reached) res.turns = buildTurnInstructions(path);
if (reached) res.turns = buildTurnInstructions(path, nameResolverFor(graph, nodePath));
if (fromSnapM > 800 || toSnapM > 800) {
res.partial = true;
res.reason = `The start/end is ${Math.max(fromSnapM, toSnapM) > 4000 ? 'far' : 'a bit'} from the routable road network (${Math.round(Math.max(fromSnapM, toSnapM))} m to nearest road). Route accuracy may be limited.`;
@ -601,7 +656,7 @@ export async function routeWithProgress(
const res = finish(false, pd.path, pd.distance);
res.partial = true;
res.reason = 'Could not find a complete route (the destination appears disconnected or fully blocked). Showing the closest reachable point on the network.';
res.turns = buildTurnInstructions(coordsFromPath(graph, pd.path));
res.turns = buildTurnInstructions(coordsFromPath(graph, pd.path), nameResolverFor(graph, pd.path));
return res;
}
return { ...base, reason: 'Could not find any route between these points.' };

View File

@ -1,16 +1,4 @@
[
{
"name": "tulsa-dump",
"label": "Tulsa OSM (dump)",
"category": "dump",
"layerType": "poi",
"color": "#8b5cf6",
"featureCount": 12853,
"path": "/data/tulsa-dump-poi.geojson",
"poiPath": "/data/tulsa-dump-poi.geojson",
"graphPath": "/data/tulsa-dump-graph.json",
"graphNodeCount": 55334
},
{
"name": "tulsa",
"label": "Tulsa, Oklahoma",
@ -60,5 +48,17 @@
"color": "#f59e0b",
"featureCount": 7,
"path": "/data/custom/rogers-county.geojson"
},
{
"name": "tulsa-dump",
"label": "Tulsa OSM (dump)",
"category": "dump",
"layerType": "poi",
"color": "#8b5cf6",
"featureCount": 12853,
"path": "/data/tulsa-dump-poi.geojson",
"poiPath": "/data/tulsa-dump-poi.geojson",
"graphPath": "/data/tulsa-dump-graph.json",
"graphNodeCount": 225956
}
]

File diff suppressed because one or more lines are too long