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:
parent
e1e3dc5c81
commit
3a1c34317d
21 changed files with 388 additions and 18 deletions
|
|
@ -42,6 +42,9 @@ export {
|
|||
type ComputeRouteRequest,
|
||||
} from "./routes.ts";
|
||||
|
||||
// Surface / waytype breakdown
|
||||
export { SurfaceBreakdownSchema, type SurfaceBreakdown } from "./surface-breakdown.ts";
|
||||
|
||||
// Activities
|
||||
export {
|
||||
ActivitySummarySchema, ActivityDetailSchema,
|
||||
|
|
|
|||
15
packages/api/src/surface-breakdown.ts
Normal file
15
packages/api/src/surface-breakdown.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Distance-weighted surface/waytype breakdown (metres per category). Shared
|
||||
* wire shape for the planner→journal handoff and the route/activity read APIs.
|
||||
* Mirrors `SurfaceBreakdown` in `@trails-cool/map-core`.
|
||||
*/
|
||||
const MetresByCategory = z.record(z.string(), z.number().min(0));
|
||||
|
||||
export const SurfaceBreakdownSchema = z.object({
|
||||
surface: MetresByCategory,
|
||||
highway: MetresByCategory,
|
||||
});
|
||||
|
||||
export type SurfaceBreakdown = z.infer<typeof SurfaceBreakdownSchema>;
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -418,6 +418,38 @@ export default {
|
|||
weeklyDistance: "Wochendistanz (letzte 12 Wochen)",
|
||||
weekOf: "Woche vom {{date}}",
|
||||
},
|
||||
surface: {
|
||||
surface: "Oberfläche",
|
||||
waytype: "Wegtyp",
|
||||
other: "Sonstige",
|
||||
cat: {
|
||||
asphalt: "Asphalt",
|
||||
paved: "Befestigt",
|
||||
concrete: "Beton",
|
||||
paving_stones: "Pflastersteine",
|
||||
cobblestone: "Kopfsteinpflaster",
|
||||
gravel: "Schotter",
|
||||
fine_gravel: "Feinschotter",
|
||||
compacted: "Verdichtet",
|
||||
ground: "Naturboden",
|
||||
dirt: "Erde",
|
||||
grass: "Gras",
|
||||
sand: "Sand",
|
||||
unpaved: "Unbefestigt",
|
||||
path: "Pfad",
|
||||
track: "Wirtschaftsweg",
|
||||
residential: "Wohnstraße",
|
||||
cycleway: "Radweg",
|
||||
footway: "Fußweg",
|
||||
primary: "Hauptstraße",
|
||||
secondary: "Landstraße",
|
||||
tertiary: "Nebenstraße",
|
||||
service: "Erschließungsweg",
|
||||
unclassified: "Kleine Straße",
|
||||
bridleway: "Reitweg",
|
||||
steps: "Treppe",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Einstellungen",
|
||||
nav: {
|
||||
|
|
|
|||
|
|
@ -418,6 +418,38 @@ export default {
|
|||
weeklyDistance: "Weekly distance (last 12 weeks)",
|
||||
weekOf: "Week of {{date}}",
|
||||
},
|
||||
surface: {
|
||||
surface: "Surface",
|
||||
waytype: "Way type",
|
||||
other: "other",
|
||||
cat: {
|
||||
asphalt: "Asphalt",
|
||||
paved: "Paved",
|
||||
concrete: "Concrete",
|
||||
paving_stones: "Paving stones",
|
||||
cobblestone: "Cobblestone",
|
||||
gravel: "Gravel",
|
||||
fine_gravel: "Fine gravel",
|
||||
compacted: "Compacted",
|
||||
ground: "Ground",
|
||||
dirt: "Dirt",
|
||||
grass: "Grass",
|
||||
sand: "Sand",
|
||||
unpaved: "Unpaved",
|
||||
path: "Path",
|
||||
track: "Track",
|
||||
residential: "Residential road",
|
||||
cycleway: "Cycleway",
|
||||
footway: "Footway",
|
||||
primary: "Primary road",
|
||||
secondary: "Secondary road",
|
||||
tertiary: "Tertiary road",
|
||||
service: "Service road",
|
||||
unclassified: "Minor road",
|
||||
bridleway: "Bridleway",
|
||||
steps: "Steps",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
nav: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
34
packages/map-core/src/surface-breakdown.test.ts
Normal file
34
packages/map-core/src/surface-breakdown.test.ts
Normal 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: {} });
|
||||
});
|
||||
});
|
||||
52
packages/map-core/src/surface-breakdown.ts
Normal file
52
packages/map-core/src/surface-breakdown.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue