From 3572f4b8a3c0786af8145a7650345e462ac572fa Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 12:49:53 +0200 Subject: [PATCH] feat(tours): show the known surfaces and way types along a hike (MAP-04) The hike's "SAC grades" card becomes "Trail details": one switch, then a segmented choice between SAC, Surface and Way type, three things kept apart and drawn along the route on the planner map one at a time. Every facet reads from the same answer: the ways under the line are matched on foot once, their OpenStreetMap values fetched once through the trail-grades read MAP-03 already uses, and each stretch is placed against the tour's own line once. Switching facets asks nothing new. The server now hands on a way's surface and highway tags as OpenStreetMap wrote them, only trimmed and bounded. The planner groups them (asphalt or concrete, paving stones, compacted, gravel, natural ground, grass, rock, wood or metal, unpaved without a kind, other; path, footway, steps, farm or forest track, cycle path, minor road, road, via ferrata, other), and each row's tooltip names the values recorded, such as surface=gravel, surface=pebblestone. The router's own surface reading is never used: it is normalised and a default where nothing is recorded. What is not known stays unknown and says why, in every facet: no value recorded, a value no group takes or two at once (unclear, never the nearest group), no way clearly under the tour's line, or a way that could not be asked about. Imported tracks and hand-drawn stretches keep their geometry; a matched way only counts where it keeps within 25 m of it. A summary badge gives the known share and the most common class; the SAC facet keeps its highest step and routing limit badges. Nothing about the tour, its places or its days changes, and no routing preference is set. --- .../Tours/bike/BikeWaysCard.test.tsx | 2 + .../Tours/bike/useRouteWays.test.tsx | 11 +- .../src/components/Tours/bike/useRouteWays.ts | 9 +- .../Tours/hike/HikeWaysCard.test.tsx | 193 +++++++++++++++ .../components/Tours/hike/HikeWaysCard.tsx | 78 ++++++ .../components/Tours/hike/HikeWaysList.tsx | 222 ++++++++++++++++++ .../Tours/hike/SacGradesCard.test.tsx | 135 ----------- .../components/Tours/hike/SacGradesCard.tsx | 218 ----------------- .../components/Tours/hike/hikeFacetSpecs.ts | 12 + .../components/Tours/hike/hikeFacets.test.ts | 131 +++++++++++ .../src/components/Tours/hike/hikeFacets.ts | 123 ++++++++++ client/src/components/Tours/hike/hikePaint.ts | 29 ++- .../components/Tours/hike/sacGrades.test.ts | 122 +++------- client/src/components/Tours/hike/sacGrades.ts | 122 ++-------- ...acGrades.test.tsx => useHikeWays.test.tsx} | 86 +++++-- .../src/components/Tours/hike/useHikeWays.ts | 124 ++++++++++ .../src/components/Tours/hike/useSacGrades.ts | 124 ---------- .../Tours/hike/wayTagFacets.test.ts | 98 ++++++++ .../src/components/Tours/hike/wayTagFacets.ts | 92 ++++++++ .../Tours/planner/TourPlannerPanels.test.tsx | 22 +- .../Tours/planner/TourPlannerPanels.tsx | 6 +- client/src/store/tourRouteHighlightStore.ts | 11 +- client/src/styles/hike.css | 60 ++++- .../nest/tours/tour-trail-grade.controller.ts | 8 +- .../nest/tours/tour-trail-grade.service.ts | 37 ++- server/tests/e2e/tours-bike.e2e.test.ts | 6 +- .../nest/tour-trail-grade.service.test.ts | 45 +++- shared/src/i18n/ar/tours.ts | 59 ++++- shared/src/i18n/az/tours.ts | 60 ++++- shared/src/i18n/br/tours.ts | 61 ++++- shared/src/i18n/ca/tours.ts | 63 ++++- shared/src/i18n/cs/tours.ts | 59 ++++- shared/src/i18n/de/tours.ts | 62 ++++- shared/src/i18n/en/tours.ts | 59 ++++- shared/src/i18n/es/tours.ts | 61 ++++- shared/src/i18n/et/tours.ts | 60 ++++- shared/src/i18n/fr/tours.ts | 62 ++++- shared/src/i18n/gr/tours.ts | 63 ++++- shared/src/i18n/hu/tours.ts | 62 ++++- shared/src/i18n/id/tours.ts | 61 ++++- shared/src/i18n/it/tours.ts | 63 ++++- shared/src/i18n/ja/tours.ts | 59 ++++- shared/src/i18n/ko/tours.ts | 59 ++++- shared/src/i18n/nl/tours.ts | 62 ++++- shared/src/i18n/pl/tours.ts | 61 ++++- shared/src/i18n/ru/tours.ts | 61 ++++- shared/src/i18n/sk/tours.ts | 60 ++++- shared/src/i18n/sv/tours.ts | 59 ++++- shared/src/i18n/th/tours.ts | 57 ++++- shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 59 ++++- shared/src/i18n/uk/tours.ts | 60 ++++- shared/src/i18n/vi/tours.ts | 59 ++++- shared/src/i18n/zh-TW/tours.ts | 56 ++++- shared/src/i18n/zh/tours.ts | 56 ++++- .../src/tours/tour-trail-grade.schema.spec.ts | 18 +- shared/src/tours/tour-trail-grade.schema.ts | 28 ++- 57 files changed, 2749 insertions(+), 1048 deletions(-) create mode 100644 client/src/components/Tours/hike/HikeWaysCard.test.tsx create mode 100644 client/src/components/Tours/hike/HikeWaysCard.tsx create mode 100644 client/src/components/Tours/hike/HikeWaysList.tsx delete mode 100644 client/src/components/Tours/hike/SacGradesCard.test.tsx delete mode 100644 client/src/components/Tours/hike/SacGradesCard.tsx create mode 100644 client/src/components/Tours/hike/hikeFacetSpecs.ts create mode 100644 client/src/components/Tours/hike/hikeFacets.test.ts create mode 100644 client/src/components/Tours/hike/hikeFacets.ts rename client/src/components/Tours/hike/{useSacGrades.test.tsx => useHikeWays.test.tsx} (59%) create mode 100644 client/src/components/Tours/hike/useHikeWays.ts delete mode 100644 client/src/components/Tours/hike/useSacGrades.ts create mode 100644 client/src/components/Tours/hike/wayTagFacets.test.ts create mode 100644 client/src/components/Tours/hike/wayTagFacets.ts 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 ( +