feat(tours): OpenHikingMap as a hiking base map of the tour planner (MAP-01)

While a hike is planned, the planner's map switch offers "Hiking" beside the
map, topo and satellite: OpenHikingMap, with marked trails over the terrain.
It is a choice of the planner's view alone, like topo: the global map
setting, the tour and its days stay as they are. With no tour open, over an
import viewed as it came in and for a paddle tour it is not offered, and a
choice made earlier falls back to the standard map there.

openmaps.fr's tile usage policy is followed: tiles load only as the map
shows them, with the browser's Referer, from the server's own caching; no
service worker rule keeps them and the 3D flyover's warm-up passes their
source by. Its credit and donation link are on the map; the 3D map, whose
attribution is folded away under the panel, shows them as a line of its own
above its controls.

The service is best effort. A run of failed tiles while it is shown marks
the switch, with a tooltip and a status for screen readers that say it is
not answering and that the tour is untouched; one tile arriving clears it.
The tile health the seamark overlay had is now useTileHealth, shared by both.

The source is free for non-commercial installs up to 500,000 tiles a month.
tours_hiking_map_enabled is an admin switch under Tours (admin tours-hiking-map,
audited), on by default, for an install that is commercial or larger to
switch off until it has an agreement with the operator. /api/addons carries
it as toursHikingMap, true only while Tours is on.

Leaflet, GL and the 3D map read one table of the planner's raster bases
(tourRasterBases), so topo and the hiking map are drawn the same way.
tile.openmaps.fr is on the CSP's connect-src for the GL maps.
This commit is contained in:
Maurice
2026-10-19 13:07:36 +02:00
parent d322363e53
commit 06e6e25b25
90 changed files with 839 additions and 117 deletions
+22 -8
View File
@@ -1,4 +1,4 @@
import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import { Anchor, 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,9 +9,10 @@ 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'
export type BaseLayer = 'default' | 'satellite'
export type TourBaseLayer = BaseLayer | 'topo'
export type TourBaseLayer = BaseLayer | TourRasterBase
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
export interface TourLayerOffer {
@@ -19,6 +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
}
const FROSTED_SHELL = {
@@ -83,6 +86,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
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: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
@@ -199,6 +203,15 @@ const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; down
* once its source stopped answering, which the tooltip names. The map and the
* planning go on without it.
*/
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
function DownBadge() {
return (
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
!
</span>
)
}
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
const { t } = useTranslation()
const look = OVERLAY_LOOK[overlay.id]
@@ -221,11 +234,7 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
>
<look.Icon size={15} strokeWidth={2} />
{!compact && <span>{label}</span>}
{down && (
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
!
</span>
)}
{down && <DownBadge />}
</button>
</Tooltip>
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
@@ -265,14 +274,17 @@ 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
return (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
{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
return (
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
<Tooltip key={id} label={down ? t('map.baseLayer.hikingDown') : label} placement="top" disabled={!compact && !down}>
<button
type="button"
onClick={() => onChange(id)}
@@ -283,10 +295,12 @@ export function TourMapLayerSwitcher({
>
<Icon size={15} strokeWidth={2} />
{!compact && <span>{label}</span>}
{down && <DownBadge />}
</button>
</Tooltip>
)
})}
{hikingDown && <span role="status" aria-live="polite" className="sr-only">{t('map.baseLayer.hikingDown')}</span>}
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}