trails/openspec/changes/hiking-time-estimate/design.md
Ullrich Schäfer 5dd4968626 docs+openspec: prior-art research (Organic Maps, Endurain, wanderer) and 15 proposals
Add docs/inspirations.md as the durable record of the 2026-07-05/06
prior-art research — per-project learnings with source paths, canonical
credit lines, and the changes each spawned — and extend the
acknowledgment lists in philosophy.md/architecture.md (Organic Maps,
Endurain, wanderer).

New OpenSpec changes (proposal/design/specs/tasks each):
- Organic Maps: elevation-profile-hardening, gpx-parser-robustness,
  hiking-time-estimate, poi-index, hiking-foot-profile
- Endurain: account-export, activity-duplicate-review,
  fit-parsing-hardening, activity-locations, self-hosting-guide,
  activity-privacy-controls
- wanderer: federation-hardening, link-share-tokens
- credits-page (user-visible acknowledgments)

Updated in-flight changes with wanderer prior-art sections:
route-federation, route-discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:23:00 +02:00

6.4 KiB
Raw Permalink Blame History

Context

The planner's route stats (WaypointSidebar.tsx) show distance and ascent from EnrichedRoute (route-merge.ts), whose coordinates are [lon, lat, ele] triples from BRouter — elevation is already on every point. BRouter also returns a total-time (summed into EnrichedRoute.totalTime but displayed nowhere); it is computed by the routing profile's speed model. The profile the UI labels "Hiking" is BRouter's trekking.brf — a trekking-bike profile — so its total-time is a cycling time and unusable for hikers. A slope-aware walking model applied to the geometry is both more honest and independent of the profile mislabel.

Organic Maps uses Tobler's hiking function for pedestrian ETA (libs/routing/edge_estimator.cpp, reviewed 2026-07-05): walking speed 6·e^(3.5·|dh/dx + 0.05|) km/h — max ~6 km/h at a 5% descent, ~5 km/h flat, dropping steeply uphill. It is the standard published model (Tobler 1993) and needs only per-segment horizontal distance and elevation change.

computeDays (packages/gpx/src/compute-days.ts) splits a route into DayStages for the multi-day sidebar; the multi-day-routes spec requires per-day "estimated duration" but DayStage has no time field — unimplemented spec, closed by this change. computeDays is also called by the journal's route detail page, so any extension must be opt-in.

Goals / Non-Goals

Goals:

  • A believable walking-time estimate for the whole route and per day, visible in the planner when planning on foot.
  • One pure, tested model function in @trails-cool/gpx, reusable later by the journal.
  • Honest labeling: walking time excluding breaks (mountain-signpost convention).

Non-Goals:

  • Cycling/car time estimates (BRouter's totalTime exists for a later change; mixing two estimation sources in one UI needs its own design).
  • Journal route/activity pages (follow-up; the primitive will be ready).
  • Rest-break or pack-weight adjustments (DIN 33466, Naismith corrections), altitude penalties (OM's >2500 m model), and fixing the trekking-labeled-"Hiking" profile mismatch — the last is a separate routing-profile decision (e.g. adding a real foot profile to the BRouter image) that this change must not block on.

Decisions

1. Model: pure Tobler, per segment, in @trails-cool/gpx

New packages/gpx/src/hiking-time.ts:

  • toblerSpeedKmh(slope) — the bare function, exported for tests.
  • hikingTimeSeconds(points: Array<{lat, lon, ele?}>) — haversine horizontal distance per segment; slope = Δele / horizontal distance; sum distance / toblerSpeed. Segments where either endpoint lacks elevation use slope 0 (flat ≈ 5.04 km/h) — a planned route without DEM data still gets a distance-based estimate rather than nothing.
  • cumulativeHikingTimeSeconds(points) — running totals per point index, for day splitting.
  • Slope clamped to ±100% before applying the formula so a data glitch can't produce a near-zero speed that dominates the total.

Lives in @trails-cool/gpx beside moving-time.ts/compute-days.ts because its input shape is the track-point shape and its consumers are the same. Not map-core (constants only, per conventions).

Alternative: use/patch BRouter's total-time — rejected: for the foot case it's a bike time today, and correcting it means maintaining a custom .brf; a geometry-based model is profile-independent and testable in TypeScript.

2. Applies to the foot profile only, keyed at the planner layer

use-routing.ts computes estimatedTimeSec from enriched.coordinates only when the active profile is the foot profile — hiking once the hiking-foot-profile change lands, else the UI's current "Hiking" (trekking) — and writes it into the route stats object next to distance/elevationGain. The gate lives in the planner, not the library — the library function is sport-agnostic; what "counts as on foot" is a planner UI concept. Profile changes already trigger a routing recompute, so the estimate stays in sync.

3. Per-day times via opt-in computeDays extension

computeDays(waypoints, tracks, opts?) gains opts.estimateHikingTime; when set, each DayStage includes estimatedTimeSec derived from the cumulative time array between the stage's track-point boundaries (same cumulative-array pattern the function already uses for ascent). Optional field + opt-in flag means the journal's existing call sites compile and behave identically, and day times sum exactly to the whole-route total by construction.

Alternative: compute day times in the planner component from DayStage waypoint indices — rejected: the track-point boundary mapping lives inside computeDays; re-deriving it outside duplicates logic the multi-day spec already anchors there.

4. Display: alongside existing stats, labeled as walking time

  • Single-day: the sidebar's stat line and stats grid gain a time entry (e.g. "≈ 4 h 20 min") when estimatedTimeSec is present.
  • Multi-day: each day section header/row in DayBreakdown.tsx shows its day time.
  • Strings via useTranslation (en: "Est. walking time", de: "Gehzeit ca."), formatted h/min; no seconds. The "≈" and the label communicate estimate-without-breaks; a tooltip/help text can note "excluding breaks".

Risks / Trade-offs

  • [Estimate feels wrong to some users — Tobler models a fit walker on decent terrain] → Label as an approximation; Tobler is the same baseline OM ships and matches signpost times reasonably. Surface/terrain multipliers (BRouter gives per-point surface tags we already extract) are a natural follow-up refinement, not v1.
  • [Foot profile is actually BRouter's trekking-bike profile, so the route may not be the path a hiker would take] → Pre-existing issue, orthogonal to the estimate; documented here so the follow-up (real foot .brf) is discoverable. The time estimate is correct for whatever geometry is shown.
  • [Points without elevation degrade to flat-speed] → Acceptable; BRouter always supplies elevation, so this path only triggers for degraded data.
  • [DayStage shape change ripples to journal types] → Field is optional and absent unless opted in; journal render code never sees it.

Migration Plan

Library + planner-only UI change; ships with the normal app deploy. No schema/API changes. Rollback = revert PR.

Open Questions

  • None blocking. Whether to also surface EnrichedRoute.totalTime for cycling profiles, and whether to add a genuine hiking .brf to the BRouter image (and relabel profiles honestly), are follow-up proposals.