mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(tours): OpenHikingMap as a hiking base map of the tour planner (MAP-01)
While a hike is planned, the planner's map switch offers "Hiking" beside the map, topo and satellite: OpenHikingMap, with marked trails over the terrain. It is a choice of the planner's view alone, like topo: the global map setting, the tour and its days stay as they are. With no tour open, over an import viewed as it came in and for a paddle tour it is not offered, and a choice made earlier falls back to the standard map there. openmaps.fr's tile usage policy is followed: tiles load only as the map shows them, with the browser's Referer, from the server's own caching; no service worker rule keeps them and the 3D flyover's warm-up passes their source by. Its credit and donation link are on the map; the 3D map, whose attribution is folded away under the panel, shows them as a line of its own above its controls. The service is best effort. A run of failed tiles while it is shown marks the switch, with a tooltip and a status for screen readers that say it is not answering and that the tour is untouched; one tile arriving clears it. The tile health the seamark overlay had is now useTileHealth, shared by both. The source is free for non-commercial installs up to 500,000 tiles a month. tours_hiking_map_enabled is an admin switch under Tours (admin tours-hiking-map, audited), on by default, for an install that is commercial or larger to switch off until it has an agreement with the operator. /api/addons carries it as toursHikingMap, true only while Tours is on. Leaflet, GL and the 3D map read one table of the planner's raster bases (tourRasterBases), so topo and the hiking map are drawn the same way. tile.openmaps.fr is on the CSP's connect-src for the GL maps.
This commit is contained in:
@@ -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<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-hiking-map').then((r) => r.data),
|
||||
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
|
||||
apiClient.put('/admin/tours-hiking-map', { enabled }).then((r) => r.data),
|
||||
};
|
||||
@@ -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 (
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
<>
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
<AddonSubRow
|
||||
icon={<Footprints size={14} />}
|
||||
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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Addon[]>([]);
|
||||
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<unknown>
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'satellite'] }} />)
|
||||
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'hiking', 'satellite'] }} />)
|
||||
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(<TourMapLayerSwitcher active="hiking" onChange={() => {}} 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(<TourMapLayerSwitcher active="default" onChange={() => {}} 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(<MapLayerSwitcher active="default" onToggle={() => {}} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import { Anchor, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -9,9 +9,10 @@ import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
import type { TileHealth, TourRasterBase } from './tourRasterBases'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | 'topo'
|
||||
export type TourBaseLayer = BaseLayer | TourRasterBase
|
||||
|
||||
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
|
||||
export interface TourLayerOffer {
|
||||
@@ -19,6 +20,8 @@ export interface TourLayerOffer {
|
||||
overlays?: readonly ToggleableRasterOverlay[]
|
||||
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
|
||||
paddle?: PaddleMapLayer
|
||||
/** MAP-01: whether OpenHikingMap answers while it is the base map; the maps report into it. */
|
||||
hikingMap?: TileHealth
|
||||
}
|
||||
|
||||
const FROSTED_SHELL = {
|
||||
@@ -83,6 +86,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
const TOUR_LAYERS = [
|
||||
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
|
||||
{ id: 'topo', labelKey: null, Icon: Mountain },
|
||||
{ id: 'hiking', labelKey: 'map.baseLayer.hiking', Icon: Footprints },
|
||||
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
|
||||
] as const
|
||||
|
||||
@@ -199,6 +203,15 @@ const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; down
|
||||
* once its source stopped answering, which the tooltip names. The map and the
|
||||
* planning go on without it.
|
||||
*/
|
||||
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
|
||||
function DownBadge() {
|
||||
return (
|
||||
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
|
||||
!
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const look = OVERLAY_LOOK[overlay.id]
|
||||
@@ -221,11 +234,7 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
>
|
||||
<look.Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{down && (
|
||||
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
||||
@@ -265,14 +274,17 @@ export function TourMapLayerSwitcher({
|
||||
const { t } = useTranslation()
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
// OpenHikingMap is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||
const hikingDown = active === 'hiking' && !!layers?.hikingMap?.down
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = labelKey ? t(labelKey) : 'Topo'
|
||||
const down = id === 'hiking' && hikingDown
|
||||
return (
|
||||
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
|
||||
<Tooltip key={id} label={down ? t('map.baseLayer.hikingDown') : label} placement="top" disabled={!compact && !down}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
@@ -283,10 +295,12 @@ export function TourMapLayerSwitcher({
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{hikingDown && <span role="status" aria-live="polite" className="sr-only">{t('map.baseLayer.hikingDown')}</span>}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
|
||||
@@ -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(<MapView {...props('hiking')} />)
|
||||
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(<MapView {...props('satellite')} />)
|
||||
const satelliteMap = screen.getByTestId('map-container')
|
||||
expect(satelliteMap).not.toBe(topoMap)
|
||||
|
||||
@@ -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) => (
|
||||
<Marker
|
||||
@@ -1116,17 +1117,16 @@ export const MapView = memo(function MapView({
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : isTopo ? (
|
||||
) : tourRaster ? (
|
||||
<TileLayer
|
||||
key="topo"
|
||||
url={OPENTOPOMAP_TILE_URL}
|
||||
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
|
||||
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
|
||||
key={baseLayer}
|
||||
url={tourRaster.url}
|
||||
attribution={tourRaster.attribution}
|
||||
maxNativeZoom={tourRaster.maxZoom}
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
keepBuffer={8} updateWhenZooming={false} updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
eventHandlers={baseLayer === 'hiking' && tourLayers?.hikingMap ? { tileerror: tourLayers.hikingMap.onTileError, tileload: tourLayers.hikingMap.onTileLoad } : undefined}
|
||||
/>
|
||||
) : basemap.kind === 'vector' ? (
|
||||
<VectorBasemap style={basemap.style} />
|
||||
|
||||
@@ -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<any | null>(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 (
|
||||
|
||||
@@ -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<string, Record<string, unknown>>();
|
||||
const layers = new Map<string, string>();
|
||||
const map = {
|
||||
getSource: vi.fn((id: string) => sources.get(id)),
|
||||
addSource: vi.fn((id: string, source: Record<string, unknown>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof applySatellite>[0], baseLayer: string): void {
|
||||
applySatellite(map, baseLayer === 'satellite');
|
||||
for (const raster of TOUR_RASTER_BASE_IDS) applySatellite(map, baseLayer === raster, raster);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<TourRasterBase, TourRasterBaseSource> = {
|
||||
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;
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET,
|
||||
}}
|
||||
>
|
||||
{baseLayer === 'hiking' && <HikingMapCredit />}
|
||||
{legendAbove && legend}
|
||||
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
|
||||
<div className="flex-none">
|
||||
@@ -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 (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full px-3 py-1 text-content-faint"
|
||||
style={{ ...GLASS, ...fs(10) }}
|
||||
data-testid="hiking-map-credit"
|
||||
>
|
||||
<a className={link} href="https://wiki.openstreetmap.org/wiki/OpenHikingMap" target="_blank" rel="noreferrer">
|
||||
© OpenHikingMap
|
||||
</a>
|
||||
<span aria-hidden>|</span>
|
||||
<a className={link} href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
|
||||
© OpenStreetMap
|
||||
</a>
|
||||
<span aria-hidden>|</span>
|
||||
<a className={link} href="https://openmaps.fr/donate" target="_blank" rel="noreferrer">
|
||||
{'❤️'} Donation
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<MapRasterOverlay, 'id' | 'url' | 'attribution' | 'minZoom' | 'maxZoom'>,
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -28,6 +28,18 @@ export const OPENTOPOMAP_TILE_ATTRIBUTION =
|
||||
'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, SRTM | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (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 =
|
||||
'© <a href="https://wiki.openstreetmap.org/wiki/OpenHikingMap">OpenHikingMap</a> | © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> | <a href="https://openmaps.fr/donate">❤️ Donation</a>'
|
||||
export const OPENHIKINGMAP_TILE_MAXZOOM = 18
|
||||
|
||||
/**
|
||||
* The basemap for a browser that will not give MapLibre a WebGL context (#2288).
|
||||
*
|
||||
|
||||
@@ -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 && <MProviderShelf options={documentProviderOptions} />}
|
||||
{addon.id === 'tours' && addon.enabled && (
|
||||
<MSubRow
|
||||
providerIcon={WandererMark}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
subtitle={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
<>
|
||||
<MSubRow
|
||||
providerIcon={WandererMark}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
subtitle={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
<MSubRow
|
||||
icon={Footprints}
|
||||
title={t('admin.toursHikingMap.title')}
|
||||
subtitle={t('admin.toursHikingMap.subtitle')}
|
||||
enabled={toursHikingMap}
|
||||
onToggle={() => void toggleToursHikingMap()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<>
|
||||
|
||||
@@ -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<typeof useTourStageLayers>[0];
|
||||
type Ferrata = Parameters<typeof useTourStageLayers>[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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useFerrataMapLayer>;
|
||||
* 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<TourPlannerController, 'mode' | 'tourType' | 'mapBaseLayer' | 'mapOverlays' | 'toggleMapOverlay'>,
|
||||
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,
|
||||
|
||||
@@ -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<void>
|
||||
isEnabled: (id: string) => boolean
|
||||
@@ -36,6 +38,7 @@ export const useAddonStore = create<AddonState>((set, get) => ({
|
||||
addons: [],
|
||||
bagTracking: false,
|
||||
toursWanderer: false,
|
||||
toursHikingMap: false,
|
||||
loaded: false,
|
||||
|
||||
loadAddons: async () => {
|
||||
@@ -43,6 +46,7 @@ export const useAddonStore = create<AddonState>((set, get) => ({
|
||||
const data = await addonsApi.enabled()
|
||||
set({ addons: data.addons || [], bagTracking: !!data.bagTracking,
|
||||
toursWanderer: !!data.toursWanderer,
|
||||
toursHikingMap: !!data.toursHikingMap,
|
||||
loaded: true,
|
||||
})
|
||||
} catch {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
return HttpResponse.json({ enabled: body.enabled });
|
||||
}),
|
||||
|
||||
http.get('/api/admin/addons', () => {
|
||||
return HttpResponse.json({ addons: [] });
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -325,6 +325,9 @@ export function applyGlobalMiddleware(
|
||||
// Tours planner (PAD-10). Leaflet loads it as an <img>; 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
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<unknown>, 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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ç',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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ů',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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日以降',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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일부터',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 日起',
|
||||
|
||||
@@ -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': '定位權限已被封鎖。請檢查裝置設定,已安裝的應用程式擁有與瀏覽器分開的定位權限。',
|
||||
|
||||
@@ -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 日起',
|
||||
|
||||
@@ -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': '定位权限已被阻止。请检查设备设置,已安装的应用拥有与浏览器分开的定位权限。',
|
||||
|
||||
Reference in New Issue
Block a user