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**.
|
||||
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)
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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.' };
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user