trails/apps/journal/app/lib/surface-match.server.ts
Ullrich Schäfer 279734c607
route-surface-breakdown (Phase 2): async Overpass backfill + SSE
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>
2026-06-14 19:18:03 +02:00

59 lines
1.9 KiB
TypeScript

import type { OverpassWay } from "./overpass-ways.server.ts";
/** Squared distance from point P to segment AB, in (degree) units — only used
* for *comparing* candidates, so the planar approximation is fine at the scale
* of a route bbox. */
function distToSegmentSq(
px: number, py: number,
ax: number, ay: number,
bx: number, by: number,
): number {
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
let t = lenSq > 0 ? ((px - ax) * dx + (py - ay) * dy) / lenSq : 0;
t = Math.max(0, Math.min(1, t));
const cx = ax + t * dx;
const cy = ay + t * dy;
return (px - cx) ** 2 + (py - cy) ** 2;
}
/**
* Map-match each route segment to the nearest OSM way and read its
* surface/highway. `coords` are `[lon, lat, ...]`; returns `surfaces[i]` /
* `highways[i]` for the segment from `coords[i]` to `coords[i+1]`
* (length `coords.length - 1`). Segments with no candidate way → `"unknown"`.
* Feed the result through `computeSurfaceBreakdown` to get the distribution.
*/
export function matchSurfaces(
coords: ReadonlyArray<readonly number[]>,
ways: readonly OverpassWay[],
): { surfaces: string[]; highways: string[] } {
const surfaces: string[] = [];
const highways: string[] = [];
for (let i = 0; i < coords.length - 1; i++) {
const a = coords[i]!;
const b = coords[i + 1]!;
const mx = (a[0]! + b[0]!) / 2; // lon
const my = (a[1]! + b[1]!) / 2; // lat
let best: OverpassWay | null = null;
let bestDist = Infinity;
for (const way of ways) {
const g = way.geometry;
for (let j = 0; j < g.length - 1; j++) {
const d = distToSegmentSq(mx, my, g[j]!.lon, g[j]!.lat, g[j + 1]!.lon, g[j + 1]!.lat);
if (d < bestDist) {
bestDist = d;
best = way;
}
}
}
surfaces.push(best?.surface ?? "unknown");
highways.push(best?.highway ?? "unknown");
}
return { surfaces, highways };
}