From 51bfb5abd1ed4c52eb85c8c1bbd93ebeac8008b8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 13:15:31 +0200 Subject: [PATCH] feat(tours): plan a trail run on forest tracks and natural paths RUN-02. Trail running joins Running as a run profile, chosen on purpose in the planner and in the GPX import. It asks Valhalla's pedestrian costing for tracks wherever they serve (use_tracks 1) and admits mountain paths up to T2 (max_hiking_difficulty 2), never T3 and above, with ferries only where no way on land exists. Checked with trace_attributes against the public instance: above Freiburg Guenterstal up to the Schauinsland the route went from 82 % roads and 83 % paved (running) to 78 % forest tracks and 81 % compacted, gravel and dirt; Rathen to the Bastei took 28 % of T2 path that running leaves out; Heidelberg's old town stayed as it was. Leaning on footways (walkway_factor) was tried and left out, it moved runs onto paved footways. While a run is planned, a card says what it runs on: its surfaces, the hiking grades of its paths (sac_scale, T1 to T6, as mappers gave them) and its kinds of way, each as shares with lengths that point at their stretches on the map. A way without a grade or a surface is its own row, unknown and never easy, and an info dot says the grades say nothing about whether a way can be run. The head carries the unpaved share and the hardest graded path as badges. The line is matched on foot for this, so steps and mountain paths stay on it; a bike tour's is matched as before. Reading fails, goes offline or is retried as for a bike tour, and nothing of it changes the run. Pace and time are RUN-01's: the running time stays the distance at the runner's pace, with climbs, descents and difficulty left out, whatever the profile. A profile change plans the route again and keeps the pace; a failed trail route keeps the last route and holds Save back. The card's building blocks moved out of the bike card into RouteWaySections, so both cards share one set of rows, bars and status lines. The run_profile column already allowed trail (RUN-01), so no migration. GPX export names a trail run trail_running, list_tours describes the profile as a routing preference, never a statement that a way can be run. --- .../src/components/Map/valhallaRoute.test.ts | 15 ++ client/src/components/Map/valhallaRoute.ts | 38 ++- .../Tours/bike/BikeWaysCard.test.tsx | 35 +-- .../components/Tours/bike/BikeWaysCard.tsx | 234 +++-------------- .../Tours/bike/RouteWaySections.tsx | 236 ++++++++++++++++++ client/src/components/Tours/bike/bikePaint.ts | 23 +- .../components/Tours/bike/routeWays.test.ts | 48 +++- client/src/components/Tours/bike/routeWays.ts | 34 ++- .../Tours/bike/useRouteWays.test.tsx | 28 ++- .../src/components/Tours/bike/useRouteWays.ts | 52 ++-- .../Tours/planner/TourPlannerPanels.tsx | 3 + .../Tours/planner/tourRouting.test.ts | 10 + .../components/Tours/planner/tourRouting.ts | 38 ++- .../Tours/planner/useTourPlanner.run.test.tsx | 36 ++- .../components/Tours/run/RunFields.test.tsx | 25 +- client/src/components/Tours/run/RunFields.tsx | 13 +- .../src/components/Tours/run/RunHeadPills.tsx | 8 +- .../components/Tours/run/RunWaysCard.test.tsx | 138 ++++++++++ .../src/components/Tours/run/RunWaysCard.tsx | 127 ++++++++++ .../components/Tours/run/runMarks.test.tsx | 11 +- .../components/Tours/run/runProfileIcon.ts | 5 + client/src/store/tourRouteHighlightStore.ts | 9 +- .../src/nest/tours/tour-gpx-export.helpers.ts | 3 +- server/src/nest/tours/tours.mcp.ts | 2 +- server/tests/e2e/tours-run.e2e.test.ts | 57 ++++- server/tests/unit/db/tour-run-columns.test.ts | 6 +- .../unit/nest/tour-gpx-export.helpers.test.ts | 1 + server/tests/unit/nest/tours.mcp.test.ts | 19 ++ shared/src/i18n/ar/tours.ts | 7 + shared/src/i18n/az/tours.ts | 7 + shared/src/i18n/br/tours.ts | 7 + shared/src/i18n/ca/tours.ts | 7 + shared/src/i18n/cs/tours.ts | 7 + shared/src/i18n/de/tours.ts | 7 + shared/src/i18n/en/tours.ts | 7 + shared/src/i18n/es/tours.ts | 7 + shared/src/i18n/et/tours.ts | 7 + shared/src/i18n/fr/tours.ts | 8 + shared/src/i18n/gr/tours.ts | 7 + shared/src/i18n/hu/tours.ts | 8 + shared/src/i18n/id/tours.ts | 7 + shared/src/i18n/it/tours.ts | 7 + shared/src/i18n/ja/tours.ts | 6 + shared/src/i18n/ko/tours.ts | 7 + shared/src/i18n/nl/tours.ts | 7 + shared/src/i18n/pl/tours.ts | 7 + shared/src/i18n/ru/tours.ts | 7 + shared/src/i18n/sk/tours.ts | 7 + shared/src/i18n/sv/tours.ts | 7 + shared/src/i18n/th/tours.ts | 7 + shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 7 + shared/src/i18n/uk/tours.ts | 7 + shared/src/i18n/vi/tours.ts | 7 + shared/src/i18n/zh-TW/tours.ts | 6 + shared/src/i18n/zh/tours.ts | 6 + shared/src/tours/tours.schema.test.ts | 12 +- shared/src/tours/tours.schema.ts | 11 +- 58 files changed, 1161 insertions(+), 306 deletions(-) create mode 100644 client/src/components/Tours/bike/RouteWaySections.tsx create mode 100644 client/src/components/Tours/run/RunWaysCard.test.tsx create mode 100644 client/src/components/Tours/run/RunWaysCard.tsx create mode 100644 client/src/components/Tours/run/runProfileIcon.ts diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts index e131938eb..011b5fd27 100644 --- a/client/src/components/Map/valhallaRoute.test.ts +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -551,6 +551,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => { expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon }))) }) + it('VALHALLA-TRACE-005: a run is matched on foot without a hiking limit, and every line is asked its hiking grades (RUN-02)', async () => { + const bodies: Record[] = [] + server.use(http.post(TRACE, async ({ request }) => { + bodies.push(await request.json() as Record) + return HttpResponse.json({ shape: SHAPE, edges: [] }) + })) + await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'pedestrian') + expect(bodies[0]).toMatchObject({ + costing: 'pedestrian', + costing_options: { pedestrian: { max_hiking_difficulty: 6 } }, + shape_match: 'map_snap', + }) + expect((bodies[0].filters as { attributes: string[] }).attributes).toContain('edge.sac_scale') + }) + it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => { let shape: unknown[] = [] server.use(http.post(TRACE, async ({ request }) => { diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index b13274d56..ab970d2d2 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -368,14 +368,32 @@ const TRACE_MAX_POINTS = 4000 const TRACE_TIMEOUT_MS = 30_000 /** - * BIK-01: how the router describes the ways under a line (`trace_attributes`): - * the surface, the use and road class, the cycle lane and the OpenStreetMap way - * of every edge, with the line as matched. The edges are matched as a mountain - * bike would ride them, the costing that refuses the fewest ways, so a road - * bike's route and a gravel one are described alike. Null for every failure: + * How a line is matched to the ways under it: as a mountain bike rides, the + * bicycle costing that refuses the fewest ways, or on foot with no hiking + * limit, so steps and mountain paths a run takes stay on the line (matched as + * a bike, a trail run over steps was described as the street beside them). + */ +export type TraceMatch = 'bicycle' | 'pedestrian' + +const TRACE_COSTING: Record>> = { + bicycle: { bicycle: { bicycle_type: 'Mountain' } }, + pedestrian: { pedestrian: { max_hiking_difficulty: 6 } }, +} + +/** + * BIK-01, RUN-02: how the router describes the ways under a line + * (`trace_attributes`): the surface, the use and road class, the cycle lane, + * the hiking grade (`sac_scale`, 0 where the way has none) and the + * OpenStreetMap way of every edge, with the line as matched. A bike tour's + * line is matched as a mountain bike would ride it, so a road bike's route and + * a gravel one are described alike; a run's on foot. Null for every failure: * the description is an addition beside a route that already exists. */ -export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise { +export async function valhallaTraceAttributes( + line: readonly [number, number][], + signal?: AbortSignal, + matchAs: TraceMatch = 'bicycle', +): Promise { const base = valhallaBase() if (!base || line.length < 2) return null const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS)) @@ -389,12 +407,12 @@ export async function valhallaTraceAttributes(line: readonly [number, number][], headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, body: JSON.stringify({ shape, - costing: 'bicycle', - costing_options: { bicycle: { bicycle_type: 'Mountain' } }, + costing: matchAs, + costing_options: TRACE_COSTING[matchAs], shape_match: 'map_snap', filters: { - attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id', - 'edge.begin_shape_index', 'edge.end_shape_index'], + attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.sac_scale', 'edge.length', + 'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index'], action: 'include', }, }), diff --git a/client/src/components/Tours/bike/BikeWaysCard.test.tsx b/client/src/components/Tours/bike/BikeWaysCard.test.tsx index f71ad4d4b..21fa030d0 100644 --- a/client/src/components/Tours/bike/BikeWaysCard.test.tsx +++ b/client/src/components/Tours/bike/BikeWaysCard.test.tsx @@ -15,7 +15,7 @@ vi.mock('./useRouteWays', () => ({ }, })); -// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008 +// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-009 const LINE: [number, number][] = [ [47.0, 11.0], @@ -26,9 +26,9 @@ const LINE: [number, number][] = [ const WAYS: RouteWays = { line: LINE, edges: [ - { from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 }, - { from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 }, - { from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 }, + { from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', sac: 'none', wayId: 1 }, + { from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 }, + { from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', sac: 'none', wayId: 3 }, ], }; @@ -66,16 +66,16 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => { it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => { show({}); const card = screen.getByTestId('bike-ways-card'); - expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument(); - expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m'); - expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument(); + expect(within(screen.getByTestId('route-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument(); + expect(screen.getByTestId('route-ways-row-surface-gravel')).toHaveTextContent('300 m'); + expect(screen.getByTestId('route-ways-row-way-cyclePath')).toBeInTheDocument(); expect(screen.getByText('40 % unpaved')).toBeInTheDocument(); expect(card).not.toHaveTextContent('Trail grade'); }); it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => { show({}); - const row = screen.getByTestId('bike-ways-row-surface-gravel'); + const row = screen.getByTestId('route-ways-row-surface-gravel'); fireEvent.click(row); expect(row).toHaveAttribute('aria-pressed', 'true'); expect(useTourRouteHighlightStore.getState().highlight).toEqual({ @@ -105,15 +105,15 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => { unasked: new Set([3]), }, }); - expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument(); - expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument(); - expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument(); - expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument(); + expect(screen.getByTestId('route-ways-row-trail-s0')).toBeInTheDocument(); + expect(screen.getByTestId('route-ways-row-trail-s2')).toBeInTheDocument(); + expect(screen.getByTestId('route-ways-row-trail-unasked')).toBeInTheDocument(); + expect(screen.getByTestId('route-ways-row-uphill-u3')).toBeInTheDocument(); }); it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => { show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } }); - expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull(); + expect(screen.queryByTestId('route-ways-row-trail-unasked')).toBeNull(); fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]); expect(hook.retry).toHaveBeenCalled(); }); @@ -132,7 +132,7 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => { it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => { const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } }); - expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull(); + expect(screen.queryByTestId('route-ways-row-trail-s0')).toBeNull(); unmount(); show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } }); expect(screen.queryByRole('button', { name: /try again/i })).toBeNull(); @@ -147,6 +147,11 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => { hike.unmount(); show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial); expect(screen.queryByTestId('bike-ways-card')).toBeNull(); - expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' }); + expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matchAs: 'bicycle', withGrades: false }); + }); + + it('FE-TOUR-BIKECARD-009: matches the line as ridden, and asks the trail grades for a mountain bike tour only', () => { + show({}, { bikeProfile: 'mtb' } as Partial); + expect(hook.input).toMatchObject({ active: true, route: LINE, matchAs: 'bicycle', withGrades: true }); }); }); diff --git a/client/src/components/Tours/bike/BikeWaysCard.tsx b/client/src/components/Tours/bike/BikeWaysCard.tsx index f75ff29ca..4cd579aa8 100644 --- a/client/src/components/Tours/bike/BikeWaysCard.tsx +++ b/client/src/components/Tours/bike/BikeWaysCard.tsx @@ -1,5 +1,5 @@ -import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react'; -import { useMemo, type ReactNode } from 'react'; +import { AlertTriangle, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react'; +import { useMemo } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore'; @@ -8,144 +8,29 @@ import { SoftPill } from '../../Planner/planParts'; import { fs } from '../../shared/DialogShell'; import type { TourPlannerController } from '../planner/useTourPlanner'; import { CardAction, RailCard, TourNotice } from '../tourParts'; -import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint'; +import { trailPaintKey } from './bikePaint'; +import { + percent, + RouteWaysProgress, + RouteWaysSource, + ShareBar, + ShareList, + SurfaceSection, + WaySection, + WaysSection, +} from './RouteWaySections'; import { sharesBy, stretchesOf, trailClassOf, + unpavedShareOf, UNPAVED, - type ClassShare, type RouteWayEdge, type RouteWays, - type SurfaceClass, type TrailClass, - type WayClass, } from './routeWays'; import { useRouteWays, type TrailGrades } from './useRouteWays'; -const percent = (share: number) => `${Math.round(share * 100)} %`; - -interface ShareRowProps { - id: string; - row: ClassShare; - label: string; - paint: BikePaintKey; - /** A dot in the class's colour; a kind of way has none. */ - dot: boolean; - ways: RouteWays; - inClass: (edge: RouteWayEdge) => boolean; -} - -/** - * One class of the route: its share and length, and a switch that points at - * its stretches on the map. Pressed again, or another row pressed, it lets go. - */ -function ShareRow({ id, row, label, paint, dot, ways, inClass }: ShareRowProps) { - const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; - const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id); - const toggle = () => - useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint }); - return ( -
  • - -
  • - ); -} - -/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */ -function ShareBar({ rows, paintOf }: { rows: ClassShare[]; paintOf: (key: C) => BikePaintKey }) { - return ( -