diff --git a/apps/journal/app/components/SurfaceBreakdown.test.tsx b/apps/journal/app/components/SurfaceBreakdown.test.tsx new file mode 100644 index 0000000..521bbc0 --- /dev/null +++ b/apps/journal/app/components/SurfaceBreakdown.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx"; + +afterEach(cleanup); + +describe("SurfaceBreakdown", () => { + it("renders nothing without a breakdown", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when every bucket is zero", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders a legend item per non-zero category with its percentage", () => { + const { container, getByText } = render( + , + ); + // 2 surface + 1 waytype = 3 legend entries + expect(container.querySelectorAll("li")).toHaveLength(3); + expect(getByText(/60% · 6\.0 km/)).toBeTruthy(); + expect(getByText(/40% · 4\.0 km/)).toBeTruthy(); + expect(getByText(/100% · 10\.0 km/)).toBeTruthy(); + }); + + it("sorts segments largest-first within a dimension", () => { + const { container } = render( + , + ); + // first bar's first segment is the larger (asphalt 70%) + const firstBar = container.querySelector("div.flex.h-3"); + const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width); + expect(widths[0]).toBe("70%"); + expect(widths[1]).toBe("30%"); + }); +}); diff --git a/apps/journal/app/components/SurfaceBreakdown.tsx b/apps/journal/app/components/SurfaceBreakdown.tsx new file mode 100644 index 0000000..868e713 --- /dev/null +++ b/apps/journal/app/components/SurfaceBreakdown.tsx @@ -0,0 +1,99 @@ +import { useTranslation } from "react-i18next"; +import { + SURFACE_COLORS, + DEFAULT_SURFACE_COLOR, + HIGHWAY_COLORS, + DEFAULT_HIGHWAY_COLOR, +} from "@trails-cool/map-core"; +import { formatDistanceKm } from "~/lib/stats"; + +export interface SurfaceBreakdownData { + surface: Record; + highway: Record; +} + +function Bar({ + title, + data, + colorFor, +}: { + title: string; + data: Record; + colorFor: (category: string) => string; +}) { + const { t } = useTranslation("journal"); + const entries = Object.entries(data) + .filter(([, m]) => m > 0) + .sort((a, b) => b[1] - a[1]); + const total = entries.reduce((s, [, m]) => s + m, 0); + if (total <= 0) return null; + + const label = (cat: string) => + cat === "unknown" + ? t("surface.other") + : t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") }); + + return ( +
+

{title}

+
+ {entries.map(([cat, m]) => ( +
+ ))} +
+
    + {entries.map(([cat, m]) => ( +
  • + + {label(cat)} + + {Math.round((m / total) * 100)}% · {formatDistanceKm(m)} + +
  • + ))} +
+
+ ); +} + +/** + * Surface + waytype proportion bars (route-surface-breakdown). Renders nothing + * when there's no breakdown data. Colours come from the shared map-core + * palettes; unknown tags collapse into "other". + */ +export function SurfaceBreakdown({ + breakdown, + className, +}: { + breakdown: SurfaceBreakdownData | null | undefined; + className?: string; +}) { + const { t } = useTranslation("journal"); + if (!breakdown) return null; + const hasSurface = Object.values(breakdown.surface).some((m) => m > 0); + const hasHighway = Object.values(breakdown.highway).some((m) => m > 0); + if (!hasSurface && !hasHighway) return null; + + return ( +
+ SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR} + /> + HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR} + /> +
+ ); +} diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 406113e..4e2a31a 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and, inArray, isNotNull } from "drizzle-orm"; import { getDb } from "./db.ts"; import { routes, routeVersions } from "@trails-cool/db/schema/journal"; -import type { Visibility } from "@trails-cool/db/schema/journal"; +import type { Visibility, SurfaceBreakdown } from "@trails-cool/db/schema/journal"; import { sql } from "drizzle-orm"; import { processGpx, writeGeom } from "./gpx-save.server.ts"; import type { ProcessedGpx } from "./gpx-save.server.ts"; @@ -20,6 +20,7 @@ export interface RouteInput { elevationLoss?: number | null; dayBreaks?: number[]; synthetic?: boolean; + surfaceBreakdown?: SurfaceBreakdown; } export async function createRoute(ownerId: string, input: RouteInput) { @@ -139,6 +140,7 @@ export async function updateRoute(route: OwnedRef, input: Partial) { if (input.name !== undefined) updateData.name = input.name; if (input.description !== undefined) updateData.description = input.description; if (input.visibility !== undefined) updateData.visibility = input.visibility; + if (input.surfaceBreakdown !== undefined) updateData.surfaceBreakdown = input.surfaceBreakdown; if (input.gpx && processed) { const { stats } = processed; diff --git a/apps/journal/app/routes/activities.$id.server.ts b/apps/journal/app/routes/activities.$id.server.ts index b5d0562..a39ef32 100644 --- a/apps/journal/app/routes/activities.$id.server.ts +++ b/apps/journal/app/routes/activities.$id.server.ts @@ -65,6 +65,7 @@ export async function loadActivityDetail(request: Request, id: string | undefine duration: activity.duration, movingTimeSec, elevation, + surfaceBreakdown: activity.surfaceBreakdown ?? null, routeId: activity.routeId, hasGpx: !!activity.gpx, geojson: activity.geojson ?? null, diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 7ade93c..86cb03f 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -7,6 +7,7 @@ import { ClientMap } from "~/components/ClientMap"; import { SportBadge } from "~/components/SportBadge"; import { StatRow } from "~/components/StatRow"; import { ElevationProfile } from "~/components/ElevationProfile"; +import { SurfaceBreakdown } from "~/components/SurfaceBreakdown"; import { activityStatItems } from "~/lib/stats"; import { loadActivityDetail, activityDetailAction } from "./activities.$id.server"; import { @@ -142,6 +143,8 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) /> )} + + {activity.routeId && (
diff --git a/apps/journal/app/routes/api.routes.$id.callback.ts b/apps/journal/app/routes/api.routes.$id.callback.ts index 98f814c..091ca67 100644 --- a/apps/journal/app/routes/api.routes.$id.callback.ts +++ b/apps/journal/app/routes/api.routes.$id.callback.ts @@ -1,4 +1,5 @@ import { data } from "react-router"; +import { SurfaceBreakdownSchema } from "@trails-cool/api"; import type { Route } from "./+types/api.routes.$id.callback"; import { verifyRouteToken } from "~/lib/jwt.server"; import { updateRoute, getRoute } from "~/lib/routes.server"; @@ -56,16 +57,23 @@ export async function action({ params, request }: Route.ActionArgs) { // Parse GPX from request body const body = await request.json(); - const { gpx } = body as { gpx: string }; + const { gpx, surfaceBreakdown } = body as { gpx: string; surfaceBreakdown?: unknown }; if (!gpx) { return data({ error: "Missing GPX data" }, { status: 400, headers: corsHeaders() }); } + // Optional, best-effort surface/waytype breakdown from the Planner's BRouter + // waytags (route-surface-breakdown, Path 1) — stored if well-formed, ignored otherwise. + const sb = SurfaceBreakdownSchema.safeParse(surfaceBreakdown); + // Update route with new GPX (creates new version). Authorization // here comes from the verified single-use route token, not a // session — vouchOwnership marks that explicitly. - await updateRoute(vouchOwnership(route), { gpx }); + await updateRoute(vouchOwnership(route), { + gpx, + ...(sb.success ? { surfaceBreakdown: sb.data } : {}), + }); return data({ success: true, routeId: params.id }, { headers: corsHeaders() }); } catch (e) { diff --git a/apps/journal/app/routes/routes.$id.server.ts b/apps/journal/app/routes/routes.$id.server.ts index 3c2340b..749d1be 100644 --- a/apps/journal/app/routes/routes.$id.server.ts +++ b/apps/journal/app/routes/routes.$id.server.ts @@ -126,6 +126,7 @@ export async function loadRouteDetail(request: Request, id: string | undefined) hasGpx: !!route.gpx, dayBreaks: route.dayBreaks ?? [], elevation, + surfaceBreakdown: route.surfaceBreakdown ?? null, geojson: routeWithGeojson?.geojson ?? null, visibility: route.visibility, createdAt: route.createdAt.toISOString(), diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index bf4fe9d..fdeae34 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -6,6 +6,7 @@ import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; import { StatRow } from "~/components/StatRow"; import { ElevationProfile } from "~/components/ElevationProfile"; +import { SurfaceBreakdown } from "~/components/SurfaceBreakdown"; import { activityStatItems } from "~/lib/stats"; import { loadRouteDetail, routeDetailAction } from "./routes.$id.server"; @@ -332,6 +333,8 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { /> )} + + {isEmpty && (
diff --git a/apps/journal/package.json b/apps/journal/package.json index 6e33431..b54967d 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -27,6 +27,7 @@ "@trails-cool/gpx": "workspace:*", "@trails-cool/i18n": "workspace:*", "@trails-cool/jobs": "workspace:*", + "@trails-cool/map-core": "workspace:*", "@trails-cool/sentry-config": "workspace:*", "@trails-cool/types": "workspace:*", "drizzle-orm": "catalog:", diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index 056d4bc..d464cf4 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -1,7 +1,9 @@ import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { computeSurfaceBreakdown } from "@trails-cool/map-core"; import type { YjsState } from "~/lib/use-yjs"; import { buildPlanGpx } from "~/lib/gpx-export"; +import { readComputedRoute } from "~/lib/route-data"; interface SaveToJournalButtonProps { yjs: YjsState; @@ -24,6 +26,19 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal // route round-trips correctly through the journal. const gpx = buildPlanGpx(yjs); + // Distance-weighted surface/waytype breakdown from the BRouter waytags + // already in routeData — sent alongside the GPX so the journal can show + // it without re-deriving (route-surface-breakdown, Path 1). + const route = readComputedRoute(yjs.routeData); + const breakdown = + route.coordinates && route.coordinates.length > 1 + ? computeSurfaceBreakdown(route.coordinates, route.surfaces, route.highways) + : null; + const surfaceBreakdown = + breakdown && (Object.keys(breakdown.surface).length > 0 || Object.keys(breakdown.highway).length > 0) + ? breakdown + : undefined; + // POST to the planner's server-side proxy. The proxy attaches the // journal Bearer token (stored on the session row) and forwards // the GPX. Token never leaves the planner server — see @@ -31,7 +46,7 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal const response = await fetch("/api/save-to-journal", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId, gpx }), + body: JSON.stringify({ sessionId, gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }), }); if (!response.ok) { diff --git a/apps/planner/app/routes/api.save-to-journal.ts b/apps/planner/app/routes/api.save-to-journal.ts index 29ac08e..1f4e2b9 100644 --- a/apps/planner/app/routes/api.save-to-journal.ts +++ b/apps/planner/app/routes/api.save-to-journal.ts @@ -21,6 +21,7 @@ import { validateFetchUrl, getCallbackAllowedHosts } from "~/lib/url-validation. interface SaveRequestBody { sessionId?: unknown; gpx?: unknown; + surfaceBreakdown?: unknown; } const MAX_GPX_BYTES = 5 * 1024 * 1024; // 5 MB — same ceiling as the Yjs doc cap @@ -39,6 +40,12 @@ export async function action({ request }: Route.ActionArgs) { const sessionId = typeof body.sessionId === "string" ? body.sessionId : ""; const gpx = typeof body.gpx === "string" ? body.gpx : ""; + // Optional, best-effort decoration — forwarded as-is; the journal callback is + // the authoritative validator (planner doesn't depend on @trails-cool/api). + const surfaceBreakdown = + body.surfaceBreakdown && typeof body.surfaceBreakdown === "object" + ? body.surfaceBreakdown + : undefined; if (!sessionId) return data({ error: "sessionId required" }, { status: 400 }); if (!gpx) return data({ error: "gpx required" }, { status: 400 }); @@ -69,7 +76,7 @@ export async function action({ request }: Route.ActionArgs) { "Content-Type": "application/json", Authorization: `Bearer ${session.callbackToken}`, }, - body: JSON.stringify({ gpx }), + body: JSON.stringify({ gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }), }); } catch { return data({ error: "journal unreachable" }, { status: 502 }); diff --git a/openspec/changes/route-surface-breakdown/tasks.md b/openspec/changes/route-surface-breakdown/tasks.md index 68d50a6..55a0a52 100644 --- a/openspec/changes/route-surface-breakdown/tasks.md +++ b/openspec/changes/route-surface-breakdown/tasks.md @@ -1,24 +1,27 @@ + + ## 1. Shared derivation + shape -- [ ] 1.1 Pure `surfaceBreakdown(coordinates, surfaces, highways)` helper: haversine-weight each segment into per-surface + per-waytype buckets → `{ surface: Record, highway: Record }` (metres). Unit-test. -- [ ] 1.2 `SurfaceBreakdownSchema` (Zod) in `@trails-cool/api` (record of category → metres ≥ 0, surface + highway). +- [x] 1.1 `computeSurfaceBreakdown(coordinates, surfaces, highways)` in `@trails-cool/map-core` → `{ surface, highway }` metres; unit-tested. +- [x] 1.2 `SurfaceBreakdownSchema` (Zod) in `@trails-cool/api`. ## 2. Schema -- [ ] 2.1 Nullable `surfaceBreakdown` jsonb on `journal.routes` **and** `journal.activities`; `pnpm db:push`. +- [x] 2.1 Nullable `surfaceBreakdown` jsonb on `journal.routes` **and** `journal.activities`; pushed to dev + e2e DBs. ## 3. Path 1 — synchronous (Planner routes) -- [ ] 3.1 `apps/planner/.../api.save-to-journal`: compute the breakdown from the session `EnrichedRoute` (coords + surfaces + highways) and add `surfaceBreakdown` to the POST body. -- [ ] 3.2 Journal route callback validates the optional field and persists it (overwrite on re-save). +- [x] 3.1 `SaveToJournalButton` computes the breakdown from `readComputedRoute` and sends it; `api.save-to-journal` forwards it (journal is the authoritative validator — planner has no `@trails-cool/api` dep). +- [x] 3.2 Journal route callback validates the optional field with `SurfaceBreakdownSchema` and persists via `updateRoute` (overwrite on re-save). ## 4. Render -- [ ] 4.1 Route + activity detail loaders expose `surfaceBreakdown`. -- [ ] 4.2 `SurfaceBreakdown` component: stacked bar per dimension (surface, waytype), `map-core` `SURFACE_COLORS`/`HIGHWAY_COLORS` (DEFAULT → "other"), legend category · km · % (largest first), hidden when empty. Mount on `routes.$id.tsx` and `activities.$id.tsx`. -- [ ] 4.3 i18n `journal.surface.*` (label + category names + "other") en + de. +- [x] 4.1 Route + activity detail loaders expose `surfaceBreakdown`. +- [x] 4.2 `SurfaceBreakdown` component (stacked bar per dimension, `map-core` palettes, legend category · % · km largest-first, unknown → "other", hidden when empty); mounted on `routes.$id.tsx` and `activities.$id.tsx`. +- [x] 4.3 i18n `journal.surface.*` (surface/waytype labels, common categories, "other") en + de. -## 5. Path 2 — async backfill + SSE +## 5. Path 2 — async backfill + SSE (PHASE 2 — follow-up PR) - [ ] 5.1 Journal-side Overpass client: `way[highway]` in bbox + parse (mirror the planner's `buildQuery`/`parseResponse` + rate-limit handling; route via the `/api/overpass` proxy). - [ ] 5.2 Map-match helper: nearest OSM way per route segment → surface/highway; unmatched → unknown. Unit-test the matcher on a small fixture. @@ -32,7 +35,7 @@ ## 7. Tests & checks -- [ ] 7.1 Unit: `surfaceBreakdown` weighting; map-matcher nearest-way + unknown bucket. -- [ ] 7.2 Component (jsdom): bars/proportions, "other" bucket, hidden when empty. -- [ ] 7.3 E2E: a route/activity with a stored breakdown shows the bars (seed the breakdown to avoid a live Overpass call in CI). -- [ ] 7.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green. +- [x] 7.1 Unit: `computeSurfaceBreakdown` weighting + unknown bucket (map-matcher unit is Phase 2). +- [x] 7.2 Component (jsdom): bars/proportions, largest-first, hidden when empty/null. +- [ ] 7.3 E2E: a route/activity with a stored breakdown shows the bars — **Phase 2** (needs a seed path; deferred to land with the backfill that creates the data). Phase 1 render verified in the browser + component test. +- [x] 7.4 Phase 1: typecheck + lint + unit (map-core 36, journal 326) green; verified in the browser. diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b8b4a2d..b157e77 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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, diff --git a/packages/api/src/surface-breakdown.ts b/packages/api/src/surface-breakdown.ts new file mode 100644 index 0000000..1587f34 --- /dev/null +++ b/packages/api/src/surface-breakdown.ts @@ -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; diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index a3c99b2..b13c46c 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -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; + highway: Record; +}; + 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(), tags: jsonb("tags").$type(), + surfaceBreakdown: jsonb("surface_breakdown").$type(), plannerState: bytea("planner_state"), visibility: text("visibility").$type().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(), + // Distance-weighted surface/waytype breakdown (backfilled via Overpass for + // imported/uploaded activities). NULL until derived. + surfaceBreakdown: jsonb("surface_breakdown").$type(), gpx: text("gpx"), geom: lineString("geom"), startedAt: timestamp("started_at", { withTimezone: true }), diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f2200fd..3dcd4fc 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 16d3eb5..83493f35 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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: { diff --git a/packages/map-core/src/index.ts b/packages/map-core/src/index.ts index 48939dd..821e82e 100644 --- a/packages/map-core/src/index.ts +++ b/packages/map-core/src/index.ts @@ -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"; diff --git a/packages/map-core/src/surface-breakdown.test.ts b/packages/map-core/src/surface-breakdown.test.ts new file mode 100644 index 0000000..1ef157e --- /dev/null +++ b/packages/map-core/src/surface-breakdown.test.ts @@ -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: {} }); + }); +}); diff --git a/packages/map-core/src/surface-breakdown.ts b/packages/map-core/src/surface-breakdown.ts new file mode 100644 index 0000000..86d6773 --- /dev/null +++ b/packages/map-core/src/surface-breakdown.ts @@ -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; + /** metres per waytype/highway category */ + highway: Record; +} + +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, + surfaces: readonly string[], + highways: readonly string[], +): SurfaceBreakdown { + const surface: Record = {}; + const highway: Record = {}; + + 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 }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22a205a..8f5cad2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: '@trails-cool/jobs': specifier: workspace:* version: link:../../packages/jobs + '@trails-cool/map-core': + specifier: workspace:* + version: link:../../packages/map-core '@trails-cool/sentry-config': specifier: workspace:* version: link:../../packages/sentry-config