diff --git a/client/src/components/Tours/bike/BikeWaysCard.test.tsx b/client/src/components/Tours/bike/BikeWaysCard.test.tsx index 6cbd3e92e..620b04e1f 100644 --- a/client/src/components/Tours/bike/BikeWaysCard.test.tsx +++ b/client/src/components/Tours/bike/BikeWaysCard.test.tsx @@ -51,6 +51,8 @@ const grade = (id: number, mtb: string | null, uphill: string | null = null): To mtb_scale_imba: null, sac_scale: null, sac_scale_unclear: null, + surface: null, + highway: null, }); function show(state: Partial, overrides: Partial = {}) { diff --git a/client/src/components/Tours/bike/useRouteWays.test.tsx b/client/src/components/Tours/bike/useRouteWays.test.tsx index b7d7f394f..b17ad573e 100644 --- a/client/src/components/Tours/bike/useRouteWays.test.tsx +++ b/client/src/components/Tours/bike/useRouteWays.test.tsx @@ -76,7 +76,16 @@ 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, sac_scale: null, sac_scale_unclear: null }, + { + id: 11, + mtb_scale: '2', + mtb_scale_uphill: null, + mtb_scale_imba: null, + sac_scale: null, + sac_scale_unclear: null, + surface: null, + highway: null, + }, ], unanswered: [12], }); diff --git a/client/src/components/Tours/bike/useRouteWays.ts b/client/src/components/Tours/bike/useRouteWays.ts index e8a399174..2db89a432 100644 --- a/client/src/components/Tours/bike/useRouteWays.ts +++ b/client/src/components/Tours/bike/useRouteWays.ts @@ -22,7 +22,7 @@ export interface TrailGrades { export interface RouteWaysState { status: RouteWaysStatus; ways: RouteWays | null; - /** BIK-05 and MAP-03, where asked for: the trail grades of its ways. */ + /** BIK-05, MAP-03 and MAP-04, where asked for: what OpenStreetMap records for its ways. */ grades: TrailGrades | null; } @@ -67,9 +67,10 @@ async function askRouteWays( } /** - * 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 + * BIK-01 to BIK-05, MAP-03 and MAP-04: what the tour being planned runs on, + * asked of the router once its route has settled, and where asked for what + * OpenStreetMap records for its ways (a mountain bike tour's grades; a hike's + * SAC grades, surfaces and kinds of way). A new route makes * the answer on its way stale and takes the map's highlight down; nothing of * it changes the tour. */ diff --git a/client/src/components/Tours/hike/HikeWaysCard.test.tsx b/client/src/components/Tours/hike/HikeWaysCard.test.tsx new file mode 100644 index 000000000..606706dca --- /dev/null +++ b/client/src/components/Tours/hike/HikeWaysCard.test.tsx @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import type { FacetRow } from './hikeFacets'; +import { HikeWaysCard } from './HikeWaysCard'; +import type { HikeWays } from './useHikeWays'; + +const hook = vi.hoisted(() => ({ ways: null as unknown, input: null as unknown })); +vi.mock('./useHikeWays', () => ({ + useHikeWays: (input: unknown) => { + hook.input = input; + return hook.ways; + }, +})); + +// FE-HIKE-WAYSCARD-001 to FE-HIKE-WAYSCARD-011 + +const LINE: [number, number][] = [ + [47.0, 11.0], + [47.01, 11.0], +]; +const SAC_ROWS: FacetRow[] = [ + { key: 't2', lengthM: 1200, share: 0.4, tags: ['mountain_hiking'] }, + { key: 't4', lengthM: 600, share: 0.2, tags: ['alpine_hiking'] }, + { key: 'untagged', lengthM: 700, share: 0.2, tags: [] }, + { key: 'unclear', lengthM: 300, share: 0.1, tags: ['T3', 'hiking;mountain_hiking'] }, + { key: 'unmatched', lengthM: 100, share: 0.05, tags: [] }, + { key: 'unasked', lengthM: 100, share: 0.05, tags: [] }, +]; +const SURFACE_ROWS: FacetRow[] = [ + { key: 'gravel', lengthM: 1500, share: 0.5, tags: ['gravel', 'pebblestone'] }, + { key: 'rock', lengthM: 600, share: 0.2, tags: ['rock'] }, + { key: 'untagged', lengthM: 600, share: 0.2, tags: [] }, + { key: 'unclear', lengthM: 300, share: 0.1, tags: ['gravel;ground'] }, +]; +const WAY_ROWS: FacetRow[] = [ + { key: 'path', lengthM: 2000, share: 0.7, tags: ['path'] }, + { key: 'track', lengthM: 900, share: 0.3, tags: ['track'] }, +]; + +function ways(overrides: Partial = {}): HikeWays { + return { + available: true, + shown: true, + toggle: vi.fn(), + facet: 'sac', + chooseFacet: vi.fn(), + status: 'ready', + rows: SAC_ROWS, + knownShare: 0.6, + mostly: SAC_ROWS[0], + highest: 4, + focus: null, + focusOn: vi.fn(), + retry: vi.fn(), + ...overrides, + }; +} + +function show(state: HikeWays, routingLimit: number | null = 3) { + hook.ways = state; + return render(); +} + +async function tooltipOf(testId: string, text: string | RegExp) { + fireEvent.mouseEnter(screen.getByTestId(testId)); + expect(await screen.findByText(text)).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId(testId)); +} + +beforeEach(() => localStorage.clear()); + +describe('HikeWaysCard (MAP-03, MAP-04)', () => { + it('FE-HIKE-WAYSCARD-001: without a line to describe there is no card', () => { + show(ways({ available: false, shown: false, status: 'off', rows: [] })); + expect(screen.queryByTestId('hike-ways-card')).toBeNull(); + expect(hook.input).toEqual({ tripId: 4, route: LINE, current: true }); + }); + + it('FE-HIKE-WAYSCARD-002: switched off it only offers the switch, and the switch turns it on', () => { + const state = ways({ 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.queryByRole('group', { name: 'What to show' })).toBeNull(); + expect(screen.queryByTestId('sac-row-t2')).toBeNull(); + fireEvent.click(toggle); + expect(state.toggle).toHaveBeenCalledTimes(1); + }); + + it('FE-HIKE-WAYSCARD-003: switched on it offers the SAC grade, the surface and the kind of way apart', () => { + const state = ways(); + show(state); + const facets = screen.getByRole('group', { name: 'What to show' }); + expect(within(facets).getByRole('button', { name: 'SAC' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(facets).getByRole('button', { name: 'Surface' })).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(within(facets).getByRole('button', { name: 'Way type' })); + expect(state.chooseFacet).toHaveBeenCalledWith('way'); + }); + + it('FE-HIKE-WAYSCARD-004: lists the known steps with their share, the unknown apart, and the routing limit as a setting', () => { + show(ways()); + 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.queryByTestId('sac-mostly')).toBeNull(); + 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-WAYSCARD-005: an import, planned without a routing limit, shows none and nothing above it', () => { + show(ways({ highest: null, rows: SAC_ROWS.slice(2), knownShare: 0, mostly: null }), 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-WAYSCARD-006: a row draws its class alone on the map, and shows that it does', () => { + const state = ways({ 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-WAYSCARD-007: says when it is asking, offline, or failed with a way to ask again', () => { + const loading = show(ways({ status: 'loading', rows: [] })); + expect(screen.getByRole('status')).toHaveTextContent('Reading the ways along the hike'); + loading.unmount(); + const offline = show(ways({ status: 'offline', rows: [] })); + expect(screen.getByText('The trail details need a connection.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).toBeNull(); + offline.unmount(); + const state = ways({ 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-WAYSCARD-008: an SAC row names its scale and its source, an unclear one what OpenStreetMap wrote', async () => { + show(ways()); + await tooltipOf('sac-row-t2', 'SAC hiking scale T2, from OpenStreetMap: sac_scale=mountain_hiking'); + await tooltipOf('sac-row-unclear', /T3, hiking;mountain_hiking/); + await tooltipOf('sac-row-untagged', /Unknown does not mean easy/); + await tooltipOf('sac-row-unmatched', /no way lies clearly under the line/i); + // 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'); + }); + + it('FE-HIKE-WAYSCARD-009: the surfaces list their groups, the most common one, and no grade or routing limit', () => { + show(ways({ facet: 'surface', rows: SURFACE_ROWS, knownShare: 0.7, mostly: SURFACE_ROWS[0], highest: null })); + expect(screen.getByTestId('surface-row-gravel')).toHaveTextContent('Gravel'); + expect(screen.getByTestId('surface-row-gravel')).toHaveTextContent('50 %'); + // A known group carries its colour only; the question mark is the unknown's. + expect(within(screen.getByTestId('surface-row-gravel')).queryByText('?')).toBeNull(); + expect(within(screen.getByTestId('surface-row-untagged')).getByText('?')).toBeInTheDocument(); + expect(screen.getByTestId('surface-row-untagged')).toHaveTextContent('No surface in OpenStreetMap'); + expect(screen.getByTestId('surface-known')).toHaveTextContent('70 %'); + expect(screen.getByTestId('surface-mostly')).toHaveTextContent('Gravel'); + expect(screen.queryByTestId('sac-routing-limit')).toBeNull(); + expect(screen.queryByTestId('sac-highest')).toBeNull(); + expect(screen.queryByText('Above the limit')).toBeNull(); + expect(screen.getByText(/router's own reading is not used/)).toBeInTheDocument(); + }); + + it('FE-HIKE-WAYSCARD-010: a surface row names the values OpenStreetMap recorded, the untagged one that nothing was', async () => { + show(ways({ facet: 'surface', rows: SURFACE_ROWS, knownShare: 0.7, mostly: SURFACE_ROWS[0], highest: null })); + await tooltipOf('surface-row-gravel', 'Recorded in OpenStreetMap as surface=gravel, surface=pebblestone'); + await tooltipOf('surface-row-unclear', /surface=gravel;ground/); + await tooltipOf('surface-row-untagged', /A router may assume one/); + }); + + it('FE-HIKE-WAYSCARD-011: the kinds of way read under their own names and source', async () => { + const state = ways({ facet: 'way', rows: WAY_ROWS, knownShare: 1, mostly: WAY_ROWS[0], highest: null }); + show(state); + expect(screen.getByTestId('way-row-track')).toHaveTextContent('Farm or forest track'); + expect(screen.getByTestId('way-mostly')).toHaveTextContent('Path'); + expect(screen.queryByText('Unknown')).toBeNull(); + await tooltipOf('way-row-path', 'Recorded in OpenStreetMap as highway=path'); + fireEvent.click(screen.getByTestId('way-row-track')); + expect(state.focusOn).toHaveBeenCalledWith('track'); + }); +}); diff --git a/client/src/components/Tours/hike/HikeWaysCard.tsx b/client/src/components/Tours/hike/HikeWaysCard.tsx new file mode 100644 index 000000000..5d557d577 --- /dev/null +++ b/client/src/components/Tours/hike/HikeWaysCard.tsx @@ -0,0 +1,78 @@ +import { Footprints, Layers, MountainSnow, Signpost } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import ToggleSwitch from '../../Settings/ToggleSwitch'; +import { Segmented, type SegmentedOption } from '../../shared/dialogParts'; +import { fs } from '../../shared/DialogShell'; +import { ReadStatusNotice } from '../ReadStatusNotice'; +import { RailCard } from '../tourParts'; +import type { HikeFacet } from './hikeFacetSpecs'; +import { HikeWaysList } from './HikeWaysList'; +import { useHikeWays } from './useHikeWays'; + +const ICON = { size: 13, strokeWidth: 2 } as const; + +/** + * MAP-03 and MAP-04: what OpenStreetMap records for the ways along a hike, on + * the traveller's word. Switched on, the ways under the line are read once + * and drawn along it on the map by the facet picked: the SAC grade, the + * surface or the kind of way, three things kept apart, each class with its + * share; the unknown stays unknown and says why. The routing limit, where the + * hike was planned with one, sits apart as the setting it is. Nothing here + * changes the tour. + */ +export function HikeWaysCard({ + tripId, + route, + routeCurrent = true, + routingLimit, +}: { + tripId: number | string; + /** The tour's own line, or null while there is none. */ + route: [number, number][] | null; + /** False while the line is being planned anew. */ + routeCurrent?: boolean; + /** The routing limit the hike is planned with, or null where it was not routed (an import). */ + routingLimit: number | null; +}) { + const { t } = useTranslation(); + const ways = useHikeWays({ tripId, route, current: routeCurrent }); + if (!ways.available) return null; + const facets: SegmentedOption[] = [ + { value: 'sac', label: t('tours.hike.ways.facet.sac'), icon: }, + { value: 'surface', label: t('tours.hike.ways.facet.surface'), icon: }, + { value: 'way', label: t('tours.hike.ways.facet.way'), icon: }, + ]; + return ( + } title={t('tours.hike.ways.title')} foldId="hike-ways"> +
+
+ + +
+ {ways.shown && ( + <> + + value={ways.facet} + options={facets} + onChange={ways.chooseFacet} + label={t('tours.hike.ways.facets')} + fill + /> + + + )} + {ways.shown && ways.status === 'ready' && } +
+
+ ); +} diff --git a/client/src/components/Tours/hike/HikeWaysList.tsx b/client/src/components/Tours/hike/HikeWaysList.tsx new file mode 100644 index 000000000..440be9f97 --- /dev/null +++ b/client/src/components/Tours/hike/HikeWaysList.tsx @@ -0,0 +1,222 @@ +import { Info, SlidersHorizontal } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance } from '../../../utils/units'; +import { SoftPill } from '../../Planner/planParts'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { bikePaintCss } from '../bike/bikePaint'; +import { FigureBadge } from '../paddle/FigureBadge'; +import { HIKE_FACETS, type HikeFacet } from './hikeFacetSpecs'; +import { facetPaint, isUnknownClass, type FacetRow } from './hikeFacets'; +import { hikePaintCss } from './hikePaint'; +import { sacStepOfClass } from './sacGrades'; +import type { HikeWays } from './useHikeWays'; + +const percent = (share: number) => `${Math.round(share * 100)} %`; +const stepName = (step: number) => `T${step}`; + +const sacKey = (name: string) => `tours.hike.sac.${name}`; +const surfaceKey = (name: string) => `tours.hike.surface.${name}`; +const wayKey = (name: string) => `tours.hike.way.${name}`; +const sharedKey = (name: string) => `tours.hike.ways.${name}`; + +/** A facet's own texts; a way not matched or not asked reads the same in every facet. */ +const FACET_KEY: Readonly string>> = { + sac: sacKey, + surface: surfaceKey, + way: wayKey, +}; + +/** The OpenStreetMap key a facet's values are written under, named in the tooltips. */ +const FACET_TAG: Readonly> = { sac: 'sac_scale', surface: 'surface', way: 'highway' }; + +function labelKey(facet: HikeFacet, key: string): string { + return key === 'unmatched' || key === 'unasked' ? sharedKey(key) : FACET_KEY[facet](key); +} + +/** The colour a class is drawn in on the map, as a CSS variable: its own, the grey of the unknown otherwise. */ +function paintOf(facet: HikeFacet, key: string): string { + const paint = facetPaint(HIKE_FACETS[facet], key); + return paint === 'unknown' ? bikePaintCss('unknown') : hikePaintCss(paint); +} + +/** What OpenStreetMap wrote for a row, as `key=value`, each once. */ +const writtenTags = (facet: HikeFacet, row: FacetRow) => row.tags.map((tag) => `${FACET_TAG[facet]}=${tag}`).join(', '); + +/** + * A class as a small white chip: a dot in its map colour, with the step of an + * SAC grade, or a question mark for the unknown. + */ +function ClassChip({ facet, row }: { facet: HikeFacet; row: FacetRow }) { + const step = sacStepOfClass(row.key); + const unknown = isUnknownClass(row.key); + return ( + + + ); +} + +/** Where a row's class comes from, said in its tooltip: what OpenStreetMap wrote, or why it is unknown. */ +function useRowSource(facet: HikeFacet, row: FacetRow): 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(FACET_KEY[facet]('untaggedTip')); + if (row.key === 'unmatched') return t('tours.hike.ways.unmatchedTip'); + if (row.key === 'unasked') return ''; + if (row.key === 'unclear' && facet === 'sac') return t('tours.hike.sac.unclearTip', { tags: row.tags.join(', ') }); + if (row.key === 'unclear') return t('tours.hike.ways.unclearTip', { tags: writtenTags(facet, row) }); + return t('tours.hike.ways.recordedTip', { tags: writtenTags(facet, row) }); +} + +/** + * One class along 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 class again. + */ +function ClassRow({ row, ways, limit }: { row: FacetRow; ways: HikeWays; limit: number | null }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const source = useRowSource(ways.facet, row); + const step = sacStepOfClass(row.key); + const pressed = ways.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 ClassBar({ ways }: { ways: HikeWays }) { + return ( +