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': '定位权限已被阻止。请检查设备设置,已安装的应用拥有与浏览器分开的定位权限。',