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
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:
parent
397979ac78
commit
fbf939d236
12
README.md
12
README.md
@ -254,9 +254,15 @@ From the opened **Directions** panel:
|
|||||||
or choosing a **Saved Point**.
|
or choosing a **Saved Point**.
|
||||||
2. **Calculate route** enables once both points are set. The computation runs in
|
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**.
|
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*,
|
3. On completion the panel shows the **turn-by-turn** directions for **Drive**
|
||||||
*Turn right*, *Arrive at destination*, ...) for **Drive** mode, plus distance,
|
mode, plus distance, estimated travel time, and the drawn route.
|
||||||
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)
|
## Saved Points (browser-local)
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,17 @@ export const DUMPS = [
|
|||||||
// Road classes included in the routing graph. Major classes keep the graph
|
// Road classes included in the routing graph. Major classes keep the graph
|
||||||
// small enough to fetch in the browser; service/paths/appended local roads
|
// small enough to fetch in the browser; service/paths/appended local roads
|
||||||
// are excluded by default.
|
// 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: {
|
poiTags: {
|
||||||
amenity: ['school', 'fire_station', 'hospital', 'restaurant', 'cafe', 'fuel', 'parking'],
|
amenity: ['school', 'fire_station', 'hospital', 'restaurant', 'cafe', 'fuel', 'parking'],
|
||||||
shop: ['supermarket', 'convenience'],
|
shop: ['supermarket', 'convenience'],
|
||||||
@ -111,6 +121,7 @@ async function buildFromDump(cfg) {
|
|||||||
const nodeIdx = new Map(); // osm nodeId -> compact index
|
const nodeIdx = new Map(); // osm nodeId -> compact index
|
||||||
const coords = []; // coords[i] = [lat, lon]
|
const coords = []; // coords[i] = [lat, lon]
|
||||||
const adj = []; // adj[i] = [[neighborIdx, meters], ...]
|
const adj = []; // adj[i] = [[neighborIdx, meters], ...]
|
||||||
|
const roadNames = new Map(); // "a|b" -> street name (for turn-by-turn)
|
||||||
const poiTagSet = new Set(
|
const poiTagSet = new Set(
|
||||||
Object.entries(cfg.poiTags).flatMap(([k, vals]) => vals.map((v) => `${k}=${v}`))
|
Object.entries(cfg.poiTags).flatMap(([k, vals]) => vals.map((v) => `${k}=${v}`))
|
||||||
);
|
);
|
||||||
@ -171,13 +182,34 @@ async function buildFromDump(cfg) {
|
|||||||
}
|
}
|
||||||
pts.push(nodeIdx.get(rid));
|
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++) {
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
const a = pts[i];
|
const a = pts[i];
|
||||||
const b = pts[i + 1];
|
const b = pts[i + 1];
|
||||||
if (a === b) continue;
|
if (a === b) continue;
|
||||||
const d = haversine(coords[a], coords[b]);
|
const d = haversine(coords[a], coords[b]);
|
||||||
adj[a].push([b, d]);
|
if (streetName) {
|
||||||
adj[b].push([a, d]);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -204,7 +236,8 @@ async function buildFromDump(cfg) {
|
|||||||
const graph = {
|
const graph = {
|
||||||
meta: { source: cfg.url, bbox: cfg.bbox, nodeCount: coords.length, edgeCount },
|
meta: { source: cfg.url, bbox: cfg.bbox, nodeCount: coords.length, edgeCount },
|
||||||
coords,
|
coords,
|
||||||
adj
|
adj,
|
||||||
|
edgeNames: Object.fromEntries(roadNames)
|
||||||
};
|
};
|
||||||
const graphPath = resolve(OUT_DIR, `${cfg.id}-graph.json`);
|
const graphPath = resolve(OUT_DIR, `${cfg.id}-graph.json`);
|
||||||
await writeFile(graphPath, JSON.stringify(graph));
|
await writeFile(graphPath, JSON.stringify(graph));
|
||||||
|
|||||||
@ -173,6 +173,11 @@ async function run() {
|
|||||||
check(turns.length >= 3, `buildTurnInstructions emits head + turn + arrive (got ${turns.length})`);
|
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('head')), 'has a "Head …" first step');
|
||||||
check(turns.some((t) => t.instruction.toLowerCase().includes('turn right')), 'has a "Turn right" maneuver for 90° turn');
|
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"');
|
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'));
|
const { route } = await import(resolve(__dirname, '..', 'src', 'lib', 'routing.ts'));
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export interface RouteGraph {
|
|||||||
};
|
};
|
||||||
coords: [number, number][];
|
coords: [number, number][];
|
||||||
adj: [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 {
|
export interface AvoidRule {
|
||||||
@ -101,6 +103,22 @@ function compassLabel(deg: number): string {
|
|||||||
return dirs[idx];
|
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 {
|
function maneuverLabel(angle: number): string {
|
||||||
const a = Math.abs(angle);
|
const a = Math.abs(angle);
|
||||||
if (a < 30) return 'Continue straight';
|
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
|
* Merges near-collinear segments into legs; emits a maneuver at each vertex
|
||||||
* where the heading change is significant.
|
* 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 [];
|
if (path.length < 2) return [];
|
||||||
const MIN_TURN = 30; // degrees of heading change that count as a turn
|
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 [];
|
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++) {
|
for (let li = 0; li < legs.length; li++) {
|
||||||
const leg = legs[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 bearing = segBearing[leg.segStart];
|
||||||
|
const name = nameForSegment ? dominantName(nameForSegment, leg.segStart, legEnd(li)) : undefined;
|
||||||
let instruction: string;
|
let instruction: string;
|
||||||
if (li === 0) {
|
if (li === 0) {
|
||||||
instruction = `Head ${compassLabel(bearing)}`;
|
instruction = name ? `Head ${compassLabel(bearing)} on ${name}` : `Head ${compassLabel(bearing)}`;
|
||||||
} else {
|
} else {
|
||||||
// Turn angle from the previous leg's heading into this leg's heading.
|
// 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);
|
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 });
|
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]);
|
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(
|
export function route(
|
||||||
graph: RouteGraph,
|
graph: RouteGraph,
|
||||||
fromLatLon: [number, number],
|
fromLatLon: [number, number],
|
||||||
@ -441,13 +495,14 @@ export function route(
|
|||||||
|
|
||||||
if (full) {
|
if (full) {
|
||||||
const path = coordsFromPath(graph, full.path);
|
const path = coordsFromPath(graph, full.path);
|
||||||
|
const names = nameResolverFor(graph, full.path);
|
||||||
const result: RouteResult = {
|
const result: RouteResult = {
|
||||||
...base,
|
...base,
|
||||||
found: true,
|
found: true,
|
||||||
path,
|
path,
|
||||||
distanceM: Math.round(full.distance),
|
distanceM: Math.round(full.distance),
|
||||||
nodesVisited: full.visited,
|
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).
|
// Warn if the snap points are far from real roads (weak data near A/B).
|
||||||
if (fromSnapM > 800 || toSnapM > 800) {
|
if (fromSnapM > 800 || toSnapM > 800) {
|
||||||
@ -469,7 +524,7 @@ export function route(
|
|||||||
distanceM: Math.round(partial.distance),
|
distanceM: Math.round(partial.distance),
|
||||||
nodesVisited: partial.visited,
|
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.',
|
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 finish = (reached: boolean, nodePath: number[], distance: number): RouteResult => {
|
||||||
const path = coordsFromPath(graph, nodePath);
|
const path = coordsFromPath(graph, nodePath);
|
||||||
const res: RouteResult = { ...base, found: reached, path, distanceM: Math.round(distance), nodesVisited: visited };
|
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) {
|
if (fromSnapM > 800 || toSnapM > 800) {
|
||||||
res.partial = true;
|
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.`;
|
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);
|
const res = finish(false, pd.path, pd.distance);
|
||||||
res.partial = true;
|
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.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 res;
|
||||||
}
|
}
|
||||||
return { ...base, reason: 'Could not find any route between these points.' };
|
return { ...base, reason: 'Could not find any route between these points.' };
|
||||||
|
|||||||
@ -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",
|
"name": "tulsa",
|
||||||
"label": "Tulsa, Oklahoma",
|
"label": "Tulsa, Oklahoma",
|
||||||
@ -60,5 +48,17 @@
|
|||||||
"color": "#f59e0b",
|
"color": "#f59e0b",
|
||||||
"featureCount": 7,
|
"featureCount": 7,
|
||||||
"path": "/data/custom/rogers-county.geojson"
|
"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
Loading…
x
Reference in New Issue
Block a user