From 73237ffedb0c4eb47d4863790e59df6f31ac4df7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 19 Oct 2026 21:33:49 +0200 Subject: [PATCH] feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02) The tour type is a dropdown; a bike tour picks its profile, routes for it, says when a point lies off every way, and shows what it runs on: surfaces, kinds of way and, for a mountain bike, the trail grades, each pointed at on the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour) is offered while a tour of that type is open, with an admin switch each. From the planned cards: a hike avoids steep climbs, steps or ferries, nothing else; a saved tour can be shared where the platform takes GPX files; and the tours rail adds a tour from one menu: plan new, import GPX, or take one over from another trip. --- client/scripts/size-baseline.json | 2 +- client/src/api/hikingMap.ts | 9 - client/src/api/tourBaseMaps.ts | 16 + client/src/api/tourTrailGrade.ts | 20 + client/src/components/Admin/AddonManager.tsx | 22 +- .../Admin/addons/useAddonManager.test.ts | 26 +- .../Admin/addons/useAddonManager.ts | 33 +- .../components/Map/MapLayerSwitcher.test.tsx | 14 +- .../src/components/Map/MapLayerSwitcher.tsx | 24 +- client/src/components/Map/MapView.tsx | 6 +- client/src/components/Map/mapRasterOverlay.ts | 8 +- client/src/components/Map/poiCategories.ts | 5 +- .../components/Map/tourRasterBases.test.ts | 38 ++ client/src/components/Map/tourRasterBases.ts | 49 ++- .../src/components/Map/valhallaRoute.test.ts | 54 ++- client/src/components/Map/valhallaRoute.ts | 56 ++- client/src/components/Planner/planParts.tsx | 8 +- .../src/components/Tours/TourDayRowFacts.tsx | 2 +- .../components/Tours/TourGpxExportButton.tsx | 83 +++-- .../components/Tours/TourGpxPreviewDialog.tsx | 43 ++- .../components/Tours/ToursSidebar.test.tsx | 3 +- .../Tours/bike/BikeWaysCard.test.tsx | 152 ++++++++ .../components/Tours/bike/BikeWaysCard.tsx | 341 ++++++++++++++++++ client/src/components/Tours/bike/bikePaint.ts | 46 +++ .../components/Tours/bike/routeHighlight.ts | 27 ++ .../components/Tours/bike/routeWays.test.ts | 155 ++++++++ client/src/components/Tours/bike/routeWays.ts | 193 ++++++++++ .../Tours/bike/useHighlightedRoute.test.ts | 22 ++ .../Tours/bike/useHighlightedRoute.ts | 18 + .../Tours/bike/useRouteWays.test.tsx | 141 ++++++++ .../src/components/Tours/bike/useRouteWays.ts | 111 ++++++ .../Tours/conversion/useGpxConversion.ts | 16 +- .../Tours/library/TourLibraryDialog.tsx | 54 +-- .../Tours/library/tourLibrary.test.tsx | 12 +- .../Tours/planner/HikingPreferencesField.tsx | 5 +- .../Tours/planner/TourAddMenu.test.tsx | 96 +++++ .../components/Tours/planner/TourAddMenu.tsx | 111 ++++++ .../Tours/planner/TourEditorFields.tsx | 10 +- .../Tours/planner/TourPlannerPanels.tsx | 99 ++--- .../Tours/planner/TourTypeFields.test.tsx | 31 +- .../Tours/planner/TourTypeFields.tsx | 50 ++- .../Tours/planner/TourTypeSelect.tsx | 110 ++++++ .../Tours/planner/hikingPreferences.test.tsx | 34 +- .../Tours/planner/tourPlannerDraft.ts | 22 +- .../Tours/planner/tourPlannerFacts.ts | 96 +++-- .../Tours/planner/tourPlannerParts.test.ts | 23 ++ .../Tours/planner/tourPlannerWaypoints.ts | 7 + .../Tours/planner/tourRouteOutcome.ts | 20 + .../Tours/planner/tourRouting.test.ts | 64 +++- .../components/Tours/planner/tourRouting.ts | 103 ++++-- .../planner/useTourPlanner.bike.test.tsx | 193 ++++++++++ .../useTourPlanner.routeTools.test.tsx | 8 + .../Tours/planner/useTourPlanner.test.tsx | 4 +- .../Tours/planner/useTourPlanner.ts | 257 +++++-------- .../components/Tours/planner/useTourRoute.ts | 264 ++++++++++++++ .../Tours/terrain/TourTerrainMap.test.tsx | 11 + .../Tours/terrain/TourTerrainMap.tsx | 34 +- .../components/Tours/terrain/terrainLayers.ts | 6 +- client/src/components/Tours/tourFacts.ts | 5 +- client/src/components/Tours/tourMapLayers.ts | 9 +- client/src/components/Tours/tourParts.tsx | 14 +- .../Tours/useTourGpxExport.test.tsx | 89 ++++- .../src/components/Tours/useTourGpxExport.ts | 58 ++- .../Tours/useTourRasterOverlay.test.ts | 17 +- .../components/Tours/useTourRasterOverlay.ts | 21 +- client/src/components/shared/dialogParts.tsx | 109 ++---- .../components/shared/usePopoverList.test.tsx | 106 ++++++ .../src/components/shared/usePopoverList.ts | 98 +++++ client/src/constants/mapDefaults.ts | 11 + client/src/main.tsx | 1 + .../screens/admin/MAdminAddonManager.tsx | 22 +- .../tripPlanner/TourPlannerMapStage.test.tsx | 9 +- .../tripPlanner/useTourStageLayers.test.ts | 37 +- .../pages/tripPlanner/useTourStageLayers.ts | 27 +- .../pages/tripPlanner/useTripPlannerPage.ts | 9 +- client/src/repo/tourTrailGradeRepo.test.ts | 46 +++ client/src/repo/tourTrailGradeRepo.ts | 19 + client/src/store/addonStore.ts | 4 + client/src/store/tourRouteHighlightStore.ts | 26 ++ client/src/styles/bike.css | 49 +++ client/src/utils/fileShare.test.ts | 56 +++ client/src/utils/fileShare.ts | 37 ++ client/tests/helpers/polyline.ts | 23 ++ client/tests/helpers/storeRegistry.ts | 2 + client/tests/helpers/tourPlannerStub.ts | 4 + 85 files changed, 3728 insertions(+), 647 deletions(-) delete mode 100644 client/src/api/hikingMap.ts create mode 100644 client/src/api/tourBaseMaps.ts create mode 100644 client/src/api/tourTrailGrade.ts create mode 100644 client/src/components/Map/tourRasterBases.test.ts create mode 100644 client/src/components/Tours/bike/BikeWaysCard.test.tsx create mode 100644 client/src/components/Tours/bike/BikeWaysCard.tsx create mode 100644 client/src/components/Tours/bike/bikePaint.ts create mode 100644 client/src/components/Tours/bike/routeHighlight.ts create mode 100644 client/src/components/Tours/bike/routeWays.test.ts create mode 100644 client/src/components/Tours/bike/routeWays.ts create mode 100644 client/src/components/Tours/bike/useHighlightedRoute.test.ts create mode 100644 client/src/components/Tours/bike/useHighlightedRoute.ts create mode 100644 client/src/components/Tours/bike/useRouteWays.test.tsx create mode 100644 client/src/components/Tours/bike/useRouteWays.ts create mode 100644 client/src/components/Tours/planner/TourAddMenu.test.tsx create mode 100644 client/src/components/Tours/planner/TourAddMenu.tsx create mode 100644 client/src/components/Tours/planner/TourTypeSelect.tsx create mode 100644 client/src/components/Tours/planner/tourRouteOutcome.ts create mode 100644 client/src/components/Tours/planner/useTourPlanner.bike.test.tsx create mode 100644 client/src/components/Tours/planner/useTourRoute.ts create mode 100644 client/src/components/shared/usePopoverList.test.tsx create mode 100644 client/src/components/shared/usePopoverList.ts create mode 100644 client/src/repo/tourTrailGradeRepo.test.ts create mode 100644 client/src/repo/tourTrailGradeRepo.ts create mode 100644 client/src/store/tourRouteHighlightStore.ts create mode 100644 client/src/styles/bike.css create mode 100644 client/src/utils/fileShare.test.ts create mode 100644 client/src/utils/fileShare.ts create mode 100644 client/tests/helpers/polyline.ts diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index ff45f522e..98e76ebe2 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -5,7 +5,7 @@ "src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Budget/CostsPanel.test.tsx": 2764, "src/components/Budget/CostsPanel.tsx": 1816, - "src/components/Map/MapView.tsx": 1402, + "src/components/Map/MapView.tsx": 1401, "src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.tsx": 2296, "src/components/Map/RouteCalculator.ts": 1195, diff --git a/client/src/api/hikingMap.ts b/client/src/api/hikingMap.ts deleted file mode 100644 index 23febff09..000000000 --- a/client/src/api/hikingMap.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AdminFeatureToggleResponse } from '@trek/shared'; -import apiClient from './client'; - -/** MAP-01: the admin's switch for OpenHikingMap in the Tours planner. */ -export const hikingMapAdminApi = { - get: (): Promise => apiClient.get('/admin/tours-hiking-map').then((r) => r.data), - update: (enabled: boolean): Promise => - apiClient.put('/admin/tours-hiking-map', { enabled }).then((r) => r.data), -}; diff --git a/client/src/api/tourBaseMaps.ts b/client/src/api/tourBaseMaps.ts new file mode 100644 index 000000000..60a94cd3a --- /dev/null +++ b/client/src/api/tourBaseMaps.ts @@ -0,0 +1,16 @@ +import type { AdminFeatureToggleResponse } from '@trek/shared'; +import apiClient from './client'; + +/** An admin's switch for a map the Tours planner offers on its operator's terms. */ +function tourBaseMapAdminApi(path: string) { + return { + get: (): Promise => apiClient.get(path).then((r) => r.data), + update: (enabled: boolean): Promise => + apiClient.put(path, { enabled }).then((r) => r.data), + }; +} + +/** MAP-01: OpenHikingMap for a hike. */ +export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map'); +/** BIK-06: CyclOSM for a bike tour. */ +export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map'); diff --git a/client/src/api/tourTrailGrade.ts b/client/src/api/tourTrailGrade.ts new file mode 100644 index 000000000..fb1355a49 --- /dev/null +++ b/client/src/api/tourTrailGrade.ts @@ -0,0 +1,20 @@ +import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared'; +import apiClient from './client'; + +/** + * BIK-05: the trail grades of the ways a mountain bike tour runs on. The + * server asks Overpass in parts, one after another, so a long tour may take + * longer than the shared default. + */ +const TRAIL_GRADE_TIMEOUT_MS = 60_000; + +export const tourTrailGradeApi = { + grades: ( + tripId: number | string, + body: TourTrailGradeRequest, + signal?: AbortSignal + ): Promise => + apiClient + .post(`/trips/${tripId}/tours/trail-grades`, body, { signal, timeout: TRAIL_GRADE_TIMEOUT_MS }) + .then((r) => r.data), +}; diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index de9d29982..94dfda115 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,7 +1,7 @@ import { useId, type ComponentType } from 'react' import { useTranslation } from '../../i18n' import { useIsDark } from '../../hooks/useIsDark' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront, Footprints } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' import EmptyState from '../shared/EmptyState' @@ -77,8 +77,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, toursWanderer, toggleToursWanderer, - toursHikingMap, - toggleToursHikingMap, + mapSwitches, } = useAddonManager() if (loading) { @@ -165,13 +164,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, enabled={toursWanderer} onToggle={() => void toggleToursWanderer()} /> - } - title={t('admin.toursHikingMap.title')} - description={t('admin.toursHikingMap.subtitle')} - enabled={toursHikingMap} - onToggle={() => void toggleToursHikingMap()} - /> + {mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => ( + } + title={t(titleKey)} + description={t(subtitleKey)} + enabled={enabled} + onToggle={() => void toggle()} + /> + ))} ) } diff --git a/client/src/components/Admin/addons/useAddonManager.test.ts b/client/src/components/Admin/addons/useAddonManager.test.ts index 5c1b4e3ba..0ce0d4a5c 100644 --- a/client/src/components/Admin/addons/useAddonManager.test.ts +++ b/client/src/components/Admin/addons/useAddonManager.test.ts @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { adminApi } from '../../../api/client'; -import { hikingMapAdminApi } from '../../../api/hikingMap'; +import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps'; import { wandererAdminApi } from '../../../api/wanderer'; import { useAddonStore } from '../../../store/addonStore'; import type { Addon } from './addonModel'; @@ -39,6 +39,7 @@ beforeEach(() => { vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({}); vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false }); vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true }); + vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true }); }); afterEach(() => vi.restoreAllMocks()); @@ -50,6 +51,13 @@ async function loaded() { const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled; +/** One of the map switches under Tours, by its key. */ +function mapSwitch(manager: ReturnType, key: 'hiking' | 'cycle') { + const found = manager.mapSwitches.find((entry) => entry.key === key); + if (!found) throw new Error(`no ${key} map switch`); + return found; +} + describe('useAddonManager', () => { it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => { const { result, rerender } = await loaded(); @@ -123,14 +131,24 @@ describe('useAddonManager', () => { it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => { const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false }); const { result } = await loaded(); - await waitFor(() => expect(result.current.toursHikingMap).toBe(true)); - await act(() => result.current.toggleToursHikingMap()); + await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true)); + await act(() => mapSwitch(result.current, 'hiking').toggle()); expect(update).toHaveBeenCalledWith(false); - expect(result.current.toursHikingMap).toBe(false); + expect(mapSwitch(result.current, 'hiking').enabled).toBe(false); expect(loadAddons).toHaveBeenCalled(); expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated'); }); + it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => { + const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false }); + const { result } = await loaded(); + await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true)); + await act(() => mapSwitch(result.current, 'cycle').toggle()); + expect(update).toHaveBeenCalledWith(false); + expect(mapSwitch(result.current, 'cycle').enabled).toBe(false); + expect(mapSwitch(result.current, 'hiking').enabled).toBe(true); + }); + it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => { vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500')); const { result } = await loaded(); diff --git a/client/src/components/Admin/addons/useAddonManager.ts b/client/src/components/Admin/addons/useAddonManager.ts index d1639e0eb..c76405ce2 100644 --- a/client/src/components/Admin/addons/useAddonManager.ts +++ b/client/src/components/Admin/addons/useAddonManager.ts @@ -1,7 +1,8 @@ +import { Bike, Footprints } from 'lucide-react'; import { useEffect, useEffectEvent, useState } from 'react'; import { adminApi } from '../../../api/client'; -import { hikingMapAdminApi } from '../../../api/hikingMap'; +import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps'; import { wandererAdminApi } from '../../../api/wanderer'; import { useTranslation } from '../../../i18n'; import { useAddonStore } from '../../../store/addonStore'; @@ -22,6 +23,7 @@ export function useAddonManager() { const [loading, setLoading] = useState(true); const [toursWanderer, setToursWanderer] = useState(false); const [toursHikingMap, setToursHikingMap] = useState(false); + const [toursCycleMap, setToursCycleMap] = useState(false); const loadAddons = async () => { try { @@ -42,6 +44,10 @@ export function useAddonManager() { .get() .then((flag) => setToursHikingMap(flag.enabled)) .catch(() => toast.error(t('admin.addons.toast.error'))); + cycleMapAdminApi + .get() + .then((flag) => setToursCycleMap(flag.enabled)) + .catch(() => toast.error(t('admin.addons.toast.error'))); }); useEffect(() => { loadOnMount(); @@ -69,7 +75,7 @@ export function useAddonManager() { toast.success(t('admin.addons.toast.updated')); }; - /** A switch under Tours (wanderer, OpenHikingMap): flipped at once, put back when the save fails. */ + /** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */ const flipToursSwitch = async ( current: boolean, set: (enabled: boolean) => void, @@ -88,7 +94,25 @@ export function useAddonManager() { toast.success(t('admin.addons.toast.updated')); }; const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update); - const toggleToursHikingMap = () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update); + /** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */ + const mapSwitches = [ + { + key: 'hiking', + Icon: Footprints, + titleKey: 'admin.toursHikingMap.title', + subtitleKey: 'admin.toursHikingMap.subtitle', + enabled: toursHikingMap, + toggle: () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update), + }, + { + key: 'cycle', + Icon: Bike, + titleKey: 'admin.toursCycleMap.title', + subtitleKey: 'admin.toursCycleMap.subtitle', + enabled: toursCycleMap, + toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update), + }, + ] as const; const asOption = (provider: Addon): ProviderOption => ({ key: provider.id, @@ -118,7 +142,6 @@ export function useAddonManager() { documentProviderOptions, toursWanderer, toggleToursWanderer, - toursHikingMap, - toggleToursHikingMap, + mapSwitches, }; } diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index c67690ec9..a100ce5ff 100644 --- a/client/src/components/Map/MapLayerSwitcher.test.tsx +++ b/client/src/components/Map/MapLayerSwitcher.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-019 +// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020 import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '../../../tests/helpers/render' @@ -70,8 +70,8 @@ describe('MapLayerSwitcher', () => { }) it('FE-COMP-MAPLAYER-019: a hiking map that stopped answering says so on its segment, and only while it is shown', () => { - const hikingMap = { down: true, onTileError: vi.fn(), onTileLoad: vi.fn() } - const layers = { bases: ['default', 'hiking'] as const, hikingMap } + const baseHealth = { base: 'hiking' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() } + const layers = { bases: ['default', 'hiking'] as const, baseHealth } const { rerender } = render( {}} layers={layers} />) const segment = screen.getByRole('button', { name: 'Hiking' }) expect(segment).toHaveAttribute('aria-pressed', 'true') @@ -83,6 +83,14 @@ describe('MapLayerSwitcher', () => { expect(screen.queryByRole('status')).toBeNull() }) + it('FE-COMP-MAPLAYER-020: CyclOSM sits in the switch as Cycling and says so when it stops answering (BIK-06)', () => { + const baseHealth = { base: 'cycle' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() } + render( {}} layers={{ bases: ['default', 'cycle'], baseHealth }} />) + expect(screen.getByRole('button', { name: 'Cycling' })).toHaveTextContent('!') + expect(screen.getByRole('status')).toHaveTextContent('CyclOSM is not answering right now') + expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull() + }) + it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => { render( {}} />) const button = screen.getByRole('button') diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 6bbd4d040..b162f8c48 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,4 +1,4 @@ -import { Anchor, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react' +import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react' import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer' import type { CSSProperties } from 'react' import { useTranslation } from '../../i18n' @@ -9,7 +9,7 @@ import { fs } from '../shared/DialogShell' import { Tooltip } from '../shared/Tooltip' import { MAP_CONTROL_SHADOW } from './mapControlShadow' import type { ToggleableRasterOverlay } from './mapRasterOverlay' -import type { TileHealth, TourRasterBase } from './tourRasterBases' +import type { BaseMapHealth, TourRasterBase } from './tourRasterBases' export type BaseLayer = 'default' | 'satellite' export type TourBaseLayer = BaseLayer | TourRasterBase @@ -20,8 +20,8 @@ export interface TourLayerOffer { overlays?: readonly ToggleableRasterOverlay[] /** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */ paddle?: PaddleMapLayer - /** MAP-01: whether OpenHikingMap answers while it is the base map; the maps report into it. */ - hikingMap?: TileHealth + /** MAP-01, BIK-06: whether the map on terms shown (OpenHikingMap, CyclOSM) answers; the maps report into it. */ + baseHealth?: BaseMapHealth } const FROSTED_SHELL = { @@ -87,6 +87,7 @@ const TOUR_LAYERS = [ { id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon }, { id: 'topo', labelKey: null, Icon: Mountain }, { id: 'hiking', labelKey: 'map.baseLayer.hiking', Icon: Footprints }, + { id: 'cycle', labelKey: 'map.baseLayer.cycle', Icon: Bike }, { id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite }, ] as const @@ -274,17 +275,18 @@ export function TourMapLayerSwitcher({ const { t } = useTranslation() const bases = layers?.bases const overlays = layers?.overlays - // OpenHikingMap is a best-effort service: while it is shown and stops answering, its segment says so. - const hikingDown = active === 'hiking' && !!layers?.hikingMap?.down + // A map on terms is a best-effort service: while it is shown and stops answering, its segment says so. + const health = layers?.baseHealth return (
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => { const selected = active === id const label = labelKey ? t(labelKey) : 'Topo' - const down = id === 'hiking' && hikingDown + const down = !!health?.down && health.base === id && active === id + const downLabel = id === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown') 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 ( +