mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +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 {
|
||||
|
||||
Reference in New Issue
Block a user