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>
59 lines
1.9 KiB
TypeScript
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 };
|
|
}
|