diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts index 011b5fd27..2182a8eec 100644 --- a/client/src/components/Map/valhallaRoute.test.ts +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -557,7 +557,7 @@ describe('valhallaTraceAttributes (BIK-01)', () => { bodies.push(await request.json() as Record) return HttpResponse.json({ shape: SHAPE, edges: [] }) })) - await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'pedestrian') + await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'run') expect(bodies[0]).toMatchObject({ costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } }, @@ -589,6 +589,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => { await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull() }) + it('VALHALLA-TRACE-006: a hike is matched on foot with every grade of path open, never by its routing limit (MAP-03)', 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, 'foot') + expect(bodies[0]).toMatchObject({ + costing: 'pedestrian', + costing_options: { pedestrian: { max_hiking_difficulty: 6 } }, + shape_match: 'map_snap', + }) + expect(JSON.stringify(bodies[0])).not.toContain('sac_scale') + }) + it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => { let asked = false server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) })) diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index ab970d2d2..30cf31b91 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -369,30 +369,46 @@ const TRACE_TIMEOUT_MS = 30_000 /** * 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). + * bicycle costing that refuses the fewest ways, so a road bike's route and a + * gravel one are described alike; or on foot with every grade of path open, + * since a hike's line (MAP-03) may run on a T6 ridge the matcher must not + * refuse, and steps and mountain paths a run takes (RUN-02) stay on the line + * (matched as a bike, a trail run over steps was described as the street + * beside them). Matching is no routing: the limit a hike was planned with + * plays no part. A hike and a run are matched alike; they differ in whether + * the router's own hiking grade is asked for. */ -export type TraceMatch = 'bicycle' | 'pedestrian' +const TRACE_COSTING = { + bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } }, + foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } }, + run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } }, +} as const -const TRACE_COSTING: Record>> = { - bicycle: { bicycle: { bicycle_type: 'Mountain' } }, - pedestrian: { pedestrian: { max_hiking_difficulty: 6 } }, +export type TraceMatching = keyof typeof TRACE_COSTING + +const TRACE_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'] + +/** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */ +function traceAttributesFor(matching: TraceMatching): string[] { + return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale'] } /** - * BIK-01, RUN-02: how the router describes the ways under a line + * BIK-01, RUN-02 and MAP-03: 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. + * the OpenStreetMap way of every edge and, but for a hike, the router's + * hiking grade (`sac_scale`, 0 where the way has none), with the line as + * matched. A run's grade shares (RUN-02) read the router's grade; the grade a + * hike shows (MAP-03) comes from OpenStreetMap itself, since the router's + * reading may be normalised or derived, so it is not asked for there. 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, - matchAs: TraceMatch = 'bicycle', + matching: TraceMatching = 'bike' ): Promise { const base = valhallaBase() if (!base || line.length < 2) return null @@ -407,12 +423,10 @@ export async function valhallaTraceAttributes( headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, body: JSON.stringify({ shape, - costing: matchAs, - costing_options: TRACE_COSTING[matchAs], + ...TRACE_COSTING[matching], shape_match: 'map_snap', filters: { - 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'], + attributes: traceAttributesFor(matching), action: 'include', }, }), diff --git a/client/src/components/Tours/ReadStatusNotice.tsx b/client/src/components/Tours/ReadStatusNotice.tsx new file mode 100644 index 000000000..5d26ead77 --- /dev/null +++ b/client/src/components/Tours/ReadStatusNotice.tsx @@ -0,0 +1,40 @@ +import { AlertTriangle, Loader2, RefreshCw, WifiOff } from 'lucide-react'; +import { useTranslation } from '../../i18n'; +import { fs } from '../shared/DialogShell'; +import { CardAction, TourNotice } from './tourParts'; + +/** + * How a card's online read stands while it has no answer to show (a bike + * tour's ways, a hike's SAC grades): still asking, offline, or failed with a + * way to ask again. Nothing while the answer is there, so the card shows it. + */ +export function ReadStatusNotice({ + status, + messages, + onRetry, +}: { + status: string; + messages: { loading: string; offline: string; failed: string }; + onRetry: () => void; +}) { + const { t } = useTranslation(); + if (status === 'loading') + return ( + + + {messages.loading} + + ); + if (status === 'offline') return }>{messages.offline}; + if (status !== 'failed') return null; + return ( + }> + + {messages.failed} + } onClick={onRetry}> + {t('common.errorRetry')} + + + + ); +} diff --git a/client/src/components/Tours/bike/BikeWaysCard.test.tsx b/client/src/components/Tours/bike/BikeWaysCard.test.tsx index 21fa030d0..6cbd3e92e 100644 --- a/client/src/components/Tours/bike/BikeWaysCard.test.tsx +++ b/client/src/components/Tours/bike/BikeWaysCard.test.tsx @@ -49,6 +49,8 @@ const grade = (id: number, mtb: string | null, uphill: string | null = null): To mtb_scale: mtb, mtb_scale_uphill: uphill, mtb_scale_imba: null, + sac_scale: null, + sac_scale_unclear: null, }); function show(state: Partial, overrides: Partial = {}) { @@ -147,11 +149,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, matchAs: 'bicycle', withGrades: false }); + expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matching: 'bike', 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 }); + expect(hook.input).toMatchObject({ active: true, route: LINE, matching: 'bike', withGrades: true }); }); }); diff --git a/client/src/components/Tours/bike/BikeWaysCard.tsx b/client/src/components/Tours/bike/BikeWaysCard.tsx index 4cd579aa8..fc3ed0897 100644 --- a/client/src/components/Tours/bike/BikeWaysCard.tsx +++ b/client/src/components/Tours/bike/BikeWaysCard.tsx @@ -155,7 +155,7 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) { tripId: planner.tripId, active: planner.isBike && editing, route, - matchAs: 'bicycle', + matching: 'bike', withGrades: planner.bikeProfile === 'mtb', }); const { ways } = state; diff --git a/client/src/components/Tours/bike/RouteWaySections.tsx b/client/src/components/Tours/bike/RouteWaySections.tsx index 5cfc7d5f7..cc6174d4c 100644 --- a/client/src/components/Tours/bike/RouteWaySections.tsx +++ b/client/src/components/Tours/bike/RouteWaySections.tsx @@ -1,11 +1,12 @@ -import { AlertTriangle, Info, Loader2, RefreshCw, WifiOff } from 'lucide-react'; +import { Info } from 'lucide-react'; import { useMemo, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore'; import { formatDistance } from '../../../utils/units'; import { fs } from '../../shared/DialogShell'; -import { CardAction, InfoDot, TourNotice } from '../tourParts'; +import { ReadStatusNotice } from '../ReadStatusNotice'; +import { InfoDot } from '../tourParts'; import { bikePaintCss, surfacePaintKey, type BikePaintKey } from './bikePaint'; import { sharesBy, @@ -202,25 +203,16 @@ export function WaySection({ ways }: { ways: RouteWays }) { /** How reading the ways went while there are none to show: on its way, offline, or failed with a way to ask again. */ export function RouteWaysProgress({ state }: { state: Pick & { retry: () => void } }) { const { t } = useTranslation(); - if (state.status === 'loading') - return ( - - - {t('tours.bike.ways.loading')} - - ); - if (state.status === 'offline') - return }>{t('tours.bike.ways.offline')}; - if (state.status !== 'failed') return null; return ( - }> - - {t('tours.bike.ways.failed')} - } onClick={state.retry}> - {t('common.errorRetry')} - - - + ); } diff --git a/client/src/components/Tours/bike/routeHighlight.ts b/client/src/components/Tours/bike/routeHighlight.ts index 552bf5ebe..7d570c217 100644 --- a/client/src/components/Tours/bike/routeHighlight.ts +++ b/client/src/components/Tours/bike/routeHighlight.ts @@ -1,8 +1,9 @@ import type { RouteHighlight } from '../../../store/tourRouteHighlightStore'; -import type { BikePaintKey } from './bikePaint'; type Line = [number, number][]; type LineColor = { line: string; casing: string } | undefined; +/** The route paints resolved for the map, by key: the bike's and the hike's, with the accent and the rim. */ +type RoutePaint = Readonly> & { readonly way: string; readonly casing: string }; /** * The planner map's lines with a highlight laid over them: the route's own @@ -15,13 +16,16 @@ export function withRouteHighlight( route: Line[] | null, walking: boolean[] | null, colors: LineColor[] | null, - paint: Record + paint: RoutePaint ): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } { if (!highlight?.lines.length || !route?.length) return { route, walking, colors }; - const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing }; + const colorOf = (index: number) => ({ + line: paint[highlight.paints?.[index] ?? highlight.paint] ?? paint.way, + casing: paint.casing, + }); return { route: [...route, ...highlight.lines], walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null, - colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)], + colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map((_, index) => colorOf(index))], }; } diff --git a/client/src/components/Tours/bike/routeWays.test.ts b/client/src/components/Tours/bike/routeWays.test.ts index ea3a94f53..e33df96d0 100644 --- a/client/src/components/Tours/bike/routeWays.test.ts +++ b/client/src/components/Tours/bike/routeWays.test.ts @@ -13,7 +13,7 @@ import { type RouteWays, } from './routeWays'; -// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-013 +// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-014 const LINE: [number, number][] = [ [47.0, 11.0], @@ -186,4 +186,19 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => { expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route); expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull(); }); + + it('FE-TOUR-WAYS-014: lines of different paints each take their own, an unknown key the accent (MAP-03)', () => { + const drawn = withRouteHighlight( + { id: 'sac', lines: [LINE.slice(0, 2), LINE.slice(1, 3), LINE.slice(2)], paint: 'gravel', paints: ['way', 'nope'] }, + [LINE], + null, + null, + paint + ); + expect(drawn.colors?.slice(1)).toEqual([ + { line: '#111', casing: '#fff' }, + { line: '#111', casing: '#fff' }, + { line: '#a0522d', casing: '#fff' }, + ]); + }); }); diff --git a/client/src/components/Tours/bike/useHighlightedRoute.test.ts b/client/src/components/Tours/bike/useHighlightedRoute.test.ts index 7009828e9..9ea649366 100644 --- a/client/src/components/Tours/bike/useHighlightedRoute.test.ts +++ b/client/src/components/Tours/bike/useHighlightedRoute.test.ts @@ -8,7 +8,7 @@ const LINE: [number, number][] = [ [47.01, 11], ]; -describe('useHighlightedRoute (BIK-01 to BIK-05)', () => { +describe('useHighlightedRoute (BIK-01 to BIK-05, MAP-03)', () => { it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => { act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' })); const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null)); @@ -19,4 +19,19 @@ describe('useHighlightedRoute (BIK-01 to BIK-05)', () => { act(() => useTourRouteHighlightStore.getState().clear()); expect(shown.result.current.route).toEqual([LINE]); }); + + it('draws the SAC grades of a hike in their own paints beside the bike paints (MAP-03)', () => { + act(() => + useTourRouteHighlightStore + .getState() + .show({ id: 'sac', lines: [LINE, LINE], paint: 'unknown', paints: ['t3', 'unknown'] }) + ); + const { result } = renderHook(() => useHighlightedRoute(true, [LINE], null, null)); + expect(result.current.route).toHaveLength(3); + expect(result.current.colors?.slice(1)).toEqual([ + { line: expect.any(String), casing: expect.any(String) }, + { line: expect.any(String), casing: expect.any(String) }, + ]); + act(() => useTourRouteHighlightStore.getState().clear()); + }); }); diff --git a/client/src/components/Tours/bike/useHighlightedRoute.ts b/client/src/components/Tours/bike/useHighlightedRoute.ts index 077b2667c..92325242a 100644 --- a/client/src/components/Tours/bike/useHighlightedRoute.ts +++ b/client/src/components/Tours/bike/useHighlightedRoute.ts @@ -1,4 +1,6 @@ +import { useMemo } from 'react'; import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore'; +import { useHikePaint } from '../hike/hikePaint'; import { useBikePaint } from './bikePaint'; import { withRouteHighlight } from './routeHighlight'; @@ -7,12 +9,14 @@ type Walking = Parameters[2]; type Colors = Parameters[3]; /** - * BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's - * card points at laid over it, while `shown` (the route being edited is on - * the map); otherwise the lines as they came. + * BIK-01 to BIK-05 and MAP-03: the planner map's route with the stretches a + * bike tour's or a hike's card points at laid over it, while `shown` (the + * tour's own line is on the map); otherwise the lines as they came. */ export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) { const highlight = useTourRouteHighlightStore((state) => state.highlight); - const paint = useBikePaint(); + const bike = useBikePaint(); + const hike = useHikePaint(); + const paint = useMemo(() => ({ ...bike, ...hike }), [bike, hike]); return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint); } diff --git a/client/src/components/Tours/bike/useRouteWays.test.tsx b/client/src/components/Tours/bike/useRouteWays.test.tsx index 4d95f85a2..b7d7f394f 100644 --- a/client/src/components/Tours/bike/useRouteWays.test.tsx +++ b/client/src/components/Tours/bike/useRouteWays.test.tsx @@ -12,7 +12,7 @@ vi.mock('../../Map/valhallaRoute', () => trace); vi.mock('../../../repo/tourTrailGradeRepo', () => ({ tourTrailGradeRepo: gradesRepo })); vi.mock('../../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: network.offline }) })); -// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-009 +// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-011 const ROUTE: [number, number][] = [ [47.0, 11.0], @@ -32,7 +32,7 @@ const input = (overrides: Partial = {}): Input => ({ tripId: 3, active: true, route: ROUTE, - matchAs: 'bicycle', + matching: 'bike', withGrades: false, ...overrides, }); @@ -66,7 +66,7 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { expect(result.current.status).toBe('loading'); expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled(); await settle(); - expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle'); + expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bike'); expect(result.current.status).toBe('ready'); expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']); expect(result.current.grades).toBeNull(); @@ -75,7 +75,9 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { it('FE-TOUR-WAYS-HOOK-003: a mountain bike tour asks the grades of its ways, and keeps what could not be asked apart', async () => { gradesRepo.grades.mockResolvedValue({ - ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }], + ways: [ + { id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null, sac_scale: null, sac_scale_unclear: null }, + ], unanswered: [12], }); const { result } = renderHook(() => useRouteWays(input({ withGrades: true }))); @@ -127,17 +129,17 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled(); }); - it('FE-TOUR-WAYS-HOOK-009: a run is matched on foot, and matched again when asked another way (RUN-02)', async () => { + it('FE-TOUR-WAYS-HOOK-011: a run is matched on foot, and matched again when asked another way (RUN-02)', async () => { const { result, rerender } = renderHook((props: Input) => useRouteWays(props), { - initialProps: input({ matchAs: 'pedestrian' }), + initialProps: input({ matching: 'run' }), }); await settle(); - expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'pedestrian'); + expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'run'); expect(result.current.status).toBe('ready'); - rerender(input({ matchAs: 'bicycle' })); + rerender(input({ matching: 'bike' })); expect(result.current.status).toBe('loading'); await settle(); - expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle'); + expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bike'); }); it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => { @@ -152,4 +154,16 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { unmount(); expect(useTourRouteHighlightStore.getState().highlight).toBeNull(); }); + + it('FE-TOUR-WAYS-HOOK-009: a hike is matched on foot, and a change of matching asks again (MAP-03)', async () => { + gradesRepo.grades.mockResolvedValue({ ways: [], unanswered: [] }); + const { result, rerender } = renderHook((props: Input) => useRouteWays(props), { + initialProps: input({ matching: 'foot', withGrades: true }), + }); + await settle(); + expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'foot'); + expect(result.current.grades?.status).toBe('ready'); + rerender(input({ matching: 'bike', withGrades: true })); + expect(result.current.status).toBe('loading'); + }); }); diff --git a/client/src/components/Tours/bike/useRouteWays.ts b/client/src/components/Tours/bike/useRouteWays.ts index 99fe3c565..e8a399174 100644 --- a/client/src/components/Tours/bike/useRouteWays.ts +++ b/client/src/components/Tours/bike/useRouteWays.ts @@ -4,7 +4,7 @@ import { useNetworkMode } from '../../../hooks/useNetworkMode'; import { OnlineOnlyError } from '../../../repo/onlineOnly'; import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo'; import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore'; -import { valhallaTraceAttributes, type TraceMatch } from '../../Map/valhallaRoute'; +import { valhallaTraceAttributes, type TraceMatching } from '../../Map/valhallaRoute'; import { routeWaysFrom, wayIdsOf, type RouteWays } from './routeWays'; /** How long a route has to stay before its ways are asked about, so a route being shaped asks once. */ @@ -22,7 +22,7 @@ export interface TrailGrades { export interface RouteWaysState { status: RouteWaysStatus; ways: RouteWays | null; - /** BIK-05, a mountain bike tour's only: the trail grades of its ways. */ + /** BIK-05 and MAP-03, where asked for: the trail grades of its ways. */ grades: TrailGrades | null; } @@ -30,31 +30,27 @@ interface Answer extends RouteWaysState { attempt: number; /** The route and the question it answers; another one makes it stale. */ route: [number, number][]; - matchAs: TraceMatch; + matching: TraceMatching; withGrades: boolean; } const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null }; const NO_GRADES = { byWay: new Map(), unasked: new Set() }; -interface RouteWaysInput { +interface Question { tripId: number | string; - active: boolean; - route: [number, number][] | null; - /** How the line is matched to its ways: a bike tour as ridden, a run on foot. */ - matchAs: TraceMatch; - /** BIK-05, a mountain bike tour's only: the trail grades of its ways are asked too. */ + route: [number, number][]; + matching: TraceMatching; withGrades: boolean; } -/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */ +/** The ways of a route, and where asked for their grades; the grades' outcome as it came. */ async function askRouteWays( - question: Pick & { route: [number, number][] }, + { tripId, route, matching, withGrades }: Question, signal: AbortSignal, onWays: (ways: RouteWays) => void ): Promise { - const { tripId, route, matchAs, withGrades } = question; - const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matchAs)); + const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matching)); if (!ways) return { status: 'failed', ways: null, grades: null }; if (!withGrades) return { status: 'ready', ways, grades: null }; onWays(ways); @@ -71,13 +67,21 @@ async function askRouteWays( } /** - * BIK-01 to BIK-05, RUN-02: what the bike tour or the run being planned runs - * on, asked of the router once its route has settled, and for a mountain bike - * tour the trail grades of its ways. A new route makes the answer on its way - * stale and takes the map's highlight down; nothing of it changes the tour. + * BIK-01 to BIK-05 and MAP-03: what the tour being planned runs on, asked of + * the router once its route has settled, and where asked for the trail grades + * of its ways (a mountain bike tour's, a hike's SAC grades). A new route makes + * the answer on its way stale and takes the map's highlight down; nothing of + * it changes the tour. */ -export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: () => void } { - const { tripId, active, route, matchAs, withGrades } = input; +export function useRouteWays(input: { + tripId: number | string; + active: boolean; + route: [number, number][] | null; + /** How the line is matched to its ways: as a bike rides it, or on foot for a hike. */ + matching: TraceMatching; + withGrades: boolean; +}): RouteWaysState & { retry: () => void } { + const { tripId, active, route, matching, withGrades } = input; const { offline } = useNetworkMode(); const [answer, setAnswer] = useState(null); const [attempt, setAttempt] = useState(0); @@ -89,10 +93,10 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: ( if (!asking || !route) return; const controller = new AbortController(); const settle = (state: RouteWaysState) => { - if (!controller.signal.aborted) setAnswer({ ...state, route, matchAs, withGrades, attempt }); + if (!controller.signal.aborted) setAnswer({ ...state, route, matching, withGrades, attempt }); }; const timer = window.setTimeout(() => { - askRouteWays({ tripId, route, matchAs, withGrades }, controller.signal, (ways) => + askRouteWays({ tripId, route, matching, withGrades }, controller.signal, (ways) => settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } }) ) .then(settle) @@ -103,7 +107,7 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: ( window.clearTimeout(timer); controller.abort(); }; - }, [asking, attempt, matchAs, route, tripId, withGrades]); + }, [asking, attempt, matching, route, tripId, withGrades]); // The highlight belongs to the route it was drawn on. useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []); @@ -112,7 +116,7 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: ( if (offline) return { status: 'offline', ways: null, grades: null, retry }; const current = answer?.route === route && - answer.matchAs === matchAs && + answer.matching === matching && answer.withGrades === withGrades && answer.attempt === attempt; return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry }; diff --git a/client/src/components/Tours/hike/SacGradesCard.test.tsx b/client/src/components/Tours/hike/SacGradesCard.test.tsx new file mode 100644 index 000000000..403ec97b1 --- /dev/null +++ b/client/src/components/Tours/hike/SacGradesCard.test.tsx @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import { SacGradesCard } from './SacGradesCard'; +import type { SacGradeRow, SacGrades } from './useSacGrades'; + +const hook = vi.hoisted(() => ({ grades: null as unknown, input: null as unknown })); +vi.mock('./useSacGrades', () => ({ + useSacGrades: (input: unknown) => { + hook.input = input; + return hook.grades; + }, +})); + +// FE-HIKE-SACCARD-001 to FE-HIKE-SACCARD-007 + +const LINE: [number, number][] = [ + [47.0, 11.0], + [47.01, 11.0], +]; +const ROWS: SacGradeRow[] = [ + { key: 't2', lengthM: 1200, share: 0.4, tags: ['mountain_hiking'] }, + { key: 't4', lengthM: 600, share: 0.2, tags: ['alpine_hiking'] }, + { key: 'unclear', lengthM: 300, share: 0.1, tags: ['T3', 'hiking;mountain_hiking'] }, + { key: 'untagged', lengthM: 700, share: 0.2, tags: [] }, + { key: 'unmatched', lengthM: 100, share: 0.05, tags: [] }, + { key: 'unasked', lengthM: 100, share: 0.05, tags: [] }, +]; + +function grades(overrides: Partial = {}): SacGrades { + return { + available: true, + shown: true, + toggle: vi.fn(), + status: 'ready', + rows: ROWS, + knownShare: 0.6, + highest: 4, + focus: null, + focusOn: vi.fn(), + retry: vi.fn(), + ...overrides, + }; +} + +function show(state: SacGrades, routingLimit: number | null = 3) { + hook.grades = state; + return render(); +} + +beforeEach(() => localStorage.clear()); + +describe('SacGradesCard (MAP-03)', () => { + it('FE-HIKE-SACCARD-001: without a line to grade there is no card', () => { + show(grades({ available: false, shown: false, status: 'off', rows: [] })); + expect(screen.queryByTestId('sac-grades-card')).toBeNull(); + expect(hook.input).toEqual({ tripId: 4, route: LINE, current: true }); + }); + + it('FE-HIKE-SACCARD-002: switched off it only offers the switch, and the switch turns it on', () => { + const state = grades({ shown: false, status: 'off', rows: [] }); + show(state); + const toggle = screen.getByRole('button', { name: 'Show along the route' }); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByTestId('sac-row-t2')).toBeNull(); + fireEvent.click(toggle); + expect(state.toggle).toHaveBeenCalledTimes(1); + }); + + it('FE-HIKE-SACCARD-003: lists the known steps with their share, the unknown apart, and the routing limit as a setting', () => { + show(grades()); + expect(within(screen.getByTestId('sac-row-t2')).getByText('T2')).toBeInTheDocument(); + expect(screen.getByTestId('sac-row-t2')).toHaveTextContent('Mountain hiking'); + expect(screen.getByTestId('sac-row-t2')).toHaveTextContent('40 %'); + expect(within(screen.getByTestId('sac-row-untagged')).getByText('?')).toBeInTheDocument(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByTestId('sac-known')).toHaveTextContent('60 %'); + expect(screen.getByTestId('sac-highest')).toHaveTextContent('T4'); + expect(screen.getByTestId('sac-routing-limit')).toHaveTextContent('Routing limit'); + expect(screen.getByTestId('sac-routing-limit')).toHaveTextContent('T3'); + // Only a known step above the limit says so; the unknown never does. + expect(within(screen.getByTestId('sac-row-t4')).getByText('Above the limit')).toBeInTheDocument(); + expect(within(screen.getByTestId('sac-row-t2')).queryByText('Above the limit')).toBeNull(); + expect(within(screen.getByTestId('sac-row-untagged')).queryByText('Above the limit')).toBeNull(); + }); + + it('FE-HIKE-SACCARD-004: an import, planned without a routing limit, shows none and nothing above it', () => { + show(grades({ highest: null, rows: ROWS.slice(2), knownShare: 0 }), null); + expect(screen.queryByTestId('sac-routing-limit')).toBeNull(); + expect(screen.queryByTestId('sac-highest')).toBeNull(); + expect(screen.queryByText('Above the limit')).toBeNull(); + expect(screen.getByTestId('sac-known')).toHaveTextContent('0 %'); + }); + + it('FE-HIKE-SACCARD-005: a row draws its class alone on the map, and shows that it does', () => { + const state = grades({ focus: 't4' }); + show(state); + expect(screen.getByTestId('sac-row-t4')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('sac-row-t2')).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByTestId('sac-row-t2')); + expect(state.focusOn).toHaveBeenCalledWith('t2'); + }); + + it('FE-HIKE-SACCARD-006: says when it is asking, offline, or failed with a way to ask again', () => { + const loading = show(grades({ status: 'loading', rows: [] })); + expect(screen.getByRole('status')).toHaveTextContent('Reading the SAC grades'); + loading.unmount(); + const offline = show(grades({ status: 'offline', rows: [] })); + expect(screen.getByText('The SAC grades need a connection.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).toBeNull(); + offline.unmount(); + const state = grades({ status: 'failed', rows: [] }); + show(state); + expect(screen.getByText(/The tour is unchanged/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(state.retry).toHaveBeenCalledTimes(1); + }); + + it('FE-HIKE-SACCARD-007: a row names its scale and its source, an unclear one what OpenStreetMap wrote', async () => { + show(grades()); + fireEvent.mouseEnter(screen.getByTestId('sac-row-t2')); + const source = 'SAC hiking scale T2, from OpenStreetMap: sac_scale=mountain_hiking'; + expect(await screen.findByText(source)).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId('sac-row-t2')); + fireEvent.mouseEnter(screen.getByTestId('sac-row-unclear')); + expect(await screen.findByText(/T3, hiking;mountain_hiking/)).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId('sac-row-unclear')); + fireEvent.mouseEnter(screen.getByTestId('sac-row-untagged')); + expect(await screen.findByText(/Unknown does not mean easy/)).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId('sac-row-untagged')); + fireEvent.mouseEnter(screen.getByTestId('sac-row-unmatched')); + expect(await screen.findByText(/no way lies clearly under the line/i)).toBeInTheDocument(); + // A way that could not be asked about says so in its row; there is nothing more to name. + expect(screen.getByTestId('sac-row-unasked')).toHaveTextContent('Could not be asked this time'); + }); +}); diff --git a/client/src/components/Tours/hike/SacGradesCard.tsx b/client/src/components/Tours/hike/SacGradesCard.tsx new file mode 100644 index 000000000..69c92b548 --- /dev/null +++ b/client/src/components/Tours/hike/SacGradesCard.tsx @@ -0,0 +1,218 @@ +import { Info, MountainSnow, SlidersHorizontal } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance } from '../../../utils/units'; +import { SoftPill } from '../../Planner/planParts'; +import ToggleSwitch from '../../Settings/ToggleSwitch'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { bikePaintCss } from '../bike/bikePaint'; +import { FigureBadge } from '../paddle/FigureBadge'; +import { ReadStatusNotice } from '../ReadStatusNotice'; +import { RailCard } from '../tourParts'; +import { hikePaintCss } from './hikePaint'; +import { isSacStep, sacStepOfClass, type SacClass } from './sacGrades'; +import { useSacGrades, type SacGradeRow, type SacGrades } from './useSacGrades'; + +const percent = (share: number) => `${Math.round(share * 100)} %`; +const stepName = (step: number) => `T${step}`; + +/** The colour a class is drawn in on the map, as a CSS variable: the step's own, the grey of the unknown otherwise. */ +const paintOf = (key: SacClass) => (isSacStep(key) ? hikePaintCss(key) : bikePaintCss('unknown')); + +/** A grade as a small white chip: a dot in its map colour and the step, or a question mark for the unknown. */ +function GradeChip({ grade }: { grade: SacClass }) { + const step = sacStepOfClass(grade); + return ( + + + ); +} + +/** Where a row's grade comes from, said in its tooltip: the scale and the tag, or why it is unknown. */ +function useRowSource(row: SacGradeRow): string { + const { t } = useTranslation(); + const step = sacStepOfClass(row.key); + if (step !== null) return t('tours.hike.sac.stepTip', { step: stepName(step), tag: row.tags[0] ?? '' }); + if (row.key === 'untagged') return t('tours.hike.sac.untaggedTip'); + if (row.key === 'unclear') return t('tours.hike.sac.unclearTip', { tags: row.tags.join(', ') }); + if (row.key === 'unmatched') return t('tours.hike.sac.unmatchedTip'); + return ''; +} + +/** + * One class of the hike: its chip, its name, how far and how much of the + * route it covers. Pressed, the map draws its stretches alone; pressed again, + * every grade again. + */ +function GradeRow({ row, grades, limit }: { row: SacGradeRow; grades: SacGrades; limit: number | null }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const source = useRowSource(row); + const step = sacStepOfClass(row.key); + const pressed = grades.focus === row.key; + return ( +
  • + + + +
  • + ); +} + +/** The classes as one bar along the route's length, in the map's colours, so the mix reads at a glance. */ +function GradeBar({ rows }: { rows: SacGradeRow[] }) { + return ( +