Merge pull request #544 from trails-cool/propose-route-surface-breakdown
openspec: propose route-surface-breakdown (surface/waytype bars)
This commit is contained in:
commit
a81134332b
5 changed files with 185 additions and 0 deletions
2
openspec/changes/route-surface-breakdown/.openspec.yaml
Normal file
2
openspec/changes/route-surface-breakdown/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-14
|
||||||
73
openspec/changes/route-surface-breakdown/design.md
Normal file
73
openspec/changes/route-surface-breakdown/design.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Planner computes accurate per-coordinate surface/highway from BRouter's
|
||||||
|
`messages`/`WayTags` (`apps/planner/.../route-merge.ts` → `EnrichedRoute`), and
|
||||||
|
`ColoredRoute` already renders the route coloured by surface. But this metadata
|
||||||
|
is **session-only**: the planner→journal handoff (`api.save-to-journal`) POSTs
|
||||||
|
only `{ gpx }` to the Journal callback (`api.routes.$id.callback`), the `routes`
|
||||||
|
table has no surface column, and GPX carries no surface extensions. So today no
|
||||||
|
saved route has surface data.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
- Surface + waytype proportion bars on route detail, using data we already
|
||||||
|
compute, with no new external dependency.
|
||||||
|
|
||||||
|
**Non-Goals (v1)**
|
||||||
|
- **Activities / imported / manually-uploaded GPX** — they have no waytags
|
||||||
|
(bare GPX). They show no breakdown for now (see "Data source" → future
|
||||||
|
Overpass).
|
||||||
|
- **Colouring the Journal map line by surface** — needs per-segment data, not
|
||||||
|
just the summary; a clean follow-up once the bars land.
|
||||||
|
- Selectable surface-vs-waytype toggle UI beyond showing both bars.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: Data source — persist BRouter waytags at save (Planner routes only)
|
||||||
|
Two ways to get a breakdown onto the Journal detail page:
|
||||||
|
|
||||||
|
- **(A, chosen) BRouter-at-save** — the Planner already has 100%-accurate
|
||||||
|
per-segment surface/highway; compute the distribution there at save and
|
||||||
|
persist it. Accurate, cheap, no external call. **Limitation:** only
|
||||||
|
Planner-created routes (activities/uploads have no waytags).
|
||||||
|
- **(B, future) Overpass map-matching** — snap arbitrary route geometry to OSM
|
||||||
|
ways and read `surface`/`highway` server-side. Covers imports/uploads, but
|
||||||
|
it's expensive, approximate, rate-limited, and an external dependency. Parked
|
||||||
|
for when we want imports covered (the repo already proxies Overpass for POIs).
|
||||||
|
|
||||||
|
v1 ships (A). Routes without the data degrade to no bars.
|
||||||
|
|
||||||
|
### D2: Store a compact distance-weighted summary, not per-segment data
|
||||||
|
The bars need **proportions**, not geometry. At save, sum the haversine length
|
||||||
|
of each coordinate segment into its surface bucket (and highway bucket),
|
||||||
|
yielding `{ surface: { asphalt: <m>, gravel: <m>, … }, highway: { … } }`
|
||||||
|
(metres per category). This is tiny (a handful of keys), stored as a nullable
|
||||||
|
`surface_breakdown` jsonb on `routes`. Per-segment data (for future map line
|
||||||
|
colouring) is deliberately *not* persisted in v1 — that's a bigger payload and
|
||||||
|
a separate feature.
|
||||||
|
|
||||||
|
### D3: Handoff carries it
|
||||||
|
`api.save-to-journal` adds `surfaceBreakdown` to the POST body alongside `gpx`;
|
||||||
|
the Journal callback validates (Zod, all values ≥ 0) and stores it. The field
|
||||||
|
is optional, so the handoff stays backward-compatible and non-Planner callers
|
||||||
|
(if any) are unaffected. Re-saving a route recomputes/overwrites it.
|
||||||
|
|
||||||
|
### D4: Rendering
|
||||||
|
A `SurfaceBreakdown` component renders one horizontal stacked bar per dimension
|
||||||
|
(surface, then waytype): segments sized by percentage, coloured via
|
||||||
|
`SURFACE_COLORS` / `HIGHWAY_COLORS` (`DEFAULT_*` for unknown), with a legend
|
||||||
|
listing each category + km + percent (largest first). Unknown/unmapped tags
|
||||||
|
collapse into an "other" segment. Hidden entirely when `surface_breakdown` is
|
||||||
|
null or empty. Labels via i18n; distances via `stats.ts`.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Coverage gap** — only Planner routes get bars in v1. Acceptable: routes are
|
||||||
|
*designed* in the Planner, which is exactly where "how much gravel" matters.
|
||||||
|
Imports get bars later via (B).
|
||||||
|
- **Tag sparsity** — OSM `surface` is often missing; those segments bucket as
|
||||||
|
`unknown`/"other". We show it honestly rather than guessing.
|
||||||
|
- **Stale on edit** — editing a route's geometry elsewhere without re-routing
|
||||||
|
could leave the breakdown stale; recomputing on every Planner save keeps it
|
||||||
|
fresh for the normal flow.
|
||||||
46
openspec/changes/route-surface-breakdown/proposal.md
Normal file
46
openspec/changes/route-surface-breakdown/proposal.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
"How much of this is gravel?" is a top-three question for anyone choosing a
|
||||||
|
route — Komoot's surface/waytype breakdown bars are one of its most-used
|
||||||
|
features, and `@trails-cool/map-core` already ships the surface/highway colour
|
||||||
|
palettes. We also already *compute* accurate per-segment surface and highway
|
||||||
|
tags from BRouter while planning (`route-merge` → `EnrichedRoute.surfaces[]`),
|
||||||
|
then **throw them away on save**. This change captures that data and shows it.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- On **route detail**, show surface and waytype **proportion bars** (e.g.
|
||||||
|
"62% asphalt · 28% gravel · 10% path"), coloured with the `map-core`
|
||||||
|
`SURFACE_COLORS` / `HIGHWAY_COLORS` palettes, with per-segment km/percent.
|
||||||
|
- **Persist the breakdown for Planner-created routes**: compute a
|
||||||
|
distance-weighted surface + waytype distribution from the BRouter waytags at
|
||||||
|
save time, send it in the planner→journal handoff, and store it on the route.
|
||||||
|
- Degrade gracefully: routes without breakdown data (older routes, or activities
|
||||||
|
/ manually-imported GPX that never had waytags) simply don't show the bars.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `route-surface-breakdown`: the surface/waytype proportion bars on route
|
||||||
|
detail, how the distribution is derived from BRouter waytags, persisted at
|
||||||
|
save, and rendered.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- `planner-journal-handoff` gains one optional field in its save payload;
|
||||||
|
noted in design, but the breakdown is a new capability, not a change to the
|
||||||
|
handoff's existing requirements. -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Schema**: a nullable `surface_breakdown` jsonb column on `journal.routes`
|
||||||
|
(additive — `pnpm db:push`, no data migration).
|
||||||
|
- **Planner**: `api.save-to-journal` computes the distance-weighted breakdown
|
||||||
|
from the session `EnrichedRoute` (surfaces/highways + coordinates) and adds it
|
||||||
|
to the POST body.
|
||||||
|
- **Journal**: the route callback (`api.routes.$id.callback`) accepts + stores
|
||||||
|
it; the route detail loader exposes it; a `SurfaceBreakdown` component renders
|
||||||
|
the bars (reusing `map-core` palettes + `stats.ts` distance formatting).
|
||||||
|
- **No new external dependency** — BRouter already returns the tags; no Overpass
|
||||||
|
call. Imported activities/uploads are out of scope for v1 (see design).
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Surface breakdown derived from BRouter waytags
|
||||||
|
The system SHALL compute a distance-weighted surface and waytype distribution for a Planner-created route from the per-segment BRouter waytags at save time, and persist it with the route.
|
||||||
|
|
||||||
|
#### Scenario: Breakdown captured on save
|
||||||
|
- **WHEN** a route is saved from the Planner with per-segment surface/highway waytags
|
||||||
|
- **THEN** the route stores a distance-weighted breakdown of metres per surface category and per waytype category
|
||||||
|
|
||||||
|
#### Scenario: No waytags available
|
||||||
|
- **WHEN** a route is saved without waytags (no surface data)
|
||||||
|
- **THEN** no breakdown is stored, and the route detail shows no breakdown bars
|
||||||
|
|
||||||
|
### Requirement: Surface and waytype proportion bars
|
||||||
|
The route detail page SHALL render the surface and waytype breakdown as proportion bars when the route has breakdown data, coloured with the shared surface/highway palettes, each segment labelled with its category, distance, and percentage.
|
||||||
|
|
||||||
|
#### Scenario: Bars shown for a route with a breakdown
|
||||||
|
- **WHEN** a user views a route that has a stored surface breakdown
|
||||||
|
- **THEN** a surface proportion bar and a waytype proportion bar are shown, each segment coloured by category with the largest category first
|
||||||
|
|
||||||
|
#### Scenario: Percentages reflect distance share
|
||||||
|
- **WHEN** a route is 62% asphalt and 28% gravel and 10% path by distance
|
||||||
|
- **THEN** the surface bar's segments are sized 62% / 28% / 10% and labelled accordingly
|
||||||
|
|
||||||
|
#### Scenario: Unknown tags collapse to "other"
|
||||||
|
- **WHEN** some segments have no recognized surface tag
|
||||||
|
- **THEN** their distance is shown as an "other" segment rather than omitted
|
||||||
|
|
||||||
|
#### Scenario: Hidden without data
|
||||||
|
- **WHEN** a route has no stored breakdown
|
||||||
|
- **THEN** no breakdown bars are rendered
|
||||||
|
|
||||||
|
### Requirement: Localized breakdown labels
|
||||||
|
The breakdown SHALL render its category and unit labels through localized strings.
|
||||||
|
|
||||||
|
#### Scenario: German labels
|
||||||
|
- **WHEN** the UI locale is German
|
||||||
|
- **THEN** the breakdown's category and unit labels render in German
|
||||||
26
openspec/changes/route-surface-breakdown/tasks.md
Normal file
26
openspec/changes/route-surface-breakdown/tasks.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
## 1. Breakdown derivation (shared, pure)
|
||||||
|
|
||||||
|
- [ ] 1.1 Add a pure `surfaceBreakdown(coordinates, surfaces, highways)` helper (in `@trails-cool/map-core` or a planner lib): sum haversine segment lengths into per-surface and per-waytype buckets → `{ surface: Record<string, number>, highway: Record<string, number> }` (metres). Unit-test it.
|
||||||
|
- [ ] 1.2 Define the shared wire shape (Zod) for the breakdown in `@trails-cool/api` (`SurfaceBreakdownSchema`, all values ≥ 0).
|
||||||
|
|
||||||
|
## 2. Capture at save (Planner → Journal)
|
||||||
|
|
||||||
|
- [ ] 2.1 In `apps/planner/.../api.save-to-journal`, read the session `EnrichedRoute` (coordinates + surfaces + highways), compute the breakdown, and add `surfaceBreakdown` to the POST body.
|
||||||
|
- [ ] 2.2 Journal callback (`api.routes.$id.callback`) validates the optional `surfaceBreakdown` and persists it; re-save overwrites.
|
||||||
|
|
||||||
|
## 3. Schema
|
||||||
|
|
||||||
|
- [ ] 3.1 Add nullable `surfaceBreakdown` jsonb to `journal.routes`; `pnpm db:push`.
|
||||||
|
|
||||||
|
## 4. Render
|
||||||
|
|
||||||
|
- [ ] 4.1 Route detail loader exposes `surfaceBreakdown`.
|
||||||
|
- [ ] 4.2 `SurfaceBreakdown` component: stacked proportion bar per dimension (surface, waytype), segments coloured via `SURFACE_COLORS`/`HIGHWAY_COLORS` (DEFAULT for unknown), legend with category · km · % (largest first), unknown → "other"; hidden when empty. Mount on `routes.$id.tsx`.
|
||||||
|
- [ ] 4.3 i18n `journal.surface.*` (label + category names + "other") en + de.
|
||||||
|
|
||||||
|
## 5. Tests & checks
|
||||||
|
|
||||||
|
- [ ] 5.1 Unit: `surfaceBreakdown` (distance weighting, multiple surfaces, unknown bucket).
|
||||||
|
- [ ] 5.2 Component (jsdom): bars render with correct proportions; "other" for unknowns; hidden when empty.
|
||||||
|
- [ ] 5.3 E2E: a Planner-saved route with waytags shows the breakdown bars on its detail page (or assert via a seeded route if the planner round-trip is too heavy for e2e).
|
||||||
|
- [ ] 5.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue