Skip to content

Commit 76c69ff

Browse files
committed
fix: route on dirt trails, mountain bike paths, and park service roads
The OSM fetch and routing graph only allowed standard road highway types, so trails never entered the graph or the Strava ridden-road matching. Added a shared highway-type filter (lib/highwayFilter.ts): path/track/bridleway always routable, footway routable unless tagged as a sidewalk/crossing or closed to bikes, service routable unless it's a driveway/alley/parking_aisle/emergency_access. Also fixes two Overpass reliability bugs surfaced while debugging: a 509 rate-limit response was misclassified as a hard failure (5min circuit-open) instead of transient (1min), and the ridden-roads background recompute had no pacing between tile fetches, letting it trip the self-hosted instance's rate limit and starve interactive routing requests.
1 parent c08d06d commit 76c69ff

8 files changed

Lines changed: 119 additions & 26 deletions

File tree

‎app/api/ridden-roads/route.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { roadsFromOSM } from '@/lib/roadsFromOSM';
66
import { prisma } from '@/lib/prisma';
77

88
const ts = () => `[${new Date().toTimeString().slice(0, 8)}]`;
9-
const RIDDEN_VERSION = 1;
9+
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
10+
const RIDDEN_VERSION = 3; // bumped: highway=service (park maintenance/multi-use paths) now included in the road fetch
1011
const FRESH_TTL_MS = 24 * 60 * 60 * 1000;
1112
const TILE = 0.02; // ~2.2km tiles to gather OSM roads over the riding footprint
1213
// Guard against a runaway precompute. Self-hosted Overpass (OVERPASS_URL) has
@@ -39,6 +40,11 @@ async function compute(riddenRoads: [number, number][][]): Promise<[number, numb
3940
console.warn(`${ts()} RiddenRoads: tile ${t} failed: ${e.message}`);
4041
}
4142
if (++i % 25 === 0) console.log(`${ts()} RiddenRoads: fetched ${i}/${tiles.size} tiles (${failed} failed)`);
43+
// Small pacing delay — a full recompute (e.g. after a cache-version bump) can hit
44+
// hundreds/thousands of tiles back-to-back and trip the Overpass instance's own
45+
// rate limit (509), which cascades into blocking interactive routing for everyone
46+
// via the shared circuit breaker. This keeps the background job well under that.
47+
await delay(75);
4248
}
4349

4450
// Don't cache a badly incomplete overlay (e.g. Overpass down) — throw so the

‎lib/graph.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import createGraph, { Graph } from 'ngraph.graph';
44
import path from 'ngraph.path';
55

66
import { OSMWay, OSMNode, OverpassResponse } from './types';
7+
import { isRoutableHighway } from './highwayFilter';
78

89
interface NodeData {
910
lat: number;
@@ -223,12 +224,8 @@ export class StreetGraph {
223224

224225
const highway = way.tags?.highway;
225226

226-
// Reject non-routable highway types regardless of data source (cache may contain
227-
// footways/paths from an earlier fetch before the OSM API filter was added).
228-
const ROUTABLE_HIGHWAYS = new Set(['motorway','trunk','primary','secondary','tertiary',
229-
'unclassified','residential','living_street','motorway_link','trunk_link',
230-
'primary_link','secondary_link','tertiary_link','track','cycleway']);
231-
if (!highway || !ROUTABLE_HIGHWAYS.has(highway)) continue;
227+
// Reject non-routable highway types regardless of data source.
228+
if (!isRoutableHighway(highway, way.tags)) continue;
232229

233230
// SAFETY: Exclude actual motorway lanes — cyclists cannot ride on them.
234231
// trunk is kept (with isAvoided=true) so divided-highway crossings remain connected.
@@ -247,7 +244,7 @@ export class StreetGraph {
247244
if (options?.avoidHighways && majorHighways.includes(highway || '')) {
248245
isAvoided = true;
249246
}
250-
if (!isAvoided && options?.avoidTrails && ['path', 'track', 'footway', 'cycleway'].includes(highway || '')) {
247+
if (!isAvoided && options?.avoidTrails && ['path', 'track', 'footway', 'cycleway', 'bridleway'].includes(highway || '')) {
251248
isAvoided = true;
252249
}
253250
if (!isAvoided && options?.avoidGravel) {

‎lib/highwayFilter.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { isRoutableHighway } from './highwayFilter';
2+
3+
describe('isRoutableHighway', () => {
4+
it('allows standard road types', () => {
5+
expect(isRoutableHighway('residential')).toBe(true);
6+
expect(isRoutableHighway('primary')).toBe(true);
7+
expect(isRoutableHighway('cycleway')).toBe(true);
8+
expect(isRoutableHighway('track')).toBe(true);
9+
});
10+
11+
it('allows dirt trails, singletrack, and bridleways unconditionally', () => {
12+
expect(isRoutableHighway('path')).toBe(true);
13+
expect(isRoutableHighway('bridleway')).toBe(true);
14+
expect(isRoutableHighway('path', { surface: 'dirt' })).toBe(true);
15+
expect(isRoutableHighway('path', { mtb_scale: '2' })).toBe(true);
16+
});
17+
18+
it('allows plain footway (standalone park/trail paths) but excludes sidewalks and crossings', () => {
19+
expect(isRoutableHighway('footway')).toBe(true);
20+
expect(isRoutableHighway('footway', {})).toBe(true);
21+
expect(isRoutableHighway('footway', { surface: 'dirt' })).toBe(true);
22+
expect(isRoutableHighway('footway', { footway: 'sidewalk' })).toBe(false);
23+
expect(isRoutableHighway('footway', { footway: 'crossing' })).toBe(false);
24+
});
25+
26+
it('excludes footway explicitly closed to bikes', () => {
27+
expect(isRoutableHighway('footway', { bicycle: 'no' })).toBe(false);
28+
expect(isRoutableHighway('footway', { bicycle: 'private' })).toBe(false);
29+
expect(isRoutableHighway('footway', { bicycle: 'yes' })).toBe(true);
30+
expect(isRoutableHighway('footway', { bicycle: 'designated' })).toBe(true);
31+
});
32+
33+
it('allows plain service ways (park maintenance/multi-use paths) but excludes driveways, alleys, and parking aisles', () => {
34+
expect(isRoutableHighway('service')).toBe(true);
35+
expect(isRoutableHighway('service', {})).toBe(true);
36+
expect(isRoutableHighway('service', { surface: 'compacted' })).toBe(true);
37+
expect(isRoutableHighway('service', { service: 'alley' })).toBe(false);
38+
expect(isRoutableHighway('service', { service: 'driveway' })).toBe(false);
39+
expect(isRoutableHighway('service', { service: 'parking_aisle' })).toBe(false);
40+
expect(isRoutableHighway('service', { service: 'emergency_access' })).toBe(false);
41+
});
42+
43+
it('rejects unknown or missing highway types', () => {
44+
expect(isRoutableHighway(undefined)).toBe(false);
45+
expect(isRoutableHighway('steps')).toBe(false);
46+
expect(isRoutableHighway('pedestrian')).toBe(false);
47+
});
48+
});

‎lib/highwayFilter.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Highway types considered part of the routable street network. `path`,
2+
// `track`, and `bridleway` are always included — OSM convention doesn't use
3+
// them for sidewalks, so this is how dirt trails, singletrack, forest roads,
4+
// and rail-trails get in.
5+
const BASE_ROUTABLE_HIGHWAYS = new Set([
6+
'motorway', 'trunk', 'primary', 'secondary', 'tertiary',
7+
'unclassified', 'residential', 'living_street', 'motorway_link', 'trunk_link',
8+
'primary_link', 'secondary_link', 'tertiary_link', 'track', 'cycleway', 'path', 'bridleway',
9+
]);
10+
11+
// `footway` is ambiguous: it covers both standalone park/trail paths (almost
12+
// never tagged with `bicycle`) and sidewalks running alongside a road. The
13+
// `footway=sidewalk`/`crossing` subtag is the reliable signal for "this is a
14+
// sidewalk" — plain `footway` with no subtag is treated as a trail.
15+
const SIDEWALK_SUBTYPES = new Set(['sidewalk', 'crossing']);
16+
const BIKE_FORBIDDEN_TAGS = new Set(['no', 'private']);
17+
18+
// `service` is similarly ambiguous: park maintenance/multi-use paths are
19+
// commonly tagged plain `highway=service` (no subtype) instead of `path` —
20+
// this is how paved/compacted paths inside parks (e.g. High Bridge Park's
21+
// interior network) get in. The `service` subtag is the reliable signal for
22+
// "this is functional property access, not a path" — driveways, alleys, and
23+
// parking aisles stay excluded.
24+
const SERVICE_EXCLUDED_SUBTYPES = new Set(['alley', 'driveway', 'parking_aisle', 'emergency_access']);
25+
26+
export function isRoutableHighway(highway: string | undefined, tags?: Record<string, string>): boolean {
27+
if (!highway) return false;
28+
if (highway === 'footway') {
29+
if (SIDEWALK_SUBTYPES.has(tags?.footway ?? '')) return false;
30+
if (BIKE_FORBIDDEN_TAGS.has(tags?.bicycle ?? '')) return false;
31+
return true;
32+
}
33+
if (highway === 'service') {
34+
return !SERVICE_EXCLUDED_SUBTYPES.has(tags?.service ?? '');
35+
}
36+
return BASE_ROUTABLE_HIGHWAYS.has(highway);
37+
}

‎lib/osmDbCache.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('OSM DB cache', () => {
6060

6161
it('returns DB-cached data without hitting the network', async () => {
6262
mockFindUnique.mockResolvedValue({
63-
key: 'v4_47.6000,-117.5000,47.7000,-117.3000',
63+
key: 'v6_47.6000,-117.5000,47.7000,-117.3000',
6464
data: mockOsmData,
6565
fetchedAt: new Date()
6666
});
@@ -87,7 +87,7 @@ describe('OSM DB cache', () => {
8787
it('falls through to network when DB cache is expired', async () => {
8888
const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
8989
mockFindUnique.mockResolvedValue({
90-
key: 'v4_47.6000,-117.5000,47.7000,-117.3000',
90+
key: 'v6_47.6000,-117.5000,47.7000,-117.3000',
9191
data: mockOsmData,
9292
fetchedAt: thirtyOneDaysAgo
9393
});
@@ -114,14 +114,14 @@ describe('OSM DB cache', () => {
114114
// Allow fire-and-forget upsert to resolve
115115
await new Promise(resolve => setTimeout(resolve, 10));
116116
expect(mockUpsert).toHaveBeenCalledWith(expect.objectContaining({
117-
where: { key: expect.stringContaining('v4_') },
117+
where: { key: expect.stringContaining('v6_') },
118118
create: expect.objectContaining({ data: mockOsmData })
119119
}));
120120
});
121121

122122
it('warms memory cache from DB so second call skips both DB and network', async () => {
123123
mockFindUnique.mockResolvedValueOnce({
124-
key: 'v4_47.6000,-117.5000,47.7000,-117.3000',
124+
key: 'v6_47.6000,-117.5000,47.7000,-117.3000',
125125
data: mockOsmData,
126126
fetchedAt: new Date()
127127
});

‎lib/overpass.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BoundingBox, OverpassResponse, OSMElement } from './types';
2+
import { isRoutableHighway } from './highwayFilter';
23
import { prisma } from './prisma';
34
import { readDiskCache, writeDiskCache } from './osmDiskCache';
45

@@ -81,11 +82,7 @@ function parseOSMXML(xml: string): OverpassResponse {
8182
}
8283
}
8384

84-
// Parse ways — must match the highway types in the Overpass query
85-
const ALLOWED_HIGHWAYS = new Set(['motorway','trunk','primary','secondary','tertiary',
86-
'unclassified','residential','living_street','motorway_link','trunk_link',
87-
'primary_link','secondary_link','tertiary_link','track','cycleway']);
88-
85+
// Parse ways — must match the highway types allowed by the Overpass query
8986
const wayRe = /<way\b([^>]*)>([\s\S]*?)<\/way>/g;
9087
while ((m = wayRe.exec(xml)) !== null) {
9188
const attrs = m[1];
@@ -98,7 +95,7 @@ function parseOSMXML(xml: string): OverpassResponse {
9895
let t: RegExpExecArray | null;
9996
while ((t = tagRe.exec(body)) !== null) tags[t[1]] = t[2];
10097

101-
if (!tags['highway'] || !ALLOWED_HIGHWAYS.has(tags['highway'])) continue;
98+
if (!isRoutableHighway(tags['highway'], tags)) continue;
10299
if (tags['access'] === 'private' || tags['access'] === 'no') continue;
103100

104101
const nodes: number[] = [];
@@ -347,7 +344,7 @@ export async function fetchOSMData(requestedBbox: BoundingBox): Promise<Overpass
347344
// Tile coords are already multiples of TILE_DEG; format to fixed precision for a stable key.
348345
const k = (n: number) => n.toFixed(4);
349346
// v4: fine-grained tile-aligned cache keys (0.005° tiles, ~500m). Old v3 entries ignored.
350-
const cacheKey = `v4_${k(bbox.south)},${k(bbox.west)},${k(bbox.north)},${k(bbox.east)}`;
347+
const cacheKey = `v6_${k(bbox.south)},${k(bbox.west)},${k(bbox.north)},${k(bbox.east)}`;
351348
const now = Date.now();
352349

353350
// For medium/large areas, reject cached responses that are clearly incomplete.
@@ -410,9 +407,17 @@ export async function fetchOSMData(requestedBbox: BoundingBox): Promise<Overpass
410407
const requestPromise = (async () => {
411408
const bikeQuery = `
412409
[out:json][timeout:90];
413-
way["highway"~"motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|track|cycleway"]
414-
["access"!~"private|no"]
415-
(${bbox.south},${bbox.west},${bbox.north},${bbox.east});
410+
(
411+
way["highway"~"motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|track|cycleway|path|bridleway"]
412+
["access"!~"private|no"]
413+
(${bbox.south},${bbox.west},${bbox.north},${bbox.east});
414+
way["highway"="footway"]["footway"!~"sidewalk|crossing"]["bicycle"!~"no|private"]
415+
["access"!~"private|no"]
416+
(${bbox.south},${bbox.west},${bbox.north},${bbox.east});
417+
way["highway"="service"]["service"!~"alley|driveway|parking_aisle|emergency_access"]
418+
["access"!~"private|no"]
419+
(${bbox.south},${bbox.west},${bbox.north},${bbox.east});
420+
);
416421
out geom;
417422
`;
418423

@@ -472,7 +477,7 @@ export async function fetchOSMData(requestedBbox: BoundingBox): Promise<Overpass
472477
return data;
473478
}
474479

475-
if (response.status === 504 || response.status === 429) {
480+
if (response.status === 504 || response.status === 429 || response.status === 509) {
476481
console.warn(`${ts()} Endpoint ${endpoint} failed with ${response.status}. Trying next...`);
477482
lastError = new Error(`Overpass API error: ${response.status}`);
478483
recordFailure(endpoint, true); // transient — short circuit TTL

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "street-sweep",
3-
"version": "0.6.45",
3+
"version": "0.6.46",
44
"private": true,
55
"scripts": {
66
"dev": "next dev -p 3888",

0 commit comments

Comments
 (0)