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>
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { defineJournalJob } from "./payloads.ts";
|
|
import { getDb } from "../lib/db.ts";
|
|
import { computeSurfaceBreakdown } from "@trails-cool/map-core";
|
|
import { fetchWaysInBbox, type Bbox } from "../lib/overpass-ways.server.ts";
|
|
import { matchSurfaces } from "../lib/surface-match.server.ts";
|
|
import { emitTo } from "../lib/events.server.ts";
|
|
import { logger } from "../lib/logger.server.ts";
|
|
|
|
// Cap coordinates fed to the matcher; the breakdown still weights by the actual
|
|
// segment length, so downsampling barely shifts proportions.
|
|
const MAX_COORDS = 250;
|
|
const BBOX_PAD_DEG = 0.0008; // ~80 m, so ways just off the line are considered
|
|
|
|
/**
|
|
* Derive a surface/waytype breakdown for a route/activity that has geometry but
|
|
* no breakdown yet (imports, uploads, pre-existing rows), by map-matching its
|
|
* geometry to OSM ways via Overpass. Idempotent (skips if already set),
|
|
* best-effort (Overpass failure throws → pg-boss retries; an empty/oversized
|
|
* bbox is a no-op). On success it stores the breakdown and pushes an SSE event
|
|
* to the owner so an open detail page fills the bars in live.
|
|
*/
|
|
export const surfaceBackfillJob = defineJournalJob({
|
|
name: "surface-backfill",
|
|
retryLimit: 3,
|
|
expireInSeconds: 120,
|
|
async handler(job) {
|
|
const batch = Array.isArray(job) ? job : [job];
|
|
for (const item of batch) {
|
|
await runSurfaceBackfill(item.data.kind, item.data.id);
|
|
}
|
|
},
|
|
});
|
|
|
|
export async function runSurfaceBackfill(kind: "route" | "activity", id: string): Promise<void> {
|
|
const db = getDb();
|
|
const tableName = kind === "route" ? sql`journal.routes` : sql`journal.activities`;
|
|
|
|
const loaded = (await db.execute(sql`
|
|
SELECT ST_AsGeoJSON(geom) AS geojson,
|
|
owner_id AS "ownerId",
|
|
(surface_breakdown IS NOT NULL) AS "hasBreakdown"
|
|
FROM ${tableName} WHERE id = ${id} LIMIT 1
|
|
`)) as unknown as Array<{ geojson: string | null; ownerId: string | null; hasBreakdown: boolean }>;
|
|
const row = loaded[0];
|
|
|
|
if (!row) {
|
|
logger.warn({ kind, id }, "surface-backfill: row not found");
|
|
return;
|
|
}
|
|
if (row.hasBreakdown) return; // idempotent
|
|
if (!row.geojson) return; // no geometry to match
|
|
|
|
let coords: number[][];
|
|
try {
|
|
coords = (JSON.parse(row.geojson) as { coordinates?: number[][] }).coordinates ?? [];
|
|
} catch {
|
|
return;
|
|
}
|
|
if (coords.length < 2) return;
|
|
|
|
if (coords.length > MAX_COORDS) {
|
|
const stride = Math.ceil(coords.length / MAX_COORDS);
|
|
const out: number[][] = [];
|
|
for (let i = 0; i < coords.length; i += stride) out.push(coords[i]!);
|
|
const last = coords[coords.length - 1]!;
|
|
if (out[out.length - 1] !== last) out.push(last);
|
|
coords = out;
|
|
}
|
|
|
|
let south = coords[0]![1]!, north = south, west = coords[0]![0]!, east = west;
|
|
for (const c of coords) {
|
|
const lon = c[0]!, lat = c[1]!;
|
|
if (lat < south) south = lat;
|
|
if (lat > north) north = lat;
|
|
if (lon < west) west = lon;
|
|
if (lon > east) east = lon;
|
|
}
|
|
const bbox: Bbox = {
|
|
south: south - BBOX_PAD_DEG,
|
|
west: west - BBOX_PAD_DEG,
|
|
north: north + BBOX_PAD_DEG,
|
|
east: east + BBOX_PAD_DEG,
|
|
};
|
|
|
|
const ways = await fetchWaysInBbox(bbox);
|
|
if (ways.length === 0) {
|
|
logger.info({ kind, id }, "surface-backfill: no ways (bbox empty/oversized) — skipping");
|
|
return;
|
|
}
|
|
|
|
const { surfaces, highways } = matchSurfaces(coords, ways);
|
|
const breakdown = computeSurfaceBreakdown(coords, surfaces, highways);
|
|
if (Object.keys(breakdown.surface).length === 0 && Object.keys(breakdown.highway).length === 0) return;
|
|
|
|
await db.execute(sql`
|
|
UPDATE ${tableName} SET surface_breakdown = ${JSON.stringify(breakdown)}::jsonb WHERE id = ${id}
|
|
`);
|
|
if (row.ownerId) emitTo(row.ownerId, "surface_breakdown", { kind, id });
|
|
logger.info({ kind, id }, "surface-backfill: stored breakdown");
|
|
}
|