route-surface-breakdown (Phase 1): Planner-path surface/waytype bars

Synchronous path + rendering for the surface/waytype breakdown:
- map-core `computeSurfaceBreakdown(coords, surfaces, highways)` → distance-
  weighted metres per surface + waytype category (unit-tested);
- `SurfaceBreakdownSchema` in @trails-cool/api;
- nullable `surfaceBreakdown` jsonb on routes + activities;
- Planner `SaveToJournalButton` computes it from the BRouter waytags already in
  routeData and sends it; `api.save-to-journal` forwards it; the journal route
  callback validates + persists (journal is the authoritative validator);
- `SurfaceBreakdown` component (stacked bars per dimension, map-core palettes,
  legend category · % · km largest-first, unknown → "other", hidden when empty)
  on route + activity detail; journal gains a @trails-cool/map-core dep;
- i18n journal.surface.* (en + de).

Phase 2 (async Overpass backfill + SSE for imports/uploads, and the e2e that
seeds a breakdown) follows in a separate PR.

Tests: computeSurfaceBreakdown unit (map-core 36); SurfaceBreakdown component
(jsdom, journal 326). typecheck + lint green; verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-14 18:46:49 +02:00
parent e1e3dc5c81
commit 3a1c34317d
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
21 changed files with 388 additions and 18 deletions

View file

@ -21,3 +21,6 @@ export {
} from "./z-index.ts";
export { SNAP_DISTANCE_METERS } from "./snap.ts";
export { computeSurfaceBreakdown } from "./surface-breakdown.ts";
export type { SurfaceBreakdown } from "./surface-breakdown.ts";

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { computeSurfaceBreakdown } from "./surface-breakdown.ts";
// ~0.001° lon at the equator ≈ 111 m; exact value doesn't matter, only ratios.
const coords: [number, number][] = [
[0, 0],
[0.001, 0],
[0.002, 0],
[0.003, 0],
];
describe("computeSurfaceBreakdown", () => {
it("weights segments by distance into surface and waytype buckets", () => {
const { surface, highway } = computeSurfaceBreakdown(
coords,
["asphalt", "asphalt", "gravel"],
["residential", "residential", "track"],
);
// segments 0,1 asphalt; segment 2 gravel — equal lengths → 2:1
expect(surface.asphalt! / surface.gravel!).toBeCloseTo(2, 5);
expect(highway.residential! / highway.track!).toBeCloseTo(2, 5);
expect(Object.keys(surface)).toEqual(["asphalt", "gravel"]);
});
it("buckets missing/empty values as unknown", () => {
const { surface } = computeSurfaceBreakdown(coords, ["asphalt", "", "asphalt"], ["", "", ""]);
expect(surface.asphalt).toBeGreaterThan(0);
expect(surface.unknown).toBeGreaterThan(0);
});
it("returns empty buckets for a degenerate track", () => {
expect(computeSurfaceBreakdown([[0, 0]], [], [])).toEqual({ surface: {}, highway: {} });
});
});

View file

@ -0,0 +1,52 @@
/**
* Distance-weighted surface / waytype breakdown for a route. Both derivation
* paths (BRouter waytags at Planner save, and the async Overpass backfill) feed
* their per-segment `surfaces`/`highways` arrays through this to produce the
* same compact summary that gets stored and rendered.
*/
export interface SurfaceBreakdown {
/** metres per surface category (e.g. asphalt, gravel, path) */
surface: Record<string, number>;
/** metres per waytype/highway category */
highway: Record<string, number>;
}
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Sum each coordinate segment's length into its surface and waytype buckets.
* `coordinates` are `[lon, lat, ...]` (GeoJSON axis order); `surfaces[i]` /
* `highways[i]` describe the segment from point `i` to `i+1`. Missing/empty
* values bucket as `"unknown"`. Returns metres per category.
*/
export function computeSurfaceBreakdown(
coordinates: ReadonlyArray<readonly number[]>,
surfaces: readonly string[],
highways: readonly string[],
): SurfaceBreakdown {
const surface: Record<string, number> = {};
const highway: Record<string, number> = {};
for (let i = 0; i < coordinates.length - 1; i++) {
const a = coordinates[i];
const b = coordinates[i + 1];
if (!a || !b || a.length < 2 || b.length < 2) continue;
const d = haversineMeters(a[1]!, a[0]!, b[1]!, b[0]!);
if (!(d > 0)) continue;
const s = surfaces[i] || "unknown";
const h = highways[i] || "unknown";
surface[s] = (surface[s] ?? 0) + d;
highway[h] = (highway[h] ?? 0) + d;
}
return { surface, highway };
}