From 06e6e25b25c5be0e06eaac7ce05b0263d3d62846 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 19 Oct 2026 13:07:36 +0200 Subject: [PATCH] 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. --- client/src/api/hikingMap.ts | 9 +++ client/src/components/Admin/AddonManager.tsx | 27 ++++++--- .../Admin/addons/useAddonManager.test.ts | 25 ++++++++- .../Admin/addons/useAddonManager.ts | 26 +++++++-- .../components/Map/MapLayerSwitcher.test.tsx | 26 ++++++++- .../src/components/Map/MapLayerSwitcher.tsx | 30 +++++++--- client/src/components/Map/MapView.test.tsx | 7 ++- client/src/components/Map/MapView.tsx | 22 ++++---- client/src/components/Map/MapViewGL.tsx | 11 ++-- .../src/components/Map/glRasterLayers.test.ts | 55 +++++++++++++++++++ client/src/components/Map/glRasterLayers.ts | 38 +++++++------ .../components/Map/mapRasterOverlay.test.ts | 31 ++++++++++- client/src/components/Map/mapRasterOverlay.ts | 44 +++++++++++++-- client/src/components/Map/tourRasterBases.ts | 53 ++++++++++++++++++ .../Tours/terrain/TourTerrainMap.tsx | 29 ++++++++++ .../Tours/terrain/terrainLayers.test.ts | 16 ++++++ .../components/Tours/terrain/terrainLayers.ts | 31 +++++++---- .../components/Tours/tourMapLayers.test.ts | 17 ++++++ client/src/components/Tours/tourMapLayers.ts | 16 ++++-- .../Tours/useTourRasterOverlay.test.ts | 54 ++++++++++++++++++ .../components/Tours/useTourRasterOverlay.ts | 42 ++++++++------ client/src/constants/mapDefaults.ts | 12 ++++ .../screens/admin/MAdminAddonManager.tsx | 27 ++++++--- .../tripPlanner/useTourStageLayers.test.ts | 44 +++++++++++++++ .../pages/tripPlanner/useTourStageLayers.ts | 35 +++++++++--- client/src/store/addonStore.ts | 4 ++ client/tests/helpers/msw/handlers/admin.ts | 9 +++ client/tests/unit/stores/addonStore.test.ts | 4 +- server/src/middleware/globalMiddleware.ts | 3 + server/src/nest/addons/addons.service.ts | 14 +++++ server/src/nest/admin/admin.controller.ts | 19 +++++++ .../src/nest/common/app-settings.registry.ts | 4 ++ server/tests/e2e/addons.e2e.test.ts | 1 + .../middleware/globalMiddleware.csp.test.ts | 4 ++ server/tests/unit/nest/addons.service.test.ts | 14 +++++ .../tests/unit/nest/admin.controller.test.ts | 4 ++ shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/ar/map.ts | 2 + shared/src/i18n/az/admin.ts | 3 + shared/src/i18n/az/map.ts | 2 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/br/map.ts | 3 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/ca/map.ts | 3 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/cs/map.ts | 2 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/de/map.ts | 3 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/en/map.ts | 3 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/es/map.ts | 2 + shared/src/i18n/et/admin.ts | 3 + shared/src/i18n/et/map.ts | 2 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/fr/map.ts | 3 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/gr/map.ts | 3 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/hu/map.ts | 2 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/id/map.ts | 2 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/it/map.ts | 3 + shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ja/map.ts | 3 + shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/ko/map.ts | 3 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/nl/map.ts | 3 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/pl/map.ts | 3 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/ru/map.ts | 3 + shared/src/i18n/sk/admin.ts | 3 + shared/src/i18n/sk/map.ts | 2 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/sv/map.ts | 2 + shared/src/i18n/th/admin.ts | 3 + shared/src/i18n/th/map.ts | 2 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/tr/map.ts | 2 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/uk/map.ts | 3 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/vi/map.ts | 3 + shared/src/i18n/zh-TW/admin.ts | 3 + shared/src/i18n/zh-TW/map.ts | 2 + shared/src/i18n/zh/admin.ts | 3 + shared/src/i18n/zh/map.ts | 2 + 90 files changed, 839 insertions(+), 117 deletions(-) create mode 100644 client/src/api/hikingMap.ts create mode 100644 client/src/components/Map/glRasterLayers.test.ts create mode 100644 client/src/components/Map/tourRasterBases.ts create mode 100644 client/src/components/Tours/tourMapLayers.test.ts create mode 100644 client/src/components/Tours/useTourRasterOverlay.test.ts create mode 100644 client/src/pages/tripPlanner/useTourStageLayers.test.ts diff --git a/client/src/api/hikingMap.ts b/client/src/api/hikingMap.ts new file mode 100644 index 000000000..23febff09 --- /dev/null +++ b/client/src/api/hikingMap.ts @@ -0,0 +1,9 @@ +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/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index d897642dd..de9d29982 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 } 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, Footprints } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' import EmptyState from '../shared/EmptyState' @@ -77,6 +77,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, toursWanderer, toggleToursWanderer, + toursHikingMap, + toggleToursHikingMap, } = useAddonManager() if (loading) { @@ -155,13 +157,22 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, } if (addon.id === 'tours' && addon.enabled) { return ( - } - title={t('admin.toursWanderer.title')} - description={t('admin.toursWanderer.subtitle')} - enabled={toursWanderer} - onToggle={() => void toggleToursWanderer()} - /> + <> + } + title={t('admin.toursWanderer.title')} + description={t('admin.toursWanderer.subtitle')} + enabled={toursWanderer} + onToggle={() => void toggleToursWanderer()} + /> + } + title={t('admin.toursHikingMap.title')} + description={t('admin.toursHikingMap.subtitle')} + enabled={toursHikingMap} + onToggle={() => void toggleToursHikingMap()} + /> + ) } // The AI-parsing settings hang off their tile like every other sub-feature, diff --git a/client/src/components/Admin/addons/useAddonManager.test.ts b/client/src/components/Admin/addons/useAddonManager.test.ts index 501fc48d7..5c1b4e3ba 100644 --- a/client/src/components/Admin/addons/useAddonManager.test.ts +++ b/client/src/components/Admin/addons/useAddonManager.test.ts @@ -2,11 +2,13 @@ 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 { wandererAdminApi } from '../../../api/wanderer'; import { useAddonStore } from '../../../store/addonStore'; import type { Addon } from './addonModel'; import { useAddonManager } from './useAddonManager'; -// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-006 +// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008 const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); @@ -35,6 +37,8 @@ beforeEach(() => { useAddonStore.setState({ loadAddons }); vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS }); vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({}); + vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false }); + vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true }); }); afterEach(() => vi.restoreAllMocks()); @@ -115,4 +119,23 @@ describe('useAddonManager', () => { await act(() => result.current.documentProviderOptions[0].toggle()); expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false }); }); + + 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()); + expect(update).toHaveBeenCalledWith(false); + expect(result.current.toursHikingMap).toBe(false); + expect(loadAddons).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated'); + }); + + 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(); + await act(() => result.current.toggleToursWanderer()); + expect(result.current.toursWanderer).toBe(false); + expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error'); + }); }); diff --git a/client/src/components/Admin/addons/useAddonManager.ts b/client/src/components/Admin/addons/useAddonManager.ts index d7fd05c63..d1639e0eb 100644 --- a/client/src/components/Admin/addons/useAddonManager.ts +++ b/client/src/components/Admin/addons/useAddonManager.ts @@ -1,6 +1,7 @@ import { useEffect, useEffectEvent, useState } from 'react'; import { adminApi } from '../../../api/client'; +import { hikingMapAdminApi } from '../../../api/hikingMap'; import { wandererAdminApi } from '../../../api/wanderer'; import { useTranslation } from '../../../i18n'; import { useAddonStore } from '../../../store/addonStore'; @@ -20,6 +21,7 @@ export function useAddonManager() { const [addons, setAddons] = useState([]); const [loading, setLoading] = useState(true); const [toursWanderer, setToursWanderer] = useState(false); + const [toursHikingMap, setToursHikingMap] = useState(false); const loadAddons = async () => { try { @@ -36,6 +38,10 @@ export function useAddonManager() { .get() .then((flag) => setToursWanderer(flag.enabled)) .catch(() => toast.error(t('admin.addons.toast.error'))); + hikingMapAdminApi + .get() + .then((flag) => setToursHikingMap(flag.enabled)) + .catch(() => toast.error(t('admin.addons.toast.error'))); }); useEffect(() => { loadOnMount(); @@ -63,20 +69,26 @@ export function useAddonManager() { toast.success(t('admin.addons.toast.updated')); }; - /** wanderer under Tours: flipped at once, put back when the save fails. */ - const toggleToursWanderer = async () => { - const next = !toursWanderer; - setToursWanderer(next); + /** A switch under Tours (wanderer, OpenHikingMap): flipped at once, put back when the save fails. */ + const flipToursSwitch = async ( + current: boolean, + set: (enabled: boolean) => void, + save: (enabled: boolean) => Promise + ) => { + const next = !current; + set(next); try { - await wandererAdminApi.update(next); + await save(next); } catch { - setToursWanderer(!next); + set(!next); toast.error(t('admin.addons.toast.error')); return; } void refreshGlobalAddons(); toast.success(t('admin.addons.toast.updated')); }; + const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update); + const toggleToursHikingMap = () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update); const asOption = (provider: Addon): ProviderOption => ({ key: provider.id, @@ -106,5 +118,7 @@ export function useAddonManager() { documentProviderOptions, toursWanderer, toggleToursWanderer, + toursHikingMap, + toggleToursHikingMap, }; } diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index 81633ba56..c67690ec9 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-017 +// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-019 import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '../../../tests/helpers/render' @@ -59,6 +59,30 @@ describe('MapLayerSwitcher', () => { expect(onChange).toHaveBeenCalledWith('topo') }) + it('FE-COMP-MAPLAYER-018: offers the hiking map only where the tour type and the admin offer it (MAP-01)', () => { + const onChange = vi.fn() + const { rerender } = render() + expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull() + + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Hiking' })) + expect(onChange).toHaveBeenCalledWith('hiking') + }) + + 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 { rerender } = render( {}} layers={layers} />) + const segment = screen.getByRole('button', { name: 'Hiking' }) + expect(segment).toHaveAttribute('aria-pressed', 'true') + expect(segment).toHaveTextContent('!') + expect(screen.getByRole('status')).toHaveTextContent('OpenHikingMap is not answering right now') + + rerender( {}} layers={layers} />) + expect(screen.getByRole('button', { name: 'Hiking' })).not.toHaveTextContent('!') + expect(screen.queryByRole('status')).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 e332bf0f4..6bbd4d040 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -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 + ! + + ) +} + 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; > {!compact && {label}} - {down && ( - - ! - - )} + {down && } {down ? t(look.downKey) : ''} @@ -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 (
{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 ( - + ) })} + {hikingDown && {t('map.baseLayer.hikingDown')}} {(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && } {placeTool && } {ferrataLayer && } diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 5dcddc861..a0112db1c 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -252,7 +252,7 @@ describe('MapView', () => { const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][] const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }] const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 } - const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({ + const props = (viewBaseLayer: 'default' | 'topo' | 'hiking' | 'satellite') => ({ places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15, route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange, }) @@ -279,6 +279,11 @@ describe('MapView', () => { expect(screen.getAllByTestId('polyline').every(polyline => polyline.getAttribute('data-points') === JSON.stringify(route[0]))).toBe(true) expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng])) + // MAP-01: OpenHikingMap is drawn in WGS-84 like topo, from its own template. + rerender() + expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'EPSG:3857') + expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', 'https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png') + rerender() const satelliteMap = screen.getByTestId('map-container') expect(satelliteMap).not.toBe(topoMap) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 16588f6f8..89a803824 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -63,7 +63,8 @@ import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './p import { resolveTrackColor, hasManualTrackColor } from './trackColors' import { SelectedTrackLine, TrackLines } from './TrackLines' import DawarichTrailLayer from './DawarichTrailLayer' -import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults' +import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults' +import { tourRasterBaseOf } from './tourRasterBases' import { crsForBasemap } from './gcj02Crs' import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl' import VectorBasemap from './VectorBasemap' @@ -839,7 +840,7 @@ export const MapView = memo(function MapView({ const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function' const isSatellite = baseLayer === 'satellite' - const isTopo = baseLayer === 'topo' + const tourRaster = tourRasterBaseOf(baseLayer) // topo or OpenHikingMap, the Tours planner's own // The caller hands over whatever the user configured; what kind of basemap // that is decides which layer draws it. A saved raster template still wins, // the default is a vector style. @@ -851,7 +852,7 @@ export const MapView = memo(function MapView({ * decided before the map is constructed, because Leaflet cannot change a map's * CRS afterwards — hence the whole map, not just the tile layer. */ - const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url) + const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url) const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap]) const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => ( - ) : isTopo ? ( + ) : tourRaster ? ( ) : basemap.kind === 'vector' ? ( diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index e782240f7..062cbe634 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -8,7 +8,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup' import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n/TranslationContext' -import { applySatellite } from './glRasterLayers' +import { applyBaseLayer } from './glRasterLayers' import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher' import { MapLockPill } from './MapLockPill' @@ -612,7 +612,7 @@ export function MapViewGL({ const mapRef = useRef(null) const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl]) useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory) - useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle) + useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers) // Beneath the planned route's casing, the GL twin of the Leaflet pane order. useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -2185,14 +2185,11 @@ export function MapViewGL({ useEffect(() => { const map = mapRef.current if (!map || !mapReady) return - const apply = () => { - applySatellite(map, isSatellite) - applySatellite(map, baseLayer === 'topo', true) - } + const apply = () => applyBaseLayer(map, baseLayer) apply() map.on('styledata', apply) return () => { map.off('styledata', apply) } - }, [isSatellite, baseLayer, mapReady, glProvider]) + }, [baseLayer, mapReady, glProvider]) if (!isMapLibre && !mapboxToken) { return ( diff --git a/client/src/components/Map/glRasterLayers.test.ts b/client/src/components/Map/glRasterLayers.test.ts new file mode 100644 index 000000000..434ee261e --- /dev/null +++ b/client/src/components/Map/glRasterLayers.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers'; + +/** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */ +function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) { + const sources = new Map>(); + const layers = new Map(); + const map = { + getSource: vi.fn((id: string) => sources.get(id)), + addSource: vi.fn((id: string, source: Record) => sources.set(id, source)), + getLayer: vi.fn((id: string) => (layers.has(id) ? { id } : undefined)), + addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, 'visible')), + setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, value)), + getStyle: vi.fn(() => ({ layers: styleLayers })), + }; + return { map, sources, layers }; +} + +describe('applyBaseLayer (MAP-01)', () => { + it('lays OpenHikingMap with its credit under the first thing TREK draws', () => { + const { map, sources } = fakeMap(); + applyBaseLayer(map, 'hiking'); + expect(tourRasterSourceId('hiking')).toBe('trip-hiking'); + expect(sources.get('trip-hiking')).toMatchObject({ + type: 'raster', + tiles: ['https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png'], + maxzoom: 18, + }); + expect(String(sources.get('trip-hiking')?.attribution)).toContain('openmaps.fr/donate'); + expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-hiking-raster' }), 'trip-route'); + // Nothing else is built while it is off. + expect(sources.has('trip-topo')).toBe(false); + expect(sources.has('trip-satellite')).toBe(false); + }); + + it('switching away hides it, and back shows the same layer again without a second source', () => { + const { map, layers } = fakeMap(); + applyBaseLayer(map, 'hiking'); + applyBaseLayer(map, 'topo'); + expect(layers.get('trip-hiking-raster')).toBe('none'); + expect(layers.get('trip-topo-raster')).toBe('visible'); + applyBaseLayer(map, 'hiking'); + expect(layers.get('trip-hiking-raster')).toBe('visible'); + expect(layers.get('trip-topo-raster')).toBe('none'); + expect(map.addSource).toHaveBeenCalledTimes(2); + }); + + it("keeps the user's own map when the style refuses the layer", () => { + const { map } = fakeMap(); + map.addLayer.mockImplementation(() => { + throw new Error('style not loaded'); + }); + expect(() => applyBaseLayer(map, 'hiking')).not.toThrow(); + }); +}); diff --git a/client/src/components/Map/glRasterLayers.ts b/client/src/components/Map/glRasterLayers.ts index 128eedbcf..57e288875 100644 --- a/client/src/components/Map/glRasterLayers.ts +++ b/client/src/components/Map/glRasterLayers.ts @@ -1,15 +1,9 @@ -import { - OPENTOPOMAP_TILE_ATTRIBUTION, - OPENTOPOMAP_TILE_MAXZOOM, - OPENTOPOMAP_TILE_URL, - SATELLITE_TILE_ATTRIBUTION, - SATELLITE_TILE_MAXZOOM, - SATELLITE_TILE_URL, -} from '../../constants/mapDefaults'; +import { SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, SATELLITE_TILE_URL } from '../../constants/mapDefaults'; +import { TOUR_RASTER_BASE_IDS, TOUR_RASTER_BASES, type TourRasterBase } from './tourRasterBases'; /* * The raster layers a GL map draws between its basemap style and what TREK - * adds on top: the satellite and topo base layers, and (in mapRasterOverlay.ts) + * adds on top: the satellite, topo and hiking base layers, and (in mapRasterOverlay.ts) * the transparent overlays such as the seamarks. */ @@ -50,9 +44,10 @@ export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']; * yet. The catch covers that case, and `styledata` brings the pass back once it is in. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function applySatellite(map: any, on: boolean, topo = false): void { - const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID; - const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID; +export function applySatellite(map: any, on: boolean, raster?: TourRasterBase): void { + const layerId = raster ? `trip-${raster}-raster` : SATELLITE_LAYER_ID; + const sourceId = raster ? tourRasterSourceId(raster) : SATELLITE_SOURCE_ID; + const source = raster ? TOUR_RASTER_BASES[raster] : null; try { if (map.getLayer(layerId)) { map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none'); @@ -65,12 +60,10 @@ export function applySatellite(map: any, on: boolean, topo = false): void { if (!map.getSource(sourceId)) { map.addSource(sourceId, { type: 'raster', - tiles: topo - ? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain)) - : [SATELLITE_TILE_URL], + tiles: source ? source.tiles : [SATELLITE_TILE_URL], tileSize: 256, - maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM, - attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION, + maxzoom: source ? source.maxZoom : SATELLITE_TILE_MAXZOOM, + attribution: source ? source.attribution : SATELLITE_TILE_ATTRIBUTION, }); } // Under the first thing TREK draws, over everything the basemap style draws. @@ -90,3 +83,14 @@ export function applySatellite(map: any, on: boolean, topo = false): void { /* a style that refuses the layer keeps the plain basemap */ } } + +/** The GL source a Tours raster base map is drawn from, which its tile events name. */ +export function tourRasterSourceId(raster: TourRasterBase): string { + return `trip-${raster}`; +} + +/** The base layer on a GL map: the imagery, a Tours raster map, or neither over the user's own style. */ +export function applyBaseLayer(map: Parameters[0], baseLayer: string): void { + applySatellite(map, baseLayer === 'satellite'); + for (const raster of TOUR_RASTER_BASE_IDS) applySatellite(map, baseLayer === raster, raster); +} diff --git a/client/src/components/Map/mapRasterOverlay.test.ts b/client/src/components/Map/mapRasterOverlay.test.ts index a7948c333..5f7059abd 100644 --- a/client/src/components/Map/mapRasterOverlay.test.ts +++ b/client/src/components/Map/mapRasterOverlay.test.ts @@ -1,6 +1,11 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { useRasterOverlaysGL, type MapRasterOverlay, type RasterOverlayGlMap } from './mapRasterOverlay'; +import { + useRasterOverlaysGL, + useSourceTileHealthGL, + type MapRasterOverlay, + type RasterOverlayGlMap, +} from './mapRasterOverlay'; type Handler = (event: Record) => void; @@ -103,3 +108,27 @@ describe('useRasterOverlaysGL', () => { expect(map.on).not.toHaveBeenCalled(); }); }); + +describe('useSourceTileHealthGL (MAP-01)', () => { + it("reports one source's failed and arrived tiles into its health, nothing else's", () => { + const { gl, fire } = fakeMap(); + const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() }; + renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health)); + fire('error', { sourceId: 'trip-hiking', error: new Error('503') }); + fire('error', { sourceId: 'trip-topo', error: new Error('503') }); + fire('sourcedata', { sourceId: 'trip-hiking', tile: {} }); + fire('sourcedata', { sourceId: 'trip-hiking' }); + expect(health.onTileError).toHaveBeenCalledTimes(1); + expect(health.onTileLoad).toHaveBeenCalledTimes(1); + }); + + it('listens to nothing while the map is not shown, and lets go when it goes', () => { + const { map, gl } = fakeMap(); + const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() }; + renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', undefined)); + expect(map.on).not.toHaveBeenCalled(); + const { unmount } = renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health)); + unmount(); + expect(map.off).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Map/mapRasterOverlay.ts b/client/src/components/Map/mapRasterOverlay.ts index 3875a0b96..c00faaf32 100644 --- a/client/src/components/Map/mapRasterOverlay.ts +++ b/client/src/components/Map/mapRasterOverlay.ts @@ -4,7 +4,8 @@ import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerG import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'; import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'; import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL'; -import { OWN_LAYER_PREFIXES } from './glRasterLayers'; +import { OWN_LAYER_PREFIXES, tourRasterSourceId } from './glRasterLayers'; +import type { TileHealth } from './tourRasterBases'; /** * A transparent raster layer laid over the basemap and under the route, such @@ -104,16 +105,47 @@ export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: re }, [map, overlays]); } -/** The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */ +/** + * Reports the tiles of one GL source into its health while `health` is given: + * a failure carries the source it came from, an arrival a tile. The base layer + * itself is drawn by applyBaseLayer. + */ +export function useSourceTileHealthGL( + map: RasterOverlayGlMap | null, + sourceId: string, + health: TileHealth | undefined +) { + const { onTileError, onTileLoad } = health ?? {}; + useEffect(() => { + if (!map || !onTileError || !onTileLoad) return; + const failed = (event: { error?: Error; sourceId?: string }) => { + if (event.sourceId === sourceId) onTileError(); + }; + const loaded = (event: { sourceId?: string; tile?: unknown }) => { + if (event.sourceId === sourceId && event.tile) onTileLoad(); + }; + map.on('error', failed); + map.on('sourcedata', loaded); + return () => { + map.off('error', failed); + map.off('sourcedata', loaded); + }; + }, [map, sourceId, onTileError, onTileLoad]); +} + +/** + * The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers, + * the paddle obstacles, and whether OpenHikingMap answers while it is the base map. + */ export function useTourMapLayersGL( map: RasterOverlayGlMap | null, ferrataLayer: FerrataMapLayer | undefined, - overlays: readonly MapRasterOverlay[] | undefined, - paddle?: PaddleMapLayer + layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; hikingMap?: TileHealth } | undefined ) { useFerrataMapLayerGL(map, ferrataLayer); - useRasterOverlaysGL(map, overlays); - usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle); + useRasterOverlaysGL(map, layers?.overlays); + usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle); + useSourceTileHealthGL(map, tourRasterSourceId('hiking'), layers?.hikingMap); } /** diff --git a/client/src/components/Map/tourRasterBases.ts b/client/src/components/Map/tourRasterBases.ts new file mode 100644 index 000000000..87e3d9066 --- /dev/null +++ b/client/src/components/Map/tourRasterBases.ts @@ -0,0 +1,53 @@ +import { + OPENHIKINGMAP_TILE_ATTRIBUTION, + OPENHIKINGMAP_TILE_MAXZOOM, + OPENHIKINGMAP_TILE_URL, + OPENTOPOMAP_TILE_ATTRIBUTION, + OPENTOPOMAP_TILE_MAXZOOM, + OPENTOPOMAP_TILE_URL, +} from '../../constants/mapDefaults'; + +/** The raster base maps only the Tours planner offers, drawn the same way by Leaflet, GL and the 3D map. */ +export type TourRasterBase = 'topo' | 'hiking'; + +export interface TourRasterBaseSource { + /** The Leaflet template; `{s}` is a shard Leaflet fills in. */ + url: string; + /** The same tiles spelled out for a GL source, which knows no shards. */ + tiles: string[]; + attribution: string; + maxZoom: number; +} + +export const TOUR_RASTER_BASES: Record = { + topo: { + url: OPENTOPOMAP_TILE_URL, + tiles: ['a', 'b', 'c'].map((shard) => OPENTOPOMAP_TILE_URL.replace('{s}', shard)), + attribution: OPENTOPOMAP_TILE_ATTRIBUTION, + maxZoom: OPENTOPOMAP_TILE_MAXZOOM, + }, + hiking: { + url: OPENHIKINGMAP_TILE_URL, + tiles: [OPENHIKINGMAP_TILE_URL], + attribution: OPENHIKINGMAP_TILE_ATTRIBUTION, + maxZoom: OPENHIKINGMAP_TILE_MAXZOOM, + }, +}; + +export const TOUR_RASTER_BASE_IDS = Object.keys(TOUR_RASTER_BASES) as TourRasterBase[]; + +/** The raster source of a base layer, or null for the user's own map and the imagery. */ +export function tourRasterBaseOf(layer: string): TourRasterBaseSource | null { + return layer === 'topo' || layer === 'hiking' ? TOUR_RASTER_BASES[layer] : null; +} + +/** + * Whether a best-effort tile source still answers: a run of failed tiles marks + * it down, one tile arriving clears that. The maps report into it, the switch + * shows it. + */ +export interface TileHealth { + down: boolean; + onTileError: () => void; + onTileLoad: () => void; +} diff --git a/client/src/components/Tours/terrain/TourTerrainMap.tsx b/client/src/components/Tours/terrain/TourTerrainMap.tsx index b188fd6f4..545546816 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.tsx @@ -108,6 +108,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) { insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET, }} > + {baseLayer === 'hiking' && } {legendAbove && legend}
@@ -142,6 +143,34 @@ export default function TourTerrainMap(props: TourTerrainMapProps) { ); } +/** + * OpenHikingMap's credit and donation link, which its operator wants readable without a + * click (MAP-01). The 3D map's own attribution is folded away and sits under the panel, + * so it rides above the control row while that map is shown. + */ +function HikingMapCredit() { + const link = 'text-content-secondary underline-offset-2 hover:underline'; + return ( + + ); +} + /** What a click on the terrain does now: plan, nothing until the tool is on, or nothing to plan. */ function PickHint({ editing, placeOff }: { editing: boolean; placeOff: boolean }) { const { t } = useTranslation(); diff --git a/client/src/components/Tours/terrain/terrainLayers.test.ts b/client/src/components/Tours/terrain/terrainLayers.test.ts index 11e2d4112..c5b90d59b 100644 --- a/client/src/components/Tours/terrain/terrainLayers.test.ts +++ b/client/src/components/Tours/terrain/terrainLayers.test.ts @@ -46,6 +46,22 @@ describe('styleFor', () => { expect(JSON.stringify(topo)).not.toContain('{s}'); expect(satellite.sources.base).toMatchObject({ type: 'raster', maxzoom: 19 }); }); + + it('draws OpenHikingMap with its credit, from a source the flyover never warms up (MAP-01)', () => { + const hiking = styleFor('hiking'); + if (typeof hiking === 'string') throw new Error('expected a raster style'); + const [id, source] = Object.entries(hiking.sources)[0]; + expect(source).toMatchObject({ + type: 'raster', + tiles: ['https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png'], + maxzoom: 18, + }); + expect(JSON.stringify(source)).toContain('openmaps.fr/donate'); + expect(hiking.layers[0]).toMatchObject({ source: id }); + + const { gl } = fakeMap({ [id]: source }); + expect(loadedTemplates(gl)).toEqual([]); + }); }); describe('trackData', () => { diff --git a/client/src/components/Tours/terrain/terrainLayers.ts b/client/src/components/Tours/terrain/terrainLayers.ts index dd4c5f8c6..a198174ff 100644 --- a/client/src/components/Tours/terrain/terrainLayers.ts +++ b/client/src/components/Tours/terrain/terrainLayers.ts @@ -1,9 +1,10 @@ import type { Map as MapLibreMap, StyleSpecification } from 'maplibre-gl'; -import { OPENTOPOMAP_TILE_URL, SATELLITE_TILE_URL } from '../../../constants/mapDefaults'; +import { SATELLITE_TILE_URL } from '../../../constants/mapDefaults'; import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry'; import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders'; import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'; import { plannerPinShape, type PlannerPinPoint } from '../../Map/plannerPinShape'; +import { TOUR_RASTER_BASES } from '../../Map/tourRasterBases'; import type { TileTemplate } from './terrainPrefetch'; import { gradeBand, gradeSegments } from './tourTerrainModel'; @@ -49,22 +50,28 @@ export function sidePadding(panels: PanelWidths, margin: number) { return { left, right }; } -function rasterStyle(tiles: string[], maxzoom: number): StyleSpecification { +/** + * The source of a base map whose operator forbids prefetching (OpenHikingMap, MAP-01): it + * loads only what the camera shows, and the flyover's warm-up passes it by. + */ +const VIEW_ONLY_SOURCE = 'base-view-only'; + +function rasterStyle(tiles: string[], maxzoom: number, sourceId = 'base', attribution?: string): StyleSpecification { return { version: 8, - sources: { base: { type: 'raster', tiles, tileSize: 256, maxzoom } }, - layers: [{ id: 'base', type: 'raster', source: 'base' }], + sources: { [sourceId]: { type: 'raster', tiles, tileSize: 256, maxzoom, attribution } }, + layers: [{ id: 'base', type: 'raster', source: sourceId }], }; } -/** The same three looks as the flat planner map, draped over the terrain. */ +/** The same looks as the flat planner map, draped over the terrain. */ export function styleFor(layer: TourBaseLayer): string | StyleSpecification { if (layer === 'satellite') return rasterStyle([SATELLITE_TILE_URL], 19); - if (layer === 'topo') - return rasterStyle( - ['a', 'b', 'c'].map((s) => OPENTOPOMAP_TILE_URL.replace('{s}', s)), - 17 - ); + if (layer === 'topo') return rasterStyle(TOUR_RASTER_BASES.topo.tiles, TOUR_RASTER_BASES.topo.maxZoom); + if (layer === 'hiking') { + const { tiles, maxZoom, attribution } = TOUR_RASTER_BASES.hiking; + return rasterStyle(tiles, maxZoom, VIEW_ONLY_SOURCE, attribution); + } return OPENFREEMAP_DEFAULT_STYLE; } @@ -165,11 +172,11 @@ export function addTerrainLayers(map: MapLibreMap, data: TrackData, exaggeration map.setSky({ 'sky-color': '#9cc3f0', 'horizon-color': '#e8f0fb', 'fog-color': '#f4f7fb', 'sky-horizon-blend': 0.5 }); } -/** Every tiled source the loaded style draws from, the elevation model included. */ +/** Every tiled source the loaded style draws from, the elevation model included, but no view-only one. */ export function loadedTemplates(map: MapLibreMap): TileTemplate[] { const templates: TileTemplate[] = []; for (const id of Object.keys(map.getStyle().sources)) { - if (id === HILLSHADE_SOURCE || id === TRACK_SOURCE) continue; + if (id === HILLSHADE_SOURCE || id === TRACK_SOURCE || id === VIEW_ONLY_SOURCE) continue; const source = map.getSource(id) as { tiles?: string[]; minzoom?: number; maxzoom?: number } | undefined; const url = source?.tiles?.[0]; if (url) templates.push({ url, minzoom: source.minzoom ?? 0, maxzoom: source.maxzoom ?? 22 }); diff --git a/client/src/components/Tours/tourMapLayers.test.ts b/client/src/components/Tours/tourMapLayers.test.ts new file mode 100644 index 000000000..69bf08c43 --- /dev/null +++ b/client/src/components/Tours/tourMapLayers.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { baseLayerFor, tourMapLayersFor } from './tourMapLayers'; + +describe('tourMapLayersFor (MAP-01)', () => { + it('offers OpenHikingMap to a hike only while the admin allows it', () => { + expect(tourMapLayersFor('hike').bases).not.toContain('hiking'); + expect(tourMapLayersFor('hike', { hikingMap: true }).bases).toEqual(['default', 'topo', 'hiking', 'satellite']); + expect(tourMapLayersFor('hike', { hikingMap: true }).overlays).toEqual(['ferratas']); + expect(tourMapLayersFor('kayak', { hikingMap: true }).bases).not.toContain('hiking'); + expect(tourMapLayersFor(null, { hikingMap: true }).bases).not.toContain('hiking'); + }); + + it('falls back to the standard map once the admin withdrew it, keeping the choice for later', () => { + expect(baseLayerFor(tourMapLayersFor('hike', { hikingMap: true }), 'hiking')).toBe('hiking'); + expect(baseLayerFor(tourMapLayersFor('hike'), 'hiking')).toBe('default'); + }); +}); diff --git a/client/src/components/Tours/tourMapLayers.ts b/client/src/components/Tours/tourMapLayers.ts index 448d0488c..519b9732d 100644 --- a/client/src/components/Tours/tourMapLayers.ts +++ b/client/src/components/Tours/tourMapLayers.ts @@ -9,10 +9,11 @@ import type { TourBaseLayer } from '../Map/MapLayerSwitcher'; * A base layer is the map itself (one at a time). An overlay is information * laid over it and switched on its own; it is off until the user turns it on. * Only sources TREK already ships or that a card cleared are listed: OpenTopoMap - * and the Esri imagery the planner had, the via ferrata layer (RC-10) and - * OpenSeaMap's seamarks (PAD-10). + * and the Esri imagery the planner had, OpenHikingMap (MAP-01), the via ferrata + * layer (RC-10) and OpenSeaMap's seamarks (PAD-10). * - * - Hike: the standard map first, as before; topo and satellite beside it; via ferratas. + * - Hike: the standard map first, as before; topo, OpenHikingMap (MAP-01, while the admin + * allows it) and satellite beside it; via ferratas. * - Paddle: the standard map, whose water reads clearly, and satellite. No topo and * no ferratas, those are hiking maps. Seamarks as an overlay. * - Any other type (switched off, or one this build does not plan): the standard map @@ -33,11 +34,16 @@ const HIKE: TourMapLayerSet = { defaultBase: 'default', overlays: ['ferratas'], }; +const HIKE_WITH_HIKING_MAP: TourMapLayerSet = { ...HIKE, bases: ['default', 'topo', 'hiking', 'satellite'] }; const PADDLE: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: ['seamarks'] }; const OTHER: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: [] }; -export function tourMapLayersFor(type: TourTypeKey | null | undefined): TourMapLayerSet { - if (type === 'hike') return HIKE; +/** `hikingMap`: the admin allows OpenHikingMap on this install (MAP-01). */ +export function tourMapLayersFor( + type: TourTypeKey | null | undefined, + { hikingMap = false }: { hikingMap?: boolean } = {} +): TourMapLayerSet { + if (type === 'hike') return hikingMap ? HIKE_WITH_HIKING_MAP : HIKE; if (type === 'kayak') return PADDLE; return OTHER; } diff --git a/client/src/components/Tours/useTourRasterOverlay.test.ts b/client/src/components/Tours/useTourRasterOverlay.test.ts new file mode 100644 index 000000000..2fe0d2337 --- /dev/null +++ b/client/src/components/Tours/useTourRasterOverlay.test.ts @@ -0,0 +1,54 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useTileHealth, useTourRasterOverlay } from './useTourRasterOverlay'; + +const SEAMARKS = { id: 'seamarks', url: 'https://x/{z}/{x}/{y}.png', attribution: 'x', minZoom: 6, maxZoom: 18 }; + +describe('useTileHealth', () => { + it('counts a source down after a run of failures, never after a stray one, and up again on one tile', () => { + const { result } = renderHook(() => useTileHealth()); + act(() => result.current.onTileError()); + expect(result.current.down).toBe(false); + act(() => { + result.current.onTileError(); + result.current.onTileError(); + result.current.onTileError(); + }); + expect(result.current.down).toBe(true); + act(() => result.current.onTileLoad()); + expect(result.current.down).toBe(false); + }); + + it('starts afresh on reset', () => { + const { result } = renderHook(() => useTileHealth()); + act(() => { + for (let i = 0; i < 4; i++) result.current.onTileError(); + }); + act(() => result.current.reset()); + expect(result.current.down).toBe(false); + act(() => result.current.onTileError()); + expect(result.current.down).toBe(false); + }); +}); + +describe('useTourRasterOverlay', () => { + it('is off unless offered and on, and down after a run of failures until switched again', () => { + const toggle = vi.fn(); + const { result, rerender } = renderHook( + ({ offered, active }) => useTourRasterOverlay(SEAMARKS, offered, active, toggle), + { + initialProps: { offered: false, active: true }, + } + ); + expect(result.current.status).toBe('off'); + rerender({ offered: true, active: true }); + expect(result.current.status).toBe('on'); + act(() => { + for (let i = 0; i < 4; i++) result.current.onTileError(); + }); + expect(result.current.status).toBe('down'); + act(() => result.current.onToggle()); + expect(toggle).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('on'); + }); +}); diff --git a/client/src/components/Tours/useTourRasterOverlay.ts b/client/src/components/Tours/useTourRasterOverlay.ts index fc14fe843..e2513aa97 100644 --- a/client/src/components/Tours/useTourRasterOverlay.ts +++ b/client/src/components/Tours/useTourRasterOverlay.ts @@ -1,15 +1,33 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import type { MapRasterOverlay, ToggleableRasterOverlay } from '../Map/mapRasterOverlay'; +import type { TileHealth } from '../Map/tourRasterBases'; /** Failed tiles in a row, with none arriving between them, before the source counts as down. */ const FAILURES_UNTIL_DOWN = 4; +/** + * Whether a best-effort tile source answers. A run of failed tiles marks it + * down; one tile arriving clears that, and `reset` tries afresh. One stray + * failure alone never marks it down. + */ +export function useTileHealth(): TileHealth & { reset: () => void } { + const [down, setDown] = useState(false); + const failures = useRef(0); + const reset = useCallback(() => { + failures.current = 0; + setDown(false); + }, []); + const onTileError = useCallback(() => { + failures.current += 1; + if (failures.current >= FAILURES_UNTIL_DOWN) setDown(true); + }, []); + return useMemo(() => ({ down, onTileError, onTileLoad: reset, reset }), [down, onTileError, reset]); +} + /** * An overlay the planner offers, off until switched on; whether it is on is - * the planner's (`active`, `onToggle`), so it outlasts the map. A run of failed - * tiles marks it down, which the switch shows; one tile arriving clears that, - * and switching it off and on again tries afresh. One stray failure alone - * never marks it down. + * the planner's (`active`, `onToggle`), so it outlasts the map. Its tile + * health shows on the switch, and switching it off and on again tries afresh. */ export function useTourRasterOverlay( definition: Pick, @@ -17,23 +35,13 @@ export function useTourRasterOverlay( active: boolean, toggle: () => void ): ToggleableRasterOverlay { - const [down, setDown] = useState(false); - const failures = useRef(0); + const { down, onTileError, onTileLoad, reset } = useTileHealth(); const on = active && offered; const onToggle = useCallback(() => { - failures.current = 0; - setDown(false); + reset(); toggle(); - }, [toggle]); - const onTileError = useCallback(() => { - failures.current += 1; - if (failures.current >= FAILURES_UNTIL_DOWN) setDown(true); - }, []); - const onTileLoad = useCallback(() => { - failures.current = 0; - setDown(false); - }, []); + }, [reset, toggle]); return useMemo( () => ({ diff --git a/client/src/constants/mapDefaults.ts b/client/src/constants/mapDefaults.ts index 701d72111..aa2a221f1 100644 --- a/client/src/constants/mapDefaults.ts +++ b/client/src/constants/mapDefaults.ts @@ -28,6 +28,18 @@ export const OPENTOPOMAP_TILE_ATTRIBUTION = 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)' export const OPENTOPOMAP_TILE_MAXZOOM = 17 +/** + * MAP-01: OpenHikingMap, the hiking base map the Tours planner offers while the admin allows it. + * Served by openmaps.fr under its tile usage policy (openmaps.fr/tile-usage-policy.html): free + * for non-commercial projects up to 500,000 tiles a month, tiles loaded only as they are viewed + * (no prefetch, no offline copy), with a Referer, and its credit and donation link visible on the + * map. Checked on 19.10.2026: zoom 0 to 18, CORS open, the server's own Cache-Control. + */ +export const OPENHIKINGMAP_TILE_URL = 'https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png' +export const OPENHIKINGMAP_TILE_ATTRIBUTION = + '© OpenHikingMap | © OpenStreetMap | ❤️ Donation' +export const OPENHIKINGMAP_TILE_MAXZOOM = 18 + /** * The basemap for a browser that will not give MapLibre a WebGL context (#2288). * diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 2a93b6210..c14e48047 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -3,7 +3,7 @@ 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, Check, Loader2, Mountain, CarFront, + Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Check, Loader2, Mountain, CarFront, Footprints, } from 'lucide-react' import DawarichIcon from '../../../components/shared/DawarichIcon' import AirTrailIcon from '../../../components/shared/AirTrailIcon' @@ -66,6 +66,8 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, toursWanderer, toggleToursWanderer, + toursHikingMap, + toggleToursHikingMap, } = useAddonManager() if (loading) { @@ -114,13 +116,22 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac )} {addon.id === 'documents' && addon.enabled && } {addon.id === 'tours' && addon.enabled && ( - void toggleToursWanderer()} - /> + <> + void toggleToursWanderer()} + /> + void toggleToursHikingMap()} + /> + )} {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && ( <> diff --git a/client/src/pages/tripPlanner/useTourStageLayers.test.ts b/client/src/pages/tripPlanner/useTourStageLayers.test.ts new file mode 100644 index 000000000..51cbfe6d0 --- /dev/null +++ b/client/src/pages/tripPlanner/useTourStageLayers.test.ts @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; +import type { TourListItem } from '@trek/shared'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; +import { useAddonStore } from '../../store/addonStore'; +import { useTourStageLayers } from './useTourStageLayers'; + +type Planner = Parameters[0]; +type Ferrata = Parameters[1]; + +const ferrata = { layer: undefined, crossings: [], selected: null } as unknown as Ferrata; + +function planner(mode: TourPlannerController['mode'], tourType: Planner['tourType'] = 'hike'): Planner { + return { mode, tourType, mapBaseLayer: 'hiking', mapOverlays: new Set(), toggleMapOverlay: () => {} }; +} + +const basesOf = (p: Planner) => renderHook(() => useTourStageLayers(p, ferrata)).result.current; + +describe('useTourStageLayers: OpenHikingMap (MAP-01)', () => { + beforeEach(() => useAddonStore.setState({ toursHikingMap: true })); + + it('offers it while a hike is planned, as a draft or a saved tour in the editor', () => { + for (const mode of [{ type: 'new-draft' }, { type: 'edit-saved', placeId: 4 }] as const) { + const layers = basesOf(planner(mode)); + expect(layers.tourLayers.bases).toContain('hiking'); + expect(layers.baseLayer).toBe('hiking'); + } + }); + + it('not with no tour open, over an import viewed as it came in, or for a paddle tour', () => { + const viewed = { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'hike' } as TourListItem } as const; + for (const p of [planner({ type: 'neutral' }), planner(viewed), planner({ type: 'new-draft' }, 'kayak')]) { + const layers = basesOf(p); + expect(layers.tourLayers.bases).not.toContain('hiking'); + // The choice is kept; the map shows the type's default until a hike is planned again. + expect(layers.baseLayer).toBe('default'); + } + }); + + it('not where the admin withdrew it', () => { + useAddonStore.setState({ toursHikingMap: false }); + expect(basesOf(planner({ type: 'new-draft' })).tourLayers.bases).not.toContain('hiking'); + }); +}); diff --git a/client/src/pages/tripPlanner/useTourStageLayers.ts b/client/src/pages/tripPlanner/useTourStageLayers.ts index 5759af5d6..0cdb45760 100644 --- a/client/src/pages/tripPlanner/useTourStageLayers.ts +++ b/client/src/pages/tripPlanner/useTourStageLayers.ts @@ -1,11 +1,12 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer'; import { paddleMapItems, selectedPaddleKey } from '../../components/Tours/paddle/paddleMapLayer'; import { usePaddlePaint } from '../../components/Tours/paddle/paddlePaint'; import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; import { baseLayerFor, SEAMARK_OVERLAY, tourMapLayersFor } from '../../components/Tours/tourMapLayers'; -import { useTourRasterOverlay } from '../../components/Tours/useTourRasterOverlay'; +import { useTileHealth, useTourRasterOverlay } from '../../components/Tours/useTourRasterOverlay'; +import { useAddonStore } from '../../store/addonStore'; import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore'; const NO_CROSSINGS: never[] = []; @@ -17,14 +18,29 @@ type FerrataLayerState = ReturnType; * and overlays of its type, an import with its own type's and a draft with the * one chosen. A type without the via ferrata layer, or no tour open at all, * neither draws nor warns about ferratas, even if the layer was switched on before; a base layer it - * does not offer falls back to its default without changing the choice kept. + * does not offer falls back to its default without changing the choice kept, + * which is also what an install whose admin withdrew OpenHikingMap sees. */ export function useTourStageLayers( tourPlanner: Pick, ferrata: FerrataLayerState ) { const { mode } = tourPlanner; - const layerSet = tourMapLayersFor(mode.type === 'view-gpx' ? mode.tour.tour_type : tourPlanner.tourType); + // MAP-01: OpenHikingMap while a hike is being planned (a draft or a saved tour in the editor), + // never over an import viewed as it came in or with no tour open, and only while the admin offers it. + const hikingMapAllowed = useAddonStore((state) => state.toursHikingMap); + const planning = mode.type === 'new-draft' || mode.type === 'edit-saved'; + const layerSet = tourMapLayersFor(mode.type === 'view-gpx' ? mode.tour.tour_type : tourPlanner.tourType, { + hikingMap: hikingMapAllowed && planning, + }); + const baseLayer = baseLayerFor(layerSet, tourPlanner.mapBaseLayer); + // Whether OpenHikingMap answers, asked afresh each time it becomes the base map. + const hikingMap = useTileHealth(); + const { reset: resetHikingMap } = hikingMap; + const showsHikingMap = baseLayer === 'hiking'; + useEffect(() => { + if (showsHikingMap) resetHikingMap(); + }, [showsHikingMap, resetHikingMap]); // With no tour open nothing is a hike yet: the ferratas wait for one, as the seamarks wait for a paddle tour. const offersFerratas = mode.type !== 'neutral' && layerSet.overlays.includes('ferratas'); const { toggleMapOverlay } = tourPlanner; @@ -54,11 +70,16 @@ export function useTourStageLayers( [paddleItems, features, selectedRef] ); const tourLayers = useMemo( - () => ({ bases: layerSet.bases, overlays: layerSet.overlays.includes('seamarks') ? [seamarks] : [], paddle }), - [layerSet, seamarks, paddle] + () => ({ + bases: layerSet.bases, + overlays: layerSet.overlays.includes('seamarks') ? [seamarks] : [], + paddle, + hikingMap: showsHikingMap ? hikingMap : undefined, + }), + [layerSet, seamarks, paddle, showsHikingMap, hikingMap] ); return { - baseLayer: baseLayerFor(layerSet, tourPlanner.mapBaseLayer), + baseLayer, tourLayers, ferrataLayer: offersFerratas ? ferrata.layer : undefined, ferrataCrossings: offersFerratas ? ferrata.crossings : NO_CROSSINGS, diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts index c5c752a8f..6d6e8c1a4 100644 --- a/client/src/store/addonStore.ts +++ b/client/src/store/addonStore.ts @@ -27,6 +27,8 @@ interface AddonState { bagTracking: boolean /** Tours is on and the admin allows wanderer under it. */ toursWanderer: boolean + /** Tours is on and the admin offers OpenHikingMap in its planner (MAP-01). */ + toursHikingMap: boolean loaded: boolean loadAddons: () => Promise isEnabled: (id: string) => boolean @@ -36,6 +38,7 @@ export const useAddonStore = create((set, get) => ({ addons: [], bagTracking: false, toursWanderer: false, + toursHikingMap: false, loaded: false, loadAddons: async () => { @@ -43,6 +46,7 @@ export const useAddonStore = create((set, get) => ({ const data = await addonsApi.enabled() set({ addons: data.addons || [], bagTracking: !!data.bagTracking, toursWanderer: !!data.toursWanderer, + toursHikingMap: !!data.toursHikingMap, loaded: true, }) } catch { diff --git a/client/tests/helpers/msw/handlers/admin.ts b/client/tests/helpers/msw/handlers/admin.ts index e90eb1872..8241650a2 100644 --- a/client/tests/helpers/msw/handlers/admin.ts +++ b/client/tests/helpers/msw/handlers/admin.ts @@ -85,6 +85,15 @@ export const adminHandlers = [ return HttpResponse.json({ enabled: body.enabled }); }), + http.get('/api/admin/tours-hiking-map', () => { + return HttpResponse.json({ enabled: true }); + }), + + http.put('/api/admin/tours-hiking-map', async ({ request }) => { + const body = await request.json() as Record; + return HttpResponse.json({ enabled: body.enabled }); + }), + http.get('/api/admin/addons', () => { return HttpResponse.json({ addons: [] }); }), diff --git a/client/tests/unit/stores/addonStore.test.ts b/client/tests/unit/stores/addonStore.test.ts index 19479ec5e..03a0fec61 100644 --- a/client/tests/unit/stores/addonStore.test.ts +++ b/client/tests/unit/stores/addonStore.test.ts @@ -33,7 +33,9 @@ describe('addonStore', () => { }); it('captures whether wanderer may be used under Tours', async () => { - server.use(http.get('/api/addons', () => HttpResponse.json({ toursWanderer: true, addons: [] }))); + server.use( + http.get('/api/addons', () => HttpResponse.json({ toursWanderer: true, toursHikingMap: true, addons: [] })) + ); await useAddonStore.getState().loadAddons(); expect(useAddonStore.getState().toursWanderer).toBe(true); diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 2fc5e598c..ad0541cd1 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -325,6 +325,9 @@ export function applyGlobalMiddleware( // Tours planner (PAD-10). Leaflet loads it as an ; the GL map reads it // through fetch and needs it here. 'https://tiles.openseamap.org', + // OpenHikingMap, the hiking base map of the Tours planner (MAP-01), loaded only + // while a user shows it and the admin allows it; GL reads it through fetch. + 'https://tile.openmaps.fr', // Amap's raster tiles, for an install whose users are in China, for the // same reason as the hosts above (#2180). Road (webrd01..04) and // satellite (webst01..04) are numbered shards of one domain, so the diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 52076db9e..18781f5ec 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -78,6 +78,19 @@ export class AddonsService { return { enabled: !!enabled }; } + /** + * MAP-01: the admin's switch for OpenHikingMap under Tours, as stored. Fail-open: an install + * that never saved it offers the map, one whose admin switched it off does not. + */ + async getToursHikingMap() { + const value = await readAppSetting(this.appSettings, 'tours_hiking_map_enabled'); + return { enabled: value !== 'false' }; + } + + async updateToursHikingMap(enabled: boolean) { + return this.writeFlag('tours_hiking_map_enabled', enabled); + } + /** wanderer may be used: Tours is on and the admin allowed it under Tours. */ async isWandererAvailable(): Promise { return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled; @@ -163,6 +176,7 @@ export class AddonsService { collabFeatures: await this.getCollabFeatures(), bagTracking: (await this.getBagTracking()).enabled, toursWanderer: await this.isWandererAvailable(), + toursHikingMap: (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursHikingMap()).enabled, addons: [ // The repository row carries every scalar column (description, config, // sort_order included); the legacy statement selected only these five, diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 4559388c2..012380083 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -304,6 +304,25 @@ export class AdminController { return result; } + @Get('tours-hiking-map') + @ResponseContract(adminFeatureToggleResponseSchema) + getToursHikingMap() { + return this.addons.getToursHikingMap(); + } + + @Put('tours-hiking-map') + @ResponseContract(adminFeatureToggleResponseSchema) + async updateToursHikingMap(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updateToursHikingMap(body.enabled); + await this.audit.writeAudit({ + userId: user.id, + action: 'admin.tours_hiking_map', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + return result; + } + @Get('places-photos') getPlacesPhotos() { return this.addons.getPlacesPhotos(); diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts index 9412ee416..37528e6ee 100644 --- a/server/src/nest/common/app-settings.registry.ts +++ b/server/src/nest/common/app-settings.registry.ts @@ -108,6 +108,10 @@ export const APP_SETTINGS = { bag_tracking_enabled: { schema: flag, default: 'false' }, // Off until an admin allows it: every user's wanderer calls go out to an address of their choosing. tours_wanderer_enabled: { schema: flag, default: 'false' }, + // MAP-01: OpenHikingMap as a base map of the Tours planner. On, since its tiles are free for + // the installs TREK is made for; an admin whose install is commercial or larger switches it off + // until there is an agreement with the operator (openmaps.fr/tile-usage-policy.html). + tours_hiking_map_enabled: { schema: flag, default: 'true' }, route_usage_enabled: { schema: flag, default: 'false' }, // PAD-11C: how TREK gets the water data of a place a paddler plans in (auto, ask, off) and the largest extract it loads by itself. paddle_auto_load: { schema: z.enum(['auto', 'ask', 'off']), default: 'auto' }, diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index 05561da55..83615876c 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -136,6 +136,7 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { bagTracking: true, // Tours is off in this database, so wanderer is not offered either. toursWanderer: false, + toursHikingMap: false, addons: [ { id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true }, { id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }, diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index d46ab41d1..f5a36b798 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -56,6 +56,10 @@ describe('global CSP: the other shipped raster presets (#2180)', () => { expect(await connectSrcSources()).toContain('https://tiles.openseamap.org'); }); + it('allows the OpenHikingMap host the Tours planner offers as a hiking base map (MAP-01)', async () => { + expect(await connectSrcSources()).toContain('https://tile.openmaps.fr'); + }); + it('keeps the routing host, which is a different host and covers nothing here', async () => { // routing.openstreetmap.de was on the list all along and looks close enough // to hide the gap: a CSP source matches a host, not a suffix of one. diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 28a093093..5d5c0bfde 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -425,6 +425,20 @@ describe('AddonsService addon/feature flags', () => { expect((await svc.list()).toursWanderer).toBe(false); }); + it('OpenHikingMap under Tours is on until an admin switches it off, and offered only with Tours (MAP-01)', async () => { + expect(await svc.getToursHikingMap()).toEqual({ enabled: true }); // absent row → ON + expect((await svc.list()).toursHikingMap).toBe(false); // Tours is off + await insertAddon({ id: 'tours', name: 'Tours', enabled: 1 }); + expect((await svc.list()).toursHikingMap).toBe(true); + + expect(await svc.updateToursHikingMap(false)).toEqual({ enabled: false }); + expect(await storedAppSetting('tours_hiking_map_enabled')).toEqual({ + key: 'tours_hiking_map_enabled', + value: 'false', + }); + expect((await svc.list()).toursHikingMap).toBe(false); + }); + it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => { await setAppSetting('collab_chat_enabled', 'false'); await setAppSetting('collab_polls_enabled', 'true'); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 7eb859463..f961af51f 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -46,6 +46,8 @@ const addonsStub = () => updateBagTracking: vi.fn((enabled: boolean) => ({ enabled })), getToursWanderer: vi.fn(() => ({ enabled: false })), updateToursWanderer: vi.fn((enabled: boolean) => ({ enabled })), + getToursHikingMap: vi.fn(() => ({ enabled: true })), + updateToursHikingMap: vi.fn((enabled: boolean) => ({ enabled })), getPlacesPhotos: vi.fn(() => ({ enabled: false })), updatePlacesPhotos: vi.fn((enabled: boolean) => ({ enabled })), getPlacesAutocomplete: vi.fn(() => ({ enabled: false })), @@ -492,6 +494,7 @@ describe('AdminController feature toggles', () => { const cases: Array<[() => Promise, string, keyof AddonsService]> = [ [() => c.updateBagTracking(user, { enabled: true }, req), 'admin.bag_tracking', 'updateBagTracking'], [() => c.updateToursWanderer(user, { enabled: true }, req), 'admin.tours_wanderer', 'updateToursWanderer'], + [() => c.updateToursHikingMap(user, { enabled: true }, req), 'admin.tours_hiking_map', 'updateToursHikingMap'], [() => c.updatePlacesPhotos(user, { enabled: true }, req), 'admin.places_photos', 'updatePlacesPhotos'], [ () => c.updatePlacesAutocomplete(user, { enabled: true }, req), @@ -513,6 +516,7 @@ describe('AdminController feature toggles', () => { const c = adminCtl(svc()); expect(await c.getBagTracking()).toEqual({ enabled: false }); expect(await c.getToursWanderer()).toEqual({ enabled: false }); + expect(await c.getToursHikingMap()).toEqual({ enabled: true }); expect(await c.getPlacesPhotos()).toEqual({ enabled: false }); expect(await c.getPlacesAutocomplete()).toEqual({ enabled: false }); expect(await c.getPlacesDetails()).toEqual({ enabled: false }); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 77f511b9c..47e6352c6 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -717,6 +717,9 @@ const admin: TranslationStrings = { 'admin.addons.subOptionsOn.many': 'المُفعّل: {count}', 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'يمكن للمستخدمين ربط خادم wanderer الخاص بهم واستيراد دروبه كجولات', + 'admin.toursHikingMap.title': 'خريطة المشي (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'يعرض OpenHikingMap كخريطة أساسية في مخطط الجولات. مجاني للتثبيتات غير التجارية حتى 500,000 مربع شهريًا؛ أما التثبيت التجاري أو الأكبر فيحتاج أولًا إلى اتفاق مع openmaps.fr', 'admin.addons.noAddons': 'لا توجد إضافات متاحة', 'admin.weather.title': 'بيانات الطقس', 'admin.weather.badge': 'منذ 24 مارس 2026', diff --git a/shared/src/i18n/ar/map.ts b/shared/src/i18n/ar/map.ts index 0862d8f57..43762ff06 100644 --- a/shared/src/i18n/ar/map.ts +++ b/shared/src/i18n/ar/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'إخفاء جميع مسارات الحجوزات', 'map.baseLayer.default': 'خريطة', 'map.baseLayer.satellite': 'قمر صناعي', + 'map.baseLayer.hiking': 'المشي', + 'map.baseLayer.hikingDown': 'لا يستجيب OpenHikingMap حاليًا. انتقل إلى خريطة أخرى، وتبقى جولتك كما هي.', 'map.baseLayer.switchToSatellite': 'التبديل إلى عرض القمر الصناعي', 'map.baseLayer.switchToDefault': 'التبديل إلى عرض الخريطة', 'map.location.denied': diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index ef9324112..0cf443501 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -750,6 +750,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'İstifadəçilər öz wanderer instansiyalarını qoşa və onun cığırlarını tur kimi idxal edə bilərlər', + 'admin.toursHikingMap.title': 'Gəzinti xəritəsi (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'OpenHikingMap-i tur planlayıcısında əsas xəritə kimi təklif edir. Qeyri-kommersiya quraşdırmalar üçün ayda 500.000 plitəyə qədər pulsuzdur; kommersiya və ya daha böyük quraşdırma əvvəlcə openmaps.fr ilə razılaşma tələb edir', 'admin.addons.noAddons': 'Heç bir əlavə mövcud deyil', 'admin.weather.title': 'Hava məlumatları', 'admin.weather.badge': '24 mart 2026-cı ildən', diff --git a/shared/src/i18n/az/map.ts b/shared/src/i18n/az/map.ts index f5258fbab..5d0a73fb6 100644 --- a/shared/src/i18n/az/map.ts +++ b/shared/src/i18n/az/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.default': 'Xəritə', 'map.baseLayer.satellite': 'Peyk', + 'map.baseLayer.hiking': 'Gəzinti', + 'map.baseLayer.hikingDown': 'OpenHikingMap hazırda cavab vermir. Başqa xəritəyə keçin; turunuz olduğu kimi qalır.', 'map.baseLayer.switchToSatellite': 'Peyk görünüşünə keç', 'map.baseLayer.switchToDefault': 'Xəritə görünüşünə keç', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 06a0750c4..aa40cf357 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -658,6 +658,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Os usuários podem conectar a própria instância do wanderer e importar as trilhas dela como passeios', + 'admin.toursHikingMap.title': 'Mapa de trilhas (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Oferece o OpenHikingMap como mapa base no planejador de passeios. Gratuito para instalações não comerciais até 500.000 blocos por mês; uma instalação comercial ou maior precisa antes de um acordo com o openmaps.fr', 'admin.addons.noAddons': 'Nenhum complemento disponível', 'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada', 'admin.weather.title': 'Dados meteorológicos', diff --git a/shared/src/i18n/br/map.ts b/shared/src/i18n/br/map.ts index 569dc22a3..b7a431785 100644 --- a/shared/src/i18n/br/map.ts +++ b/shared/src/i18n/br/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Ocultar todas as rotas de reservas', 'map.baseLayer.default': 'Mapa', 'map.baseLayer.satellite': 'Satélite', + 'map.baseLayer.hiking': 'Trilhas', + 'map.baseLayer.hikingDown': + 'O OpenHikingMap não está respondendo agora. Mude para outro mapa; seu passeio continua como está.', 'map.baseLayer.switchToSatellite': 'Mudar para vista de satélite', 'map.baseLayer.switchToDefault': 'Mudar para vista de mapa', 'map.location.denied': diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 70a994725..febabd59f 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -267,6 +267,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Els usuaris poden connectar la seva pròpia instància de wanderer i importar-ne els senders com a excursions', + 'admin.toursHikingMap.title': "Mapa d'excursionisme (OpenHikingMap)", + 'admin.toursHikingMap.subtitle': + 'Ofereix OpenHikingMap com a mapa base al planificador de rutes. Gratuït per a instal·lacions no comercials fins a 500.000 tessel·les al mes; una instal·lació comercial o més gran necessita abans un acord amb openmaps.fr', 'admin.addons.noAddons': 'No hi ha complements disponibles', 'admin.weather.title': 'Dades meteorològiques', 'admin.weather.badge': 'Des del 24 de març de 2026', diff --git a/shared/src/i18n/ca/map.ts b/shared/src/i18n/ca/map.ts index 109fbb3ef..8d18f2310 100644 --- a/shared/src/i18n/ca/map.ts +++ b/shared/src/i18n/ca/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Amaga totes les rutes de reserva', 'map.baseLayer.default': 'Mapa', 'map.baseLayer.satellite': 'Satèl·lit', + 'map.baseLayer.hiking': 'Excursionisme', + 'map.baseLayer.hikingDown': + 'OpenHikingMap no respon ara mateix. Canvia a un altre mapa; la teva ruta es queda tal com és.', 'map.baseLayer.switchToSatellite': 'Canvia a vista de satèl·lit', 'map.baseLayer.switchToDefault': 'Canvia a vista de mapa', 'map.location.denied': diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index bd0c644ed..7eafca240 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -661,6 +661,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Uživatelé mohou připojit vlastní instanci wanderer a importovat z ní trasy jako výlety', + 'admin.toursHikingMap.title': 'Turistická mapa (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Nabízí OpenHikingMap jako podkladovou mapu v plánovači túr. Zdarma pro nekomerční instalace do 500 000 dlaždic měsíčně; komerční nebo větší instalace potřebuje nejdřív dohodu s openmaps.fr', 'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů', diff --git a/shared/src/i18n/cs/map.ts b/shared/src/i18n/cs/map.ts index 41e49f4ba..1cf8acf0c 100644 --- a/shared/src/i18n/cs/map.ts +++ b/shared/src/i18n/cs/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Skrýt všechny trasy rezervací', 'map.baseLayer.default': 'Mapa', 'map.baseLayer.satellite': 'Satelit', + 'map.baseLayer.hiking': 'Turistika', + 'map.baseLayer.hikingDown': 'OpenHikingMap teď neodpovídá. Přepni na jinou mapu, tvoje túra zůstane beze změny.', 'map.baseLayer.switchToSatellite': 'Přepnout na satelitní zobrazení', 'map.baseLayer.switchToDefault': 'Přepnout na mapové zobrazení', 'map.location.denied': diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 7454bd5d9..849fe9730 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -666,6 +666,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Nutzer können ihre eigene wanderer-Instanz verbinden und deren Trails als Touren importieren', + 'admin.toursHikingMap.title': 'Wanderkarte (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Bietet OpenHikingMap als Grundkarte im Tourplaner an. Kostenlos für nichtkommerzielle Installationen bis 500.000 Kacheln im Monat; eine kommerzielle oder größere Installation braucht vorher eine Vereinbarung mit openmaps.fr', 'admin.addons.noAddons': 'Keine Addons verfügbar', 'admin.weather.title': 'Wetterdaten', 'admin.weather.badge': 'Seit 24. März 2026', diff --git a/shared/src/i18n/de/map.ts b/shared/src/i18n/de/map.ts index 5c4210bb0..67bace502 100644 --- a/shared/src/i18n/de/map.ts +++ b/shared/src/i18n/de/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Alle Buchungsrouten ausblenden', 'map.baseLayer.default': 'Karte', 'map.baseLayer.satellite': 'Satellit', + 'map.baseLayer.hiking': 'Wandern', + 'map.baseLayer.hikingDown': + 'OpenHikingMap antwortet gerade nicht. Wechsle auf eine andere Karte, deine Tour bleibt unverändert.', 'map.baseLayer.switchToSatellite': 'Zur Satellitenansicht wechseln', 'map.baseLayer.switchToDefault': 'Zur Kartenansicht wechseln', 'map.location.denied': diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 77d4b0fef..8b93d0263 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -710,6 +710,9 @@ const admin: TranslationStrings = { 'admin.addons.subOptionsOn.one': '{count} on', 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Users can connect their own wanderer instance and import its trails as tours', + 'admin.toursHikingMap.title': 'Hiking map (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Offers OpenHikingMap as a base map in the tour planner. Free for non-commercial installs up to 500,000 tiles a month; a commercial or larger install needs an agreement with openmaps.fr first', 'admin.addons.noAddons': 'No addons available', 'admin.weather.title': 'Weather Data', 'admin.weather.badge': 'Since March 24, 2026', diff --git a/shared/src/i18n/en/map.ts b/shared/src/i18n/en/map.ts index b7a995dfc..7b46c1ebf 100644 --- a/shared/src/i18n/en/map.ts +++ b/shared/src/i18n/en/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Hide all booking routes', 'map.baseLayer.default': 'Map', 'map.baseLayer.satellite': 'Satellite', + 'map.baseLayer.hiking': 'Hiking', + 'map.baseLayer.hikingDown': + 'OpenHikingMap is not answering right now. Switch to another map; your tour stays as it is.', 'map.baseLayer.switchToSatellite': 'Switch to satellite view', 'map.baseLayer.switchToDefault': 'Switch to map view', 'map.location.denied': diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index ed3d5cdd5..7464b24f0 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -649,6 +649,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Los usuarios pueden conectar su propia instancia de wanderer e importar sus senderos como excursiones', + 'admin.toursHikingMap.title': 'Mapa de senderismo (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Ofrece OpenHikingMap como mapa base en el planificador de rutas. Gratis para instalaciones no comerciales hasta 500.000 teselas al mes; una instalación comercial o mayor necesita antes un acuerdo con openmaps.fr', 'admin.addons.noAddons': 'No hay complementos disponibles', 'admin.weather.title': 'Datos meteorológicos', 'admin.weather.badge': 'Desde el 24 de marzo de 2026', diff --git a/shared/src/i18n/es/map.ts b/shared/src/i18n/es/map.ts index 1ec43f156..ad19a58df 100644 --- a/shared/src/i18n/es/map.ts +++ b/shared/src/i18n/es/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Ocultar todas las rutas de reservas', 'map.baseLayer.default': 'Mapa', 'map.baseLayer.satellite': 'Satélite', + 'map.baseLayer.hiking': 'Senderismo', + 'map.baseLayer.hikingDown': 'OpenHikingMap no responde ahora mismo. Cambia a otro mapa; tu ruta se queda como está.', 'map.baseLayer.switchToSatellite': 'Cambiar a vista de satélite', 'map.baseLayer.switchToDefault': 'Cambiar a vista de mapa', 'map.location.denied': diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index 954b919fa..4d0b00554 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -730,6 +730,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Kasutajad saavad ühendada oma wanderer-serveri ja importida selle matkarajad retkedena', + 'admin.toursHikingMap.title': 'Matkakaart (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Pakub OpenHikingMapi matkaplaneerija aluskaardina. Mitteäriliste paigalduste jaoks tasuta kuni 500 000 paani kuus; äriline või suurem paigaldus vajab enne kokkulepet openmaps.fr-iga', 'admin.addons.noAddons': 'Lisamooduleid pole saadaval', 'admin.weather.title': 'Ilmaandmed', 'admin.weather.badge': 'Alates 24. märtsist 2026', diff --git a/shared/src/i18n/et/map.ts b/shared/src/i18n/et/map.ts index eefbf52ca..74767a3db 100644 --- a/shared/src/i18n/et/map.ts +++ b/shared/src/i18n/et/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Peida kõik broneeringute marsruudid', 'map.baseLayer.default': 'Kaart', 'map.baseLayer.satellite': 'Satelliit', + 'map.baseLayer.hiking': 'Matkamine', + 'map.baseLayer.hikingDown': 'OpenHikingMap ei vasta praegu. Vaheta teisele kaardile, sinu matk jääb samaks.', 'map.baseLayer.switchToSatellite': 'Lülitu satelliidivaatele', 'map.baseLayer.switchToDefault': 'Lülitu kaardivaatele', 'map.location.denied': diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 2884fa2eb..3aa1b6fea 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -671,6 +671,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Les utilisateurs peuvent connecter leur propre instance wanderer et importer ses sentiers comme sorties', + 'admin.toursHikingMap.title': 'Carte de randonnée (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + "Propose OpenHikingMap comme fond de carte dans le planificateur d'itinéraires. Gratuit pour les installations non commerciales jusqu'à 500 000 tuiles par mois ; une installation commerciale ou plus importante nécessite d'abord un accord avec openmaps.fr", 'admin.addons.noAddons': 'Aucune extension disponible', 'admin.weather.title': 'Données météo', 'admin.weather.badge': 'Depuis le 24 mars 2026', diff --git a/shared/src/i18n/fr/map.ts b/shared/src/i18n/fr/map.ts index ce967fc74..ca723b9ec 100644 --- a/shared/src/i18n/fr/map.ts +++ b/shared/src/i18n/fr/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Masquer tous les itinéraires', 'map.baseLayer.default': 'Carte', 'map.baseLayer.satellite': 'Satellite', + 'map.baseLayer.hiking': 'Randonnée', + 'map.baseLayer.hikingDown': + 'OpenHikingMap ne répond pas pour le moment. Passe à une autre carte ; ton itinéraire reste inchangé.', 'map.baseLayer.switchToSatellite': 'Passer à la vue satellite', 'map.baseLayer.switchToDefault': 'Passer à la vue carte', 'map.location.denied': diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index fdc39db01..c7031902c 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -734,6 +734,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Οι χρήστες μπορούν να συνδέσουν τη δική τους εγκατάσταση wanderer και να εισαγάγουν τα μονοπάτια της ως εκδρομές', + 'admin.toursHikingMap.title': 'Χάρτης πεζοπορίας (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Προσφέρει το OpenHikingMap ως χάρτη βάσης στον σχεδιαστή διαδρομών. Δωρεάν για μη εμπορικές εγκαταστάσεις έως 500.000 πλακίδια τον μήνα· μια εμπορική ή μεγαλύτερη εγκατάσταση χρειάζεται πρώτα συμφωνία με το openmaps.fr', 'admin.addons.noAddons': 'Δεν υπάρχουν διαθέσιμα πρόσθετα', 'admin.weather.title': 'Δεδομένα Καιρού', 'admin.weather.badge': 'Από 24 Μαρτίου 2026', diff --git a/shared/src/i18n/gr/map.ts b/shared/src/i18n/gr/map.ts index fb31e5e9d..193506ae2 100644 --- a/shared/src/i18n/gr/map.ts +++ b/shared/src/i18n/gr/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Απόκρυψη όλων των διαδρομών κρατήσεων', 'map.baseLayer.default': 'Χάρτης', 'map.baseLayer.satellite': 'Δορυφόρος', + 'map.baseLayer.hiking': 'Πεζοπορία', + 'map.baseLayer.hikingDown': + 'Το OpenHikingMap δεν απαντά αυτή τη στιγμή. Άλλαξε σε άλλο χάρτη· η διαδρομή σου μένει ως έχει.', 'map.baseLayer.switchToSatellite': 'Εναλλαγή σε δορυφορική προβολή', 'map.baseLayer.switchToDefault': 'Εναλλαγή σε προβολή χάρτη', 'map.location.denied': diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index b77897fe6..1b8c1c592 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -672,6 +672,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'A felhasználók csatlakoztathatják saját wanderer-példányukat, és túraként importálhatják annak túraútvonalait', + 'admin.toursHikingMap.title': 'Túratérkép (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Az OpenHikingMapet alaptérképként kínálja a túratervezőben. Nem kereskedelmi telepítéseknek havi 500 000 csempéig ingyenes; kereskedelmi vagy nagyobb telepítéshez előbb megállapodás kell az openmaps.fr-rel', 'admin.addons.noAddons': 'Nincsenek elérhető bővítmények', 'admin.weather.title': 'Időjárás adatok', 'admin.weather.badge': '2026. március 24. óta', diff --git a/shared/src/i18n/hu/map.ts b/shared/src/i18n/hu/map.ts index 1ceb46384..075a58e92 100644 --- a/shared/src/i18n/hu/map.ts +++ b/shared/src/i18n/hu/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Összes foglalási útvonal elrejtése', 'map.baseLayer.default': 'Térkép', 'map.baseLayer.satellite': 'Műhold', + 'map.baseLayer.hiking': 'Túrázás', + 'map.baseLayer.hikingDown': 'Az OpenHikingMap most nem válaszol. Válts másik térképre, a túrád változatlan marad.', 'map.baseLayer.switchToSatellite': 'Váltás műholdas nézetre', 'map.baseLayer.switchToDefault': 'Váltás térkép nézetre', 'map.location.denied': diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index de6758918..4d7b887db 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -710,6 +710,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Pengguna dapat menghubungkan instans wanderer milik sendiri dan mengimpor jalurnya sebagai tur', + 'admin.toursHikingMap.title': 'Peta pendakian (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Menawarkan OpenHikingMap sebagai peta dasar di perencana tur. Gratis untuk instalasi nonkomersial hingga 500.000 ubin per bulan; instalasi komersial atau yang lebih besar perlu perjanjian dengan openmaps.fr terlebih dahulu', 'admin.addons.noAddons': 'Tidak ada addon yang tersedia', 'admin.weather.title': 'Data Cuaca', 'admin.weather.badge': 'Sejak 24 Maret 2026', diff --git a/shared/src/i18n/id/map.ts b/shared/src/i18n/id/map.ts index ddd583b6e..de5627376 100644 --- a/shared/src/i18n/id/map.ts +++ b/shared/src/i18n/id/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Sembunyikan semua rute pemesanan', 'map.baseLayer.default': 'Peta', 'map.baseLayer.satellite': 'Satelit', + 'map.baseLayer.hiking': 'Pendakian', + 'map.baseLayer.hikingDown': 'OpenHikingMap sedang tidak merespons. Beralih ke peta lain; turmu tetap seperti semula.', 'map.baseLayer.switchToSatellite': 'Beralih ke tampilan satelit', 'map.baseLayer.switchToDefault': 'Beralih ke tampilan peta', 'map.location.denied': diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 51eeb5e14..9ae087b40 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -666,6 +666,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Gli utenti possono collegare la propria istanza wanderer e importarne i sentieri come escursioni', + 'admin.toursHikingMap.title': 'Mappa escursionistica (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + "Offre OpenHikingMap come mappa di base nel pianificatore di percorsi. Gratuito per installazioni non commerciali fino a 500.000 tile al mese; un'installazione commerciale o più grande richiede prima un accordo con openmaps.fr", 'admin.addons.noAddons': 'Nessun modulo disponibile', 'admin.weather.title': 'Dati meteo', 'admin.weather.badge': 'Dal 24 marzo 2026', diff --git a/shared/src/i18n/it/map.ts b/shared/src/i18n/it/map.ts index c3888d52e..34fbcd59c 100644 --- a/shared/src/i18n/it/map.ts +++ b/shared/src/i18n/it/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Nascondi tutti i percorsi prenotati', 'map.baseLayer.default': 'Mappa', 'map.baseLayer.satellite': 'Satellite', + 'map.baseLayer.hiking': 'Escursionismo', + 'map.baseLayer.hikingDown': + "OpenHikingMap al momento non risponde. Passa a un'altra mappa; il tuo percorso resta com'è.", 'map.baseLayer.switchToSatellite': 'Passa alla vista satellitare', 'map.baseLayer.switchToDefault': 'Passa alla vista mappa', 'map.location.denied': diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 44cf1a07a..221260a06 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -673,6 +673,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'ユーザーが自分のwandererインスタンスを接続し、そのトレイルをツアーとしてインポートできます', + 'admin.toursHikingMap.title': 'ハイキング地図 (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'ツアープランナーのベースマップとして OpenHikingMap を提供します。非商用の環境では月 500,000 タイルまで無料です。商用またはそれ以上の規模の環境では、先に openmaps.fr との合意が必要です', 'admin.addons.noAddons': '利用可能なアドオンはありません', 'admin.weather.title': '天気データ', 'admin.weather.badge': '2026年3月24日以降', diff --git a/shared/src/i18n/ja/map.ts b/shared/src/i18n/ja/map.ts index 5d813f01a..b55b41b61 100644 --- a/shared/src/i18n/ja/map.ts +++ b/shared/src/i18n/ja/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'すべての予約ルートを非表示', 'map.baseLayer.default': '地図', 'map.baseLayer.satellite': '衛星', + 'map.baseLayer.hiking': 'ハイキング', + 'map.baseLayer.hikingDown': + 'OpenHikingMap が現在応答していません。別の地図に切り替えてください。ツアーはそのまま残ります。', 'map.baseLayer.switchToSatellite': '衛星表示に切り替える', 'map.baseLayer.switchToDefault': '地図表示に切り替える', 'map.location.denied': diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 2ef9aca99..1cbceb40e 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -671,6 +671,9 @@ const admin: TranslationStrings = { 'admin.addons.subOptionsOn': '{count}개 켜짐', 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': '사용자가 자신의 wanderer 인스턴스를 연결하고 그 트레일을 투어로 가져올 수 있습니다', + 'admin.toursHikingMap.title': '하이킹 지도 (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + '투어 플래너에서 OpenHikingMap을 기본 지도로 제공합니다. 비상업적 설치는 월 500,000 타일까지 무료이며, 상업적이거나 더 큰 설치는 먼저 openmaps.fr과의 협의가 필요합니다', 'admin.addons.noAddons': '사용 가능한 애드온이 없습니다', 'admin.weather.title': '날씨 데이터', 'admin.weather.badge': '2026년 3월 24일부터', diff --git a/shared/src/i18n/ko/map.ts b/shared/src/i18n/ko/map.ts index 3deca5ff4..455bd44d2 100644 --- a/shared/src/i18n/ko/map.ts +++ b/shared/src/i18n/ko/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': '모든 예약 경로 숨기기', 'map.baseLayer.default': '지도', 'map.baseLayer.satellite': '위성', + 'map.baseLayer.hiking': '하이킹', + 'map.baseLayer.hikingDown': + 'OpenHikingMap이 지금 응답하지 않습니다. 다른 지도로 전환하세요. 투어는 그대로 유지됩니다.', 'map.baseLayer.switchToSatellite': '위성 보기로 전환', 'map.baseLayer.switchToDefault': '지도 보기로 전환', 'map.location.denied': diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index c48709807..ffd2c33ea 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -665,6 +665,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Gebruikers kunnen hun eigen wanderer-instantie koppelen en de wandelroutes daarvan als tochten importeren', + 'admin.toursHikingMap.title': 'Wandelkaart (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Biedt OpenHikingMap aan als basiskaart in de tochtplanner. Gratis voor niet-commerciële installaties tot 500.000 tegels per maand; een commerciële of grotere installatie heeft eerst een afspraak met openmaps.fr nodig', 'admin.addons.noAddons': 'Geen add-ons beschikbaar', 'admin.weather.title': 'Weergegevens', 'admin.weather.badge': 'Sinds 24 maart 2026', diff --git a/shared/src/i18n/nl/map.ts b/shared/src/i18n/nl/map.ts index 61456539d..ecb583e10 100644 --- a/shared/src/i18n/nl/map.ts +++ b/shared/src/i18n/nl/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Alle boekingsroutes verbergen', 'map.baseLayer.default': 'Kaart', 'map.baseLayer.satellite': 'Satelliet', + 'map.baseLayer.hiking': 'Wandelen', + 'map.baseLayer.hikingDown': + 'OpenHikingMap reageert op dit moment niet. Schakel over naar een andere kaart; je tocht blijft zoals hij is.', 'map.baseLayer.switchToSatellite': 'Overschakelen naar satellietweergave', 'map.baseLayer.switchToDefault': 'Overschakelen naar kaartweergave', 'map.location.denied': diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 1b94aa37d..dd3220942 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -685,6 +685,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Użytkownicy mogą połączyć własną instancję wanderer i importować z niej szlaki jako wycieczki', + 'admin.toursHikingMap.title': 'Mapa turystyczna (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Udostępnia OpenHikingMap jako mapę bazową w planerze tras. Bezpłatnie dla niekomercyjnych instalacji do 500 000 kafelków miesięcznie; instalacja komercyjna lub większa wymaga najpierw porozumienia z openmaps.fr', 'admin.addons.noAddons': 'Brak dostępnych dodatków', 'admin.weather.title': 'Dane pogodowe', 'admin.weather.badge': 'Od 24 marca 2026', diff --git a/shared/src/i18n/pl/map.ts b/shared/src/i18n/pl/map.ts index 199045c7a..0310c97a7 100644 --- a/shared/src/i18n/pl/map.ts +++ b/shared/src/i18n/pl/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Ukryj wszystkie trasy rezerwacji', 'map.baseLayer.default': 'Mapa', 'map.baseLayer.satellite': 'Satelita', + 'map.baseLayer.hiking': 'Turystyka', + 'map.baseLayer.hikingDown': + 'OpenHikingMap w tej chwili nie odpowiada. Przełącz na inną mapę; twoja trasa pozostaje bez zmian.', 'map.baseLayer.switchToSatellite': 'Przełącz na widok satelitarny', 'map.baseLayer.switchToDefault': 'Przełącz na widok mapy', 'map.location.denied': diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 0847ff2eb..698f178aa 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -690,6 +690,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Пользователи могут подключить свой экземпляр wanderer и импортировать его маршруты как туры', + 'admin.toursHikingMap.title': 'Туристическая карта (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Предлагает OpenHikingMap как базовую карту в планировщике маршрутов. Бесплатно для некоммерческих установок до 500 000 тайлов в месяц; коммерческой или более крупной установке сначала нужно соглашение с openmaps.fr', 'admin.addons.noAddons': 'Нет доступных дополнений', 'admin.weather.title': 'Данные о погоде', 'admin.weather.badge': 'С 24 марта 2026', diff --git a/shared/src/i18n/ru/map.ts b/shared/src/i18n/ru/map.ts index e321a79f4..82d7b14c6 100644 --- a/shared/src/i18n/ru/map.ts +++ b/shared/src/i18n/ru/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Скрыть все маршруты бронирований', 'map.baseLayer.default': 'Карта', 'map.baseLayer.satellite': 'Спутник', + 'map.baseLayer.hiking': 'Походы', + 'map.baseLayer.hikingDown': + 'OpenHikingMap сейчас не отвечает. Переключитесь на другую карту, ваш маршрут останется без изменений.', 'map.baseLayer.switchToSatellite': 'Переключить на спутниковый вид', 'map.baseLayer.switchToDefault': 'Переключить на вид карты', 'map.location.denied': diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index af16e655e..b6ddce271 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -737,6 +737,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Používatelia môžu pripojiť vlastnú inštanciu wanderer a importovať z nej trasy ako túry', + 'admin.toursHikingMap.title': 'Turistická mapa (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Ponúka OpenHikingMap ako podkladovú mapu v plánovači túr. Zadarmo pre nekomerčné inštalácie do 500 000 dlaždíc mesačne; komerčná alebo väčšia inštalácia potrebuje najprv dohodu s openmaps.fr', 'admin.addons.noAddons': 'Žiadne doplnky nie sú dostupné', 'admin.weather.title': 'Dáta o počasí', 'admin.weather.badge': 'Od 24. marca 2026', diff --git a/shared/src/i18n/sk/map.ts b/shared/src/i18n/sk/map.ts index 4e9d27686..8bdcff424 100644 --- a/shared/src/i18n/sk/map.ts +++ b/shared/src/i18n/sk/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Skryť všetky trasy rezervácií', 'map.baseLayer.default': 'Mapa', 'map.baseLayer.satellite': 'Satelit', + 'map.baseLayer.hiking': 'Turistika', + 'map.baseLayer.hikingDown': 'OpenHikingMap teraz neodpovedá. Prepni na inú mapu, tvoja túra zostane bez zmeny.', 'map.baseLayer.switchToSatellite': 'Prepnúť na satelitné zobrazenie', 'map.baseLayer.switchToDefault': 'Prepnúť na mapové zobrazenie', 'map.location.denied': diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 282a1874c..9c10feb49 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -732,6 +732,9 @@ const admin: TranslationStrings = { 'admin.addons.subOptionsOn.one': '{count} aktiv', 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Användare kan ansluta sin egen wanderer-instans och importera dess leder som turer', + 'admin.toursHikingMap.title': 'Vandringskarta (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Erbjuder OpenHikingMap som baskarta i turplaneraren. Gratis för icke-kommersiella installationer upp till 500 000 rutor i månaden; en kommersiell eller större installation behöver först ett avtal med openmaps.fr', 'admin.addons.noAddons': 'Inga tillägg tillgängliga', 'admin.weather.title': 'Väderdata', 'admin.weather.badge': 'Sedan den 24 mars 2026', diff --git a/shared/src/i18n/sv/map.ts b/shared/src/i18n/sv/map.ts index 07e43546d..0d4760683 100644 --- a/shared/src/i18n/sv/map.ts +++ b/shared/src/i18n/sv/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Dölj alla bokningsvägar', 'map.baseLayer.default': 'Karta', 'map.baseLayer.satellite': 'Satellit', + 'map.baseLayer.hiking': 'Vandring', + 'map.baseLayer.hikingDown': 'OpenHikingMap svarar inte just nu. Byt till en annan karta; din tur förblir som den är.', 'map.baseLayer.switchToSatellite': 'Byt till satellitvy', 'map.baseLayer.switchToDefault': 'Byt till kartvy', 'map.location.denied': diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index c26c2d934..c529a462b 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -633,6 +633,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'ผู้ใช้เชื่อมต่ออินสแตนซ์ wanderer ของตนเองและนำเข้าเส้นทางเดินจากที่นั่นเป็นทัวร์ได้', + 'admin.toursHikingMap.title': 'แผนที่เดินป่า (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'เสนอ OpenHikingMap เป็นแผนที่ฐานในตัววางแผนทัวร์ ใช้ฟรีสำหรับการติดตั้งที่ไม่ใช่เชิงพาณิชย์ได้ถึง 500,000 ไทล์ต่อเดือน การติดตั้งเชิงพาณิชย์หรือขนาดใหญ่กว่าต้องทำข้อตกลงกับ openmaps.fr ก่อน', 'admin.addons.noAddons': 'ไม่มีส่วนเสริมให้ใช้งาน', 'admin.weather.title': 'ข้อมูลสภาพอากาศ', 'admin.weather.badge': 'ตั้งแต่วันที่ 24 มีนาคม 2569', diff --git a/shared/src/i18n/th/map.ts b/shared/src/i18n/th/map.ts index 0c6428a71..ded5d2567 100644 --- a/shared/src/i18n/th/map.ts +++ b/shared/src/i18n/th/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'ซ่อนเส้นทางการจองทั้งหมด', 'map.baseLayer.default': 'แผนที่', 'map.baseLayer.satellite': 'ดาวเทียม', + 'map.baseLayer.hiking': 'เดินป่า', + 'map.baseLayer.hikingDown': 'OpenHikingMap ไม่ตอบสนองในขณะนี้ เปลี่ยนไปใช้แผนที่อื่น ทัวร์ของคุณยังคงเหมือนเดิม', 'map.baseLayer.switchToSatellite': 'สลับไปที่มุมมองดาวเทียม', 'map.baseLayer.switchToDefault': 'สลับไปที่มุมมองแผนที่', 'map.location.denied': diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 7944781e3..e835570b3 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -721,6 +721,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Kullanıcılar kendi wanderer sunucularını bağlayıp parkurlarını tur olarak içe aktarabilir', + 'admin.toursHikingMap.title': 'Yürüyüş haritası (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + "OpenHikingMap'i tur planlayıcısında temel harita olarak sunar. Ticari olmayan kurulumlar için ayda 500.000 karoya kadar ücretsizdir; ticari veya daha büyük bir kurulum önce openmaps.fr ile anlaşma gerektirir", 'admin.addons.noAddons': 'Kullanılabilir eklenti yok', 'admin.weather.title': 'Hava Durumu Verileri', 'admin.weather.badge': "24 Mart 2026'dan beri", diff --git a/shared/src/i18n/tr/map.ts b/shared/src/i18n/tr/map.ts index a5cf36f13..840cc09fe 100644 --- a/shared/src/i18n/tr/map.ts +++ b/shared/src/i18n/tr/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Tüm rezervasyon rotalarını gizle', 'map.baseLayer.default': 'Harita', 'map.baseLayer.satellite': 'Uydu', + 'map.baseLayer.hiking': 'Yürüyüş', + 'map.baseLayer.hikingDown': 'OpenHikingMap şu anda yanıt vermiyor. Başka bir haritaya geç; turun olduğu gibi kalır.', 'map.baseLayer.switchToSatellite': 'Uydu görünümüne geç', 'map.baseLayer.switchToDefault': 'Harita görünümüne geç', 'map.location.denied': diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 54a668a1b..74ebb1143 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -687,6 +687,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Користувачі можуть підключити власний екземпляр wanderer та імпортувати його маршрути як тури', + 'admin.toursHikingMap.title': 'Туристична карта (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Пропонує OpenHikingMap як базову карту в планувальнику маршрутів. Безкоштовно для некомерційних установок до 500 000 тайлів на місяць; комерційній або більшій установці спершу потрібна домовленість з openmaps.fr', 'admin.addons.noAddons': 'Немає доступних доповнень', 'admin.weather.title': 'Дані про погоду', 'admin.weather.badge': 'З 24 березня 2026', diff --git a/shared/src/i18n/uk/map.ts b/shared/src/i18n/uk/map.ts index 7b14b1ace..a86040c23 100644 --- a/shared/src/i18n/uk/map.ts +++ b/shared/src/i18n/uk/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Приховати всі маршрути бронювань', 'map.baseLayer.default': 'Карта', 'map.baseLayer.satellite': 'Супутник', + 'map.baseLayer.hiking': 'Походи', + 'map.baseLayer.hikingDown': + 'OpenHikingMap зараз не відповідає. Перемкніться на іншу карту, ваш маршрут залишиться без змін.', 'map.baseLayer.switchToSatellite': 'Перемкнути на супутниковий вигляд', 'map.baseLayer.switchToDefault': 'Перемкнути на вигляд карти', 'map.location.denied': diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index aa32fe283..3175d1f44 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -723,6 +723,9 @@ const admin: TranslationStrings = { 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': 'Người dùng có thể kết nối máy chủ wanderer riêng và nhập các cung đường của máy chủ đó thành chuyến tham quan', + 'admin.toursHikingMap.title': 'Bản đồ đi bộ đường dài (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + 'Cung cấp OpenHikingMap làm bản đồ nền trong trình lập kế hoạch chuyến đi. Miễn phí cho các bản cài đặt phi thương mại đến 500.000 ô bản đồ mỗi tháng; bản cài đặt thương mại hoặc lớn hơn cần thỏa thuận với openmaps.fr trước', 'admin.addons.noAddons': 'Không có tiện ích bổ sung nào', 'admin.weather.title': 'Dữ liệu thời tiết', 'admin.weather.badge': 'Kể từ ngày 24 tháng 3 năm 2026', diff --git a/shared/src/i18n/vi/map.ts b/shared/src/i18n/vi/map.ts index 54f6fd33e..3cde37a16 100644 --- a/shared/src/i18n/vi/map.ts +++ b/shared/src/i18n/vi/map.ts @@ -18,6 +18,9 @@ const map: TranslationStrings = { 'map.hideAllConnections': 'Ẩn tất cả lộ trình đặt chỗ', 'map.baseLayer.default': 'Bản đồ', 'map.baseLayer.satellite': 'Vệ tinh', + 'map.baseLayer.hiking': 'Đi bộ đường dài', + 'map.baseLayer.hikingDown': + 'OpenHikingMap hiện không phản hồi. Hãy chuyển sang bản đồ khác; chuyến đi của bạn vẫn giữ nguyên.', 'map.baseLayer.switchToSatellite': 'Chuyển sang chế độ xem vệ tinh', 'map.baseLayer.switchToDefault': 'Chuyển sang chế độ xem bản đồ', 'map.location.denied': diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 05f8014e0..70c90782e 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -645,6 +645,9 @@ const admin: TranslationStrings = { 'admin.addons.subOptionsOn': '已開啟 {count} 個', 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': '使用者可以連結自己的 wanderer 執行個體,並將其中的步道匯入為遊程', + 'admin.toursHikingMap.title': '健行地圖 (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + '在路線規劃器中提供 OpenHikingMap 作為底圖。非商業安裝每月 500,000 個圖磚以內免費;商業或更大規模的安裝需要先與 openmaps.fr 達成協議', 'admin.addons.noAddons': '暫無可用擴充套件', 'admin.weather.title': '天氣資料', 'admin.weather.badge': '自 2026 年 3 月 24 日起', diff --git a/shared/src/i18n/zh-TW/map.ts b/shared/src/i18n/zh-TW/map.ts index 986c4452f..f47874878 100644 --- a/shared/src/i18n/zh-TW/map.ts +++ b/shared/src/i18n/zh-TW/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': '隱藏所有預訂路線', 'map.baseLayer.default': '地圖', 'map.baseLayer.satellite': '衛星', + 'map.baseLayer.hiking': '健行', + 'map.baseLayer.hikingDown': 'OpenHikingMap 目前沒有回應。請切換到其他地圖,你的路線保持不變。', 'map.baseLayer.switchToSatellite': '切換到衛星檢視', 'map.baseLayer.switchToDefault': '切換到地圖檢視', 'map.location.denied': '定位權限已被封鎖。請檢查裝置設定,已安裝的應用程式擁有與瀏覽器分開的定位權限。', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 0ccb7bef2..dc1d6d363 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -595,6 +595,9 @@ const admin: TranslationStrings = { 'admin.addons.subOptionsOn': '已开启 {count} 个', 'admin.toursWanderer.title': 'wanderer', 'admin.toursWanderer.subtitle': '用户可以连接自己的 wanderer 实例,并将其中的步道作为游览路线导入', + 'admin.toursHikingMap.title': '徒步地图 (OpenHikingMap)', + 'admin.toursHikingMap.subtitle': + '在路线规划器中提供 OpenHikingMap 作为底图。非商业安装每月 500,000 个瓦片以内免费;商业或更大规模的安装需要先与 openmaps.fr 达成协议', 'admin.addons.noAddons': '暂无可用扩展', 'admin.weather.title': '天气数据', 'admin.weather.badge': '自 2026 年 3 月 24 日起', diff --git a/shared/src/i18n/zh/map.ts b/shared/src/i18n/zh/map.ts index ce4e16d9e..e4a1bfb69 100644 --- a/shared/src/i18n/zh/map.ts +++ b/shared/src/i18n/zh/map.ts @@ -18,6 +18,8 @@ const map: TranslationStrings = { 'map.hideAllConnections': '隐藏所有预订路线', 'map.baseLayer.default': '地图', 'map.baseLayer.satellite': '卫星', + 'map.baseLayer.hiking': '徒步', + 'map.baseLayer.hikingDown': 'OpenHikingMap 当前无响应。请切换到其他地图,你的路线保持不变。', 'map.baseLayer.switchToSatellite': '切换到卫星视图', 'map.baseLayer.switchToDefault': '切换到地图视图', 'map.location.denied': '定位权限已被阻止。请检查设备设置,已安装的应用拥有与浏览器分开的定位权限。',