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 ( -