Covers routes/activities that enter the journal without BRouter waytags (imports, uploads, pre-existing rows): - overpass-ways.server.ts: server-side Overpass client (way[highway] + geom in a bbox, configurable OVERPASS_URLS, timeout + oversized-bbox guard). - surface-match.server.ts: pure nearest-way map-matcher → per-segment surface/highway (unmatched → unknown). Unit-tested. - surface-backfill pg-boss job: load geom → skip if breakdown exists → Overpass → match → computeSurfaceBreakdown → store → emit `surface_breakdown` SSE to the owner. Idempotent, retry-safe, best-effort; registered in server.ts. - Enqueued from createActivity (imports/uploads) + owner-on-open in the route & activity detail loaders (non-Planner routes, old rows), deduped via singletonKey. - useSurfaceBackfillUpdates: detail pages subscribe to /api/events and revalidate() when their row's backfill lands (live bars, no reload). - Privacy manifest updated (DE + EN) for the Overpass bbox lookup. Tests: matchSurfaces unit; surface-backfill job (mocked Overpass/db/events: store + emit, skip-if-present, skip-if-no-ways). Verified the real Overpass→match→breakdown pipeline against a live Berlin bbox (509 ways → plausible asphalt/paving_stones/footway mix). typecheck + lint + unit (journal 333) green. Note: the worker runs via server.ts (prod/staging), not `react-router dev`, so the live job + SSE exercise on a deployed instance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
3 KiB
TypeScript
84 lines
3 KiB
TypeScript
// Server-side Overpass client for the surface backfill: fetches highway ways
|
|
// (with their `surface` tag + geometry) in a bbox so a route/activity can be
|
|
// map-matched. Runs only in the backfill job, never in a request path.
|
|
// Privacy: this sends the route's bounding box to the upstream Overpass — see
|
|
// route-surface-breakdown design §D5; `OVERPASS_URLS` can point at a
|
|
// self-hosted instance to keep it in-house.
|
|
|
|
export interface OverpassWay {
|
|
highway?: string;
|
|
surface?: string;
|
|
/** Polyline nodes, in order. */
|
|
geometry: Array<{ lat: number; lon: number }>;
|
|
}
|
|
|
|
export interface Bbox {
|
|
south: number;
|
|
west: number;
|
|
north: number;
|
|
east: number;
|
|
}
|
|
|
|
const DEFAULT_UPSTREAMS = [
|
|
"https://lz4.overpass-api.de/api/interpreter",
|
|
"https://overpass-api.de/api/interpreter",
|
|
];
|
|
|
|
function upstreams(): string[] {
|
|
const env = process.env.OVERPASS_URLS ?? process.env.OVERPASS_URL;
|
|
return env ? env.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_UPSTREAMS;
|
|
}
|
|
|
|
// Skip absurdly large bboxes — Overpass would time out / return too much, and a
|
|
// huge box means the route is so long that per-segment matching is unreliable
|
|
// anyway. ~0.25 deg² (roughly a 50 km square at mid latitudes).
|
|
const MAX_BBOX_DEG2 = 0.25;
|
|
const USER_AGENT = "trails.cool Journal (https://trails.cool; legal@trails.cool)";
|
|
|
|
/**
|
|
* Fetch highway ways within `bbox`. Returns `[]` for an empty/oversized bbox
|
|
* (caller treats that as "no data, don't store"); throws if every upstream
|
|
* fails so the job retries.
|
|
*/
|
|
export async function fetchWaysInBbox(
|
|
bbox: Bbox,
|
|
opts: { timeoutMs?: number } = {},
|
|
): Promise<OverpassWay[]> {
|
|
const area = Math.abs(bbox.north - bbox.south) * Math.abs(bbox.east - bbox.west);
|
|
if (!(area > 0) || area > MAX_BBOX_DEG2) return [];
|
|
|
|
const query = `[out:json][timeout:25];way["highway"](${bbox.south},${bbox.west},${bbox.north},${bbox.east});out tags geom;`;
|
|
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
|
|
let lastError: unknown;
|
|
for (const url of upstreams()) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const resp = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "text/plain", "User-Agent": USER_AGENT },
|
|
body: query,
|
|
signal: controller.signal,
|
|
});
|
|
if (!resp.ok) {
|
|
lastError = new Error(`overpass ${resp.status}`);
|
|
continue;
|
|
}
|
|
const json = (await resp.json()) as {
|
|
elements?: Array<{ type: string; tags?: Record<string, string>; geometry?: Array<{ lat: number; lon: number }> }>;
|
|
};
|
|
const ways: OverpassWay[] = [];
|
|
for (const el of json.elements ?? []) {
|
|
if (el.type !== "way" || !el.geometry || el.geometry.length < 2) continue;
|
|
ways.push({ highway: el.tags?.highway, surface: el.tags?.surface, geometry: el.geometry });
|
|
}
|
|
return ways;
|
|
} catch (e) {
|
|
lastError = e;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
throw new Error(`overpass: all upstreams failed (${(lastError as Error)?.message ?? "unknown"})`);
|
|
}
|