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

@ -91,6 +91,15 @@ export const magicTokens = journalSchema.table("magic_tokens", {
*/
export type Visibility = "private" | "unlisted" | "public";
/**
* Distance-weighted surface/waytype breakdown (metres per category), derived
* from BRouter waytags at Planner save or backfilled via Overpass.
*/
export type SurfaceBreakdown = {
surface: Record<string, number>;
highway: Record<string, number>;
};
export const routes = journalSchema.table("routes", {
id: text("id").primaryKey(),
ownerId: text("owner_id")
@ -106,6 +115,7 @@ export const routes = journalSchema.table("routes", {
elevationLoss: real("elevation_loss"),
dayBreaks: jsonb("day_breaks").$type<number[]>(),
tags: jsonb("tags").$type<string[]>(),
surfaceBreakdown: jsonb("surface_breakdown").$type<SurfaceBreakdown>(),
plannerState: bytea("planner_state"),
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
/**
@ -173,6 +183,9 @@ export const activities = journalSchema.table("activities", {
description: text("description").default(""),
// Sport / activity type (NULL = unspecified). See SPORT_TYPES above.
sportType: text("sport_type").$type<SportType>(),
// Distance-weighted surface/waytype breakdown (backfilled via Overpass for
// imported/uploaded activities). NULL until derived.
surfaceBreakdown: jsonb("surface_breakdown").$type<SurfaceBreakdown>(),
gpx: text("gpx"),
geom: lineString("geom"),
startedAt: timestamp("started_at", { withTimezone: true }),