/* eslint-disable no-console */ /** * Generates app/features/navigation/data/exitJunctions.json — the bundled, offline highway-exit table * (exit numbers for the turn-by-turn banner and voice, decision 0047). * * yarn exits:gen (ts-node --project tsconfig.node.json scripts/genExitJunctions.ts) * * Re-run when the routing graph or the map tiles are rebuilt (owner-todos), review the stderr report, * commit the JSON. * * Source: OpenStreetMap via the public Overpass API — every node tagged highway=motorway_junction * (the editors' "Motorway Exit") inside the routing graph's bounding box (gh.openrydr.app /info), * fetched as a grid of tiles one at a time. `ref` is the exit number ("171", "205B"); `noref=yes` * marks a split with no number and is kept as "" so a lookup there answers "no number" rather than * borrowing a neighbour's. * * LICENCE: this table is OSM data only, so it is an ODbL database (© OpenStreetMap contributors, * credited in Settings like the map tiles). Keep it a separate file; never merge it into a non-OSM * table (erHospitals.json stays OSM-free on purpose). * * Nothing about a rider is sent anywhere: this runs at build time, and the app looks exits up on-device. */ import { writeFileSync } from 'fs'; import { join } from 'path'; import type { ExitJunctionTable, ExitJunctionRow } from '../app/features/navigation/exitJunctionsCore'; const OVERPASS = 'https://overpass-api.de/api/interpreter'; // gh.openrydr.app /info bbox (2026-11-07, after the Wyoming rebuild): [minLng, minLat, maxLng, maxLat]. Re-read it if the graph's // region changes; exits outside the graph can never be on a route. const BBOX: [number, number, number, number] = [-111.196743, 40.36615, -86.291898, 49.406667]; const TILE_DEG = 3; const UA = 'OpenRydr-exit-table-generator/1.0 (build-time, one request at a time)'; interface OverpassNode { type: 'node'; id: number; lat: number; lon: number; tags?: Record } async function fetchTile(s: number, w: number, n: number, e: number): Promise<{ nodes: OverpassNode[]; osmBase: string | null }> { const q = `[out:json][timeout:90];node[highway=motorway_junction](${s},${w},${n},${e});out;`; for (let attempt = 1; attempt <= 6; attempt++) { try { const res = await fetch(OVERPASS, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA }, body: `data=${encodeURIComponent(q)}`, signal: AbortSignal.timeout(120_000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json() as { elements: OverpassNode[]; remark?: string; osm3s?: { timestamp_osm_base?: string } }; // A query that runs out of time/memory returns 200 with a remark and partial/empty elements. if (json.remark) throw new Error(`remark: ${json.remark}`); return { nodes: json.elements, osmBase: json.osm3s?.timestamp_osm_base ?? null }; } catch (err) { console.error(` tile ${s},${w} attempt ${attempt} failed: ${String(err)}`); await new Promise(r => setTimeout(r, 15_000 * attempt)); // 502/504/429 = busy server: back off } } throw new Error(`tile ${s},${w},${n},${e} failed after 6 attempts`); } async function main(): Promise { const [minLng, minLat, maxLng, maxLat] = BBOX; const byId = new Map(); let osmBase: string | null = null; // Even tiles of at most TILE_DEG (no thin leftover strip at the edge). const nLat = Math.ceil((maxLat - minLat) / TILE_DEG), nLng = Math.ceil((maxLng - minLng) / TILE_DEG); const dLat = (maxLat - minLat) / nLat, dLng = (maxLng - minLng) / nLng; for (let i = 0; i < nLat; i++) { for (let j = 0; j < nLng; j++) { const s = minLat + i * dLat, w = minLng + j * dLng, n = s + dLat, e = w + dLng; const t = await fetchTile(+s.toFixed(5), +w.toFixed(5), +n.toFixed(5), +e.toFixed(5)); for (const node of t.nodes) byId.set(node.id, node); osmBase = osmBase ?? t.osmBase; console.error(`tile ${s.toFixed(2)},${w.toFixed(2)}: ${t.nodes.length} junctions (total ${byId.size})`); await new Promise(r => setTimeout(r, 2_000)); // be polite to the shared public server } } const rows: ExitJunctionRow[] = []; let numbered = 0, noref = 0, skipped = 0; for (const node of byId.values()) { const ref = node.tags?.ref?.trim() ?? ''; const isNoref = node.tags?.noref === 'yes'; if (!ref && !isNoref) { skipped++; continue; } // untagged: no number and no "no number" claim if (ref) numbered++; else noref++; rows.push([+node.lat.toFixed(5), +node.lon.toFixed(5), ref]); } rows.sort((a, b) => a[0] - b[0] || a[1] - b[1]); const table: ExitJunctionTable = { source: { osm: 'OpenStreetMap contributors, ODbL 1.0 — highway=motorway_junction via Overpass', osmBase, generatedAt: new Date().toISOString().slice(0, 10), bbox: BBOX, }, rows, }; const out = join(__dirname, '..', 'app', 'features', 'navigation', 'data', 'exitJunctions.json'); // One row per line keeps diffs readable when the table is refreshed. const body = `{\n "source": ${JSON.stringify(table.source)},\n "rows": [\n${rows.map(r => ` ${JSON.stringify(r)}`).join(',\n')}\n ]\n}\n`; writeFileSync(out, body); console.error(`\nwrote ${rows.length} rows (${numbered} numbered, ${noref} noref; ${skipped} junctions with neither skipped), ${(body.length / 1024).toFixed(0)} KB → ${out}`); } main().catch(err => { console.error(err); process.exit(1); });