mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat(maps): add MapLibre OpenFreeMap support (#1317)
Adds MapLibre GL with OpenFreeMap as a tokenless third map provider alongside Leaflet and Mapbox: a provider abstraction with style presets, CSP + service-worker entries for tiles.openfreemap.org, and the map_provider allow-list entry. Mapbox-only APIs stay gated behind the mapbox provider, and existing Mapbox/Leaflet users are unaffected. Maintainer review follow-ups folded in: the new map-settings strings are translated across all locales; the GL engine is lazy-loaded so Leaflet-only installs don't download it; MapLibre gets its own maplibre_style slot so switching providers no longer overwrites a custom Mapbox style; and the MapLibre render path plus the OpenFreeMap style-guards are covered by tests.
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
||||
@@ -8,6 +8,15 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, Place } from '../../types'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
getStylePresets,
|
||||
isOpenFreeMapStyle,
|
||||
normalizeStyleForProvider,
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -28,18 +37,22 @@ type Defaults = {
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
maplibre_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
const MAPBOX_STYLE_PRESETS = [
|
||||
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
||||
]
|
||||
type MapProvider = 'leaflet' | GlMapProvider
|
||||
|
||||
function normalizeProvider(value: unknown): MapProvider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
@@ -99,10 +112,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
const provider = normalizeProvider(data.map_provider)
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(data.mapbox_style || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
@@ -123,7 +137,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'mapbox_style') setMapboxStyle('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
}
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
@@ -173,6 +190,20 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
const mapProvider = normalizeProvider(defaults.map_provider)
|
||||
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
|
||||
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
|
||||
const saveMapProvider = (nextProvider: MapProvider) => {
|
||||
const patch: Partial<Defaults> = { map_provider: nextProvider }
|
||||
if (nextProvider !== 'leaflet') {
|
||||
// Load + save the new provider's own style slot so the other provider's style is kept.
|
||||
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
|
||||
const nextStyle = styleForProvider(nextProvider, slot)
|
||||
setMapboxStyle(nextStyle)
|
||||
patch[styleSettingKey(nextProvider)] = nextStyle
|
||||
}
|
||||
save(patch)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
@@ -333,19 +364,21 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={(defaults.map_provider || 'leaflet') === opt.value}
|
||||
onClick={() => save({ map_provider: opt.value })}
|
||||
active={mapProvider === opt.value}
|
||||
onClick={() => saveMapProvider(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{defaults.map_provider === 'mapbox-gl' && (
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
@@ -363,17 +396,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field="mapbox_style" />
|
||||
<ResetButton field={styleKey} />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
@@ -381,12 +415,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||
setMapboxStyle(nextStyle)
|
||||
save({ [styleKey]: nextStyle })
|
||||
}}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
@@ -408,6 +448,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
||||
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
|
||||
import type { JourneyMapGLHandle } from './JourneyMapGL'
|
||||
|
||||
// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only
|
||||
// installs never download it — it ships only once a GL provider is picked.
|
||||
const JourneyMapGL = lazy(() => import('./JourneyMapGL'))
|
||||
|
||||
// Unified handle — both providers expose the same three methods.
|
||||
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||
@@ -37,8 +41,9 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
|
||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||
// supplied a token yet — otherwise the map would just show a stub.
|
||||
const useGL = provider === 'mapbox-gl' && !!token
|
||||
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
|
||||
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||
@@ -47,8 +52,12 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
|
||||
}), [useGL])
|
||||
|
||||
if (useGL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, type GlMapProvider } from '../Map/glProviders'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
@@ -32,6 +35,7 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
glProvider?: GlMapProvider
|
||||
}
|
||||
|
||||
interface Item {
|
||||
@@ -95,8 +99,10 @@ function ensureJourneyPopupStyle() {
|
||||
const s = document.createElement('style')
|
||||
s.id = 'trek-journey-popup-style'
|
||||
s.textContent = `
|
||||
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
||||
.mapboxgl-popup.trek-journey-popup,
|
||||
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
@@ -108,20 +114,24 @@ function ensureJourneyPopupStyle() {
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
|
||||
background: rgba(24, 24, 27, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: #FAFAFA;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
|
||||
border-top-color: rgba(255, 255, 255, 0.94);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
|
||||
border-top-color: rgba(24, 24, 27, 0.88);
|
||||
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
|
||||
.trek-journey-popup-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
@@ -132,7 +142,8 @@ function ensureJourneyPopupStyle() {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||
.trek-journey-popup-sub {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -143,7 +154,8 @@ function ensureJourneyPopupStyle() {
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||
.trek-journey-popup-place {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -194,20 +206,28 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const isMapLibre = glProvider === 'maplibre-gl'
|
||||
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
|
||||
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
|
||||
const enableMapbox3d = !isMapLibre && mapbox3d
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapRef = useRef<any | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<string, any>>(new Map())
|
||||
const itemsRef = useRef<Item[]>([])
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
const darkRef = useRef(dark)
|
||||
@@ -247,7 +267,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
const el = popupRef.current.getElement()
|
||||
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||
} else {
|
||||
popupRef.current = new mapboxgl.Popup({
|
||||
popupRef.current = new gl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
closeOnMove: false,
|
||||
@@ -260,7 +280,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
.setHTML(html)
|
||||
.addTo(mapRef.current)
|
||||
}
|
||||
}, [])
|
||||
}, [gl])
|
||||
|
||||
const hidePopup = useCallback(() => {
|
||||
if (popupRef.current) {
|
||||
@@ -305,11 +325,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 600,
|
||||
})
|
||||
} catch { /* map not yet ready */ }
|
||||
}, [highlightMarker, mapbox3d])
|
||||
}, [highlightMarker, enableMapbox3d])
|
||||
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||
@@ -320,37 +340,39 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
// Build map once per style/token change. Markers and layers are rebuilt
|
||||
// inside the same effect so they stay in sync with the active style.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
|
||||
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const items = buildItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
const bounds = new gl.LngLatBounds()
|
||||
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||||
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
const mapOptions: Record<string, unknown> = {
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
style: glStyle,
|
||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||
zoom: hasPoints ? 2 : 1,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||
if (enableMapbox3d) {
|
||||
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||
}
|
||||
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||||
// stay pinned to their coordinates at every zoom and pitch.
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
|
||||
@@ -383,7 +405,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
// markers
|
||||
items.forEach((item) => {
|
||||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.addTo(map)
|
||||
el.addEventListener('click', (ev) => {
|
||||
@@ -400,7 +422,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
map.fitBounds(bounds, {
|
||||
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||||
maxZoom: 16,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 0,
|
||||
})
|
||||
} catch { /* empty bounds */ }
|
||||
@@ -418,7 +440,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
|
||||
// external activeMarkerId → highlight + flyTo
|
||||
useEffect(() => {
|
||||
@@ -431,15 +453,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 500,
|
||||
})
|
||||
} catch { /* map not ready */ }
|
||||
}, 50)
|
||||
return () => clearTimeout(t)
|
||||
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||||
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen])
|
||||
|
||||
if (!mapboxToken) {
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigation } from 'lucide-react'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
|
||||
export interface CompassMap {
|
||||
getBearing: () => number
|
||||
on: (type: 'rotate', listener: () => void) => unknown
|
||||
off: (type: 'rotate', listener: () => void) => unknown
|
||||
easeTo: (options: { bearing: number; pitch: number; duration: number }) => unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
|
||||
* Round compass pill for the GL planner map. The map can be rotated and
|
||||
* pitched, so this shows the current bearing (the arrow points to north) and snaps
|
||||
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
|
||||
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||
* so its height and transparency match the POI pill exactly.
|
||||
*/
|
||||
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
|
||||
export function MapCompassPill({ map }: { map: CompassMap }) {
|
||||
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import { MapViewGL } from './MapViewGL'
|
||||
|
||||
// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so
|
||||
// Leaflet-only installs never download it — it ships only once a GL provider is picked.
|
||||
const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL })))
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||
//
|
||||
// Offline maps: only the Leaflet renderer supports full pre-download (raster
|
||||
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
|
||||
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
|
||||
// vector tiles are cached opportunistically by the Service Worker as you view
|
||||
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
|
||||
// them online (see the GL tile rules in vite.config.js), not prefetched.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MapViewAuto(props: any) {
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
// so trip planner never shows an empty map due to a missing token.
|
||||
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
|
||||
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
|
||||
: null
|
||||
if (glProvider) {
|
||||
// Render the previous Leaflet map as the fallback so there's no blank flash
|
||||
// while the GL chunk loads on first use.
|
||||
return (
|
||||
<Suspense fallback={<MapView {...props} />}>
|
||||
<MapViewGL {...props} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
return <MapView {...props} />
|
||||
}
|
||||
|
||||
@@ -58,6 +58,35 @@ vi.mock('mapbox-gl', () => ({
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function () {
|
||||
return {
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => document.createElement('div')),
|
||||
}
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return { extend: vi.fn().mockReturnThis() }
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return {
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
}),
|
||||
},
|
||||
}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
vi.mock('./mapboxSetup', () => ({
|
||||
isStandardFamily: vi.fn(() => false),
|
||||
supportsCustom3d: vi.fn(() => false),
|
||||
@@ -177,4 +206,25 @@ describe('MapViewGL', () => {
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const maplibregl = (await import('maplibre-gl')).default
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
map_provider: 'maplibre-gl',
|
||||
mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit
|
||||
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
},
|
||||
} as any)
|
||||
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]
|
||||
|
||||
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
|
||||
await act(async () => {})
|
||||
|
||||
// The MapLibre engine builds the map even without a token; Mapbox is not used.
|
||||
expect(maplibregl.Map).toHaveBeenCalled()
|
||||
expect(mapboxgl.Map).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
@@ -9,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, type GlMapProvider } from './glProviders'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
@@ -54,7 +57,9 @@ interface Props {
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
onMapReady?: (map: mapboxgl.Map | null) => void
|
||||
glProvider?: GlMapProvider
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onMapReady?: (map: any | null) => void
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
@@ -91,8 +96,8 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
// Do NOT set `position: relative` here — mapbox-gl ships
|
||||
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
||||
// Do NOT set `position: relative` here — GL map libraries ship
|
||||
// marker classes with `position: absolute` and rely on it. An inline
|
||||
// `position: relative` here overrides the class, turns every marker into
|
||||
// a static block element, and stacks them in document order inside the
|
||||
// canvas container. The result looks exactly like "markers drift as the
|
||||
@@ -169,29 +174,39 @@ export function MapViewGL({
|
||||
pois = [],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||
const isMapLibre = glProvider === 'maplibre-gl'
|
||||
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
|
||||
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
|
||||
const enableMapbox3d = !isMapLibre && mapbox3d
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapRef = useRef<any | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<number, any>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const poiMarkersRef = useRef<any[]>([])
|
||||
// Single reusable hover popup (name/category/address card) shared by planned
|
||||
// places and POI markers — mirrors the Leaflet map's hover tooltip.
|
||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
@@ -204,23 +219,25 @@ export function MapViewGL({
|
||||
onClickRefs.current.map = onMapClick
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
|
||||
// Build/rebuild the map on style/token/3d change
|
||||
// Build/rebuild the map on provider/style/token/3d change
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
|
||||
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
const mapOptions: Record<string, unknown> = {
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
style: glStyle,
|
||||
center: [center[1], center[0]],
|
||||
zoom,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
mapRef.current = map
|
||||
popupRef.current = new mapboxgl.Popup({
|
||||
popupRef.current = new gl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
offset: 18,
|
||||
@@ -234,12 +251,12 @@ export function MapViewGL({
|
||||
;(window as any).__trek_map = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
if (enableMapbox3d) {
|
||||
// Terrain is only valuable on satellite styles — on clean vector
|
||||
// styles it makes route lines drift off the HTML markers because
|
||||
// the lines snap to DEM height while markers stay at sea level.
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) {
|
||||
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(glStyle)) {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
addCustom3dBuildings(map, dark)
|
||||
}
|
||||
@@ -252,7 +269,7 @@ export function MapViewGL({
|
||||
// non-satellite Standard style still looks great without terrain,
|
||||
// so flatten it out to keep markers pinned. (Satellite variants
|
||||
// are left alone — the DEM is what gives them their character.)
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
@@ -298,7 +315,7 @@ export function MapViewGL({
|
||||
|
||||
map.on('click', (e) => {
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
@@ -309,7 +326,7 @@ export function MapViewGL({
|
||||
}
|
||||
map.on('moveend', emitViewport)
|
||||
map.once('idle', emitViewport)
|
||||
// In the mapbox-gl map the right mouse button is reserved for the
|
||||
// In the GL map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
const canvas = map.getCanvasContainer()
|
||||
@@ -356,7 +373,9 @@ export function MapViewGL({
|
||||
const ll = marker.getLngLat()
|
||||
let alt = 0
|
||||
try {
|
||||
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
||||
const e = typeof map.queryTerrainElevation === 'function'
|
||||
? map.queryTerrainElevation([ll.lng, ll.lat])
|
||||
: null
|
||||
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
||||
} catch { /* terrain not ready */ }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -368,7 +387,9 @@ export function MapViewGL({
|
||||
}
|
||||
})
|
||||
}
|
||||
map.on('render', syncMarkerAltitudes)
|
||||
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame
|
||||
// listener entirely for MapLibre and flat mapbox styles.
|
||||
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
@@ -389,7 +410,7 @@ export function MapViewGL({
|
||||
mapRef.current = null
|
||||
setMapReady(false)
|
||||
}
|
||||
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
||||
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
|
||||
|
||||
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
||||
// simultaneous thumb arrivals into one re-render.
|
||||
@@ -489,12 +510,12 @@ export function MapViewGL({
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map)
|
||||
markersRef.current.set(place.id, m)
|
||||
})
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
@@ -511,10 +532,10 @@ export function MapViewGL({
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
}
|
||||
}, [pois, mapReady])
|
||||
}, [pois, mapReady, glProvider])
|
||||
|
||||
// Update route geojson
|
||||
useEffect(() => {
|
||||
@@ -578,7 +599,7 @@ export function MapViewGL({
|
||||
showStats: showReservationStats,
|
||||
showEndpointLabels,
|
||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||
})
|
||||
}, gl.Marker as any)
|
||||
}
|
||||
reservationOverlayRef.current.update(visibleReservations, {
|
||||
showConnections: true,
|
||||
@@ -586,7 +607,7 @@ export function MapViewGL({
|
||||
showEndpointLabels,
|
||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||
})
|
||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
|
||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
@@ -606,14 +627,14 @@ export function MapViewGL({
|
||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||
const valid = target.filter(p => p.lat && p.lng)
|
||||
if (valid.length === 0) return
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
const bounds = new gl.LngLatBounds()
|
||||
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const run = () => {
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
@@ -632,7 +653,7 @@ export function MapViewGL({
|
||||
map.flyTo({
|
||||
center: [target.lng, target.lat],
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
// Account for the side panels and the bottom inspector / day-detail panel
|
||||
// so the selected pin lands in the centre of the *visible* map area rather
|
||||
@@ -640,7 +661,7 @@ export function MapViewGL({
|
||||
padding: paddingOpts,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// External center/zoom prop changes — jump without animation
|
||||
useEffect(() => {
|
||||
@@ -663,7 +684,7 @@ export function MapViewGL({
|
||||
}
|
||||
if (!userPosition) return
|
||||
const apply = () => {
|
||||
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
||||
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any)
|
||||
locationMarkerRef.current.update(userPosition)
|
||||
if (trackingMode === 'follow') {
|
||||
// easeTo is gentler than flyTo for continuous updates
|
||||
@@ -679,9 +700,9 @@ export function MapViewGL({
|
||||
}
|
||||
if (map.loaded()) apply()
|
||||
else map.once('load', apply)
|
||||
}, [userPosition, trackingMode])
|
||||
}, [userPosition, trackingMode, glProvider])
|
||||
|
||||
if (!mapboxToken) {
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||
<div className="text-sm text-zinc-500">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
OPENFREEMAP_DEFAULT_STYLE,
|
||||
isOpenFreeMapStyle,
|
||||
normalizeStyleForProvider,
|
||||
styleForActiveProvider,
|
||||
} from './glProviders'
|
||||
|
||||
describe('glProviders', () => {
|
||||
it('keeps OpenFreeMap styles for MapLibre', () => {
|
||||
const style = 'https://tiles.openfreemap.org/styles/bright'
|
||||
|
||||
expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style)
|
||||
})
|
||||
|
||||
it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => {
|
||||
expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe(
|
||||
OPENFREEMAP_DEFAULT_STYLE,
|
||||
)
|
||||
expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||
})
|
||||
|
||||
it('leaves Mapbox styles unchanged for Mapbox GL', () => {
|
||||
expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE)
|
||||
})
|
||||
|
||||
it('matches the OpenFreeMap CSP host', () => {
|
||||
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true)
|
||||
expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects host/userinfo spoofing and http downgrade', () => {
|
||||
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false)
|
||||
expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false)
|
||||
expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false)
|
||||
expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to provider defaults for empty/whitespace styles', () => {
|
||||
expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||
expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||
expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE)
|
||||
expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE)
|
||||
})
|
||||
|
||||
it('styleForActiveProvider reads each provider\'s own style slot', () => {
|
||||
const mb = 'mapbox://styles/me/custom'
|
||||
const ofm = 'https://tiles.openfreemap.org/styles/bright'
|
||||
expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb)
|
||||
expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm)
|
||||
// An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched.
|
||||
expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl'
|
||||
|
||||
export interface GlStylePreset {
|
||||
name: string
|
||||
url: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard'
|
||||
export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
|
||||
|
||||
export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [
|
||||
{ name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, tags: ['3D', 'Apple-like'] },
|
||||
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
|
||||
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
|
||||
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
|
||||
]
|
||||
|
||||
export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [
|
||||
{ name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] },
|
||||
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] },
|
||||
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] },
|
||||
]
|
||||
|
||||
export function getStylePresets(provider: GlMapProvider): GlStylePreset[] {
|
||||
return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS
|
||||
}
|
||||
|
||||
export function defaultStyleForProvider(provider: GlMapProvider): string {
|
||||
return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE
|
||||
}
|
||||
|
||||
export function isOpenFreeMapStyle(style?: string | null): boolean {
|
||||
return (style || '').trim().startsWith('https://tiles.openfreemap.org/')
|
||||
}
|
||||
|
||||
export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string {
|
||||
const trimmed = (style || '').trim()
|
||||
if (!trimmed) return defaultStyleForProvider(provider)
|
||||
if (provider === 'maplibre-gl') {
|
||||
return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** The settings key that holds the style for a given GL provider. */
|
||||
export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' {
|
||||
return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
|
||||
}
|
||||
|
||||
/**
|
||||
* Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so
|
||||
* switching providers never overwrites the other one's custom style. Picks and normalizes
|
||||
* the style for the active provider.
|
||||
*/
|
||||
export function styleForActiveProvider(
|
||||
provider: GlMapProvider,
|
||||
mapboxStyle?: string | null,
|
||||
maplibreStyle?: string | null,
|
||||
): string {
|
||||
return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle)
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import type { GeoPosition } from '../../hooks/useGeolocation'
|
||||
|
||||
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => {
|
||||
setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown }
|
||||
addTo: (map: mapboxgl.Map) => unknown
|
||||
remove: () => void
|
||||
getElement: () => HTMLElement
|
||||
}
|
||||
|
||||
// Build the DOM element that backs the mapbox Marker. We animate the
|
||||
// heading cone via a CSS rotation so the DOM stays stable across updates
|
||||
// and mapbox doesn't get confused about which element to position.
|
||||
@@ -66,10 +73,10 @@ export interface LocationMarkerHandle {
|
||||
// mapbox map. Returns a handle the caller uses to push position updates
|
||||
// and clean up. Keeps its own DOM element and GeoJSON source so it can
|
||||
// coexist with the regular trip markers.
|
||||
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
|
||||
export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle {
|
||||
ensurePulseStyle()
|
||||
const { root, cone } = buildLocationEl()
|
||||
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
|
||||
const marker = new MarkerCtor({ element: root, anchor: 'center' })
|
||||
|
||||
const ensureAccuracyLayer = () => {
|
||||
if (map.getSource('trek-location-accuracy')) return
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
@@ -220,18 +220,29 @@ export interface ReservationOverlayOptions {
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
}
|
||||
|
||||
type GlMarker = {
|
||||
setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker
|
||||
addTo: (map: mapboxgl.Map) => GlMarker
|
||||
remove: () => void
|
||||
getElement: () => HTMLElement
|
||||
}
|
||||
|
||||
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker
|
||||
|
||||
export class ReservationMapboxOverlay {
|
||||
private map: mapboxgl.Map
|
||||
private items: TransportItem[] = []
|
||||
private opts: ReservationOverlayOptions
|
||||
private endpointMarkers: mapboxgl.Marker[] = []
|
||||
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
|
||||
private MarkerCtor: MarkerConstructor
|
||||
private endpointMarkers: GlMarker[] = []
|
||||
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
|
||||
private rerender: () => void
|
||||
private destroyed = false
|
||||
|
||||
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
|
||||
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) {
|
||||
this.map = map
|
||||
this.opts = opts
|
||||
this.MarkerCtor = MarkerCtor
|
||||
this.rerender = () => { if (!this.destroyed) this.render() }
|
||||
this.setupLayer()
|
||||
map.on('zoomend', this.rerender)
|
||||
@@ -350,7 +361,7 @@ export class ReservationMapboxOverlay {
|
||||
this.opts.onEndpointClick?.(item.res.id)
|
||||
})
|
||||
}
|
||||
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
|
||||
const marker = new this.MarkerCtor({ element: node, anchor: 'center' })
|
||||
.setLngLat([ep.lng, ep.lat])
|
||||
.addTo(map)
|
||||
this.endpointMarkers.push(marker)
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react'
|
||||
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import MapboxPreview from './MapboxPreview'
|
||||
import GlMapPreview from './MapboxPreview'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import type { Place } from '../../types'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
getStylePresets,
|
||||
isOpenFreeMapStyle,
|
||||
normalizeStyleForProvider,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
@@ -23,25 +31,6 @@ const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
interface StylePreset {
|
||||
name: string
|
||||
url: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const MAPBOX_STYLE_PRESETS: StylePreset[] = [
|
||||
{ name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] },
|
||||
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
|
||||
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
|
||||
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
|
||||
]
|
||||
|
||||
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
|
||||
// user scanning the list can spot 3D / Satellite / Apple-like styles.
|
||||
const TAG_STYLES: Record<string, string> = {
|
||||
@@ -59,6 +48,7 @@ const TAG_STYLES: Record<string, string> = {
|
||||
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
|
||||
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||
'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
}
|
||||
|
||||
function TagChip({ tag }: { tag: string }) {
|
||||
@@ -70,10 +60,11 @@ function TagChip({ tag }: { tag: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const presets = getStylePresets(provider)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -84,7 +75,10 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [open])
|
||||
|
||||
const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value)
|
||||
const selected = presets.find(p => p.url === value)
|
||||
const placeholder = provider === 'maplibre-gl'
|
||||
? t('settings.mapOpenFreeMapStylePlaceholder')
|
||||
: t('settings.mapStylePlaceholder')
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
@@ -95,11 +89,11 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-900 dark:text-white truncate">
|
||||
{selected ? selected.name : t('settings.mapStylePlaceholder')}
|
||||
{selected ? selected.name : placeholder}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
{selected.tags.map(t => <TagChip key={t} tag={t} />)}
|
||||
{(selected.tags || []).map(t => <TagChip key={t} tag={t} />)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -107,7 +101,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
|
||||
{MAPBOX_STYLE_PRESETS.map(preset => {
|
||||
{presets.map(preset => {
|
||||
const isActive = preset.url === value
|
||||
return (
|
||||
<button
|
||||
@@ -118,7 +112,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
|
||||
{preset.tags.map(t => <TagChip key={t} tag={t} />)}
|
||||
{(preset.tags || []).map(t => <TagChip key={t} tag={t} />)}
|
||||
</span>
|
||||
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
|
||||
</button>
|
||||
@@ -130,17 +124,34 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
||||
)
|
||||
}
|
||||
|
||||
type Provider = 'leaflet' | 'mapbox-gl'
|
||||
type Provider = 'leaflet' | GlMapProvider
|
||||
|
||||
function normalizeProvider(value: unknown): Provider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
function styleForProvider(provider: Provider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
|
||||
// Each GL provider has its own style slot, so toggling providers never clobbers the
|
||||
// other one's style. Leaflet/Mapbox use mapbox_style; MapLibre uses maplibre_style.
|
||||
function slotStyle(provider: Provider, s: { mapbox_style?: string; maplibre_style?: string }): string | undefined {
|
||||
return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style
|
||||
}
|
||||
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const initialProvider = normalizeProvider(settings.map_provider)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet')
|
||||
const [provider, setProvider] = useState<Provider>(initialProvider)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
@@ -148,10 +159,11 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
useEffect(() => {
|
||||
setProvider((settings.map_provider as Provider) || 'leaflet')
|
||||
const nextProvider = normalizeProvider(settings.map_provider)
|
||||
setProvider(nextProvider)
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setMapboxToken(settings.mapbox_access_token || '')
|
||||
setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
|
||||
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
||||
setMapboxQuality(settings.mapbox_quality_mode === true)
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
@@ -186,11 +198,15 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const saveMapSettings = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle)
|
||||
setMapboxStyle(glStyle)
|
||||
// Save into the active provider's own slot so the other provider's style survives.
|
||||
const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle }
|
||||
await updateSettings({
|
||||
map_provider: provider,
|
||||
map_tile_url: mapTileUrl,
|
||||
mapbox_access_token: mapboxToken,
|
||||
mapbox_style: mapboxStyle,
|
||||
...stylePatch,
|
||||
mapbox_3d_enabled: mapbox3d,
|
||||
mapbox_quality_mode: mapboxQuality,
|
||||
default_lat: parseFloat(String(defaultLat)),
|
||||
@@ -208,16 +224,20 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
// 3D is available on every style now — pure satellite uses the
|
||||
// mapbox-streets-v8 tileset as a fallback building source.
|
||||
const supports3d = true
|
||||
const changeProvider = (nextProvider: Provider) => {
|
||||
setProvider(nextProvider)
|
||||
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
{/* Provider picker — big cards so the choice is obvious */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvider('leaflet')}
|
||||
onClick={() => changeProvider('leaflet')}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'leaflet'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
@@ -232,7 +252,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvider('mapbox-gl')}
|
||||
onClick={() => changeProvider('mapbox-gl')}
|
||||
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'mapbox-gl'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
@@ -252,6 +272,24 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeProvider('maplibre-gl')}
|
||||
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'maplibre-gl'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Globe2 size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
<span className="sm:hidden">MapLibre</span>
|
||||
<span className="hidden sm:inline">MapLibre GL</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
{t('settings.mapProviderHint')}
|
||||
@@ -281,9 +319,10 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mapbox GL settings */}
|
||||
{provider === 'mapbox-gl' && (
|
||||
{/* GL settings */}
|
||||
{provider !== 'leaflet' && (
|
||||
<div className="space-y-3">
|
||||
{provider === 'mapbox-gl' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
||||
<input
|
||||
@@ -300,24 +339,27 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
|
||||
<div className="mb-2">
|
||||
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
|
||||
<StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e) => setMapboxStyle(e.target.value)}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
placeholder={defaultStyleForProvider(provider)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{t('settings.mapStyleHint')}
|
||||
{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{provider === 'mapbox-gl' && (
|
||||
<>
|
||||
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
supports3d
|
||||
? 'border-slate-200 dark:border-slate-700'
|
||||
@@ -354,6 +396,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -383,8 +427,9 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{provider === 'mapbox-gl' ? (
|
||||
<MapboxPreview
|
||||
{provider !== 'leaflet' ? (
|
||||
<GlMapPreview
|
||||
provider={provider}
|
||||
token={mapboxToken}
|
||||
style={mapboxStyle}
|
||||
lat={parseFloat(String(defaultLat)) || 48.8566}
|
||||
@@ -392,8 +437,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
// Zoom in close so the style's character (3D buildings,
|
||||
// satellite texture, label density) is immediately visible.
|
||||
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
|
||||
enable3d={mapbox3d && supports3d}
|
||||
quality={mapboxQuality}
|
||||
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
|
||||
quality={provider === 'mapbox-gl' && mapboxQuality}
|
||||
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
|
||||
|
||||
interface Props {
|
||||
token: string
|
||||
provider?: GlMapProvider
|
||||
token?: string
|
||||
style: string
|
||||
lat: number
|
||||
lng: number
|
||||
@@ -14,37 +18,44 @@ interface Props {
|
||||
onClick?: (latlng: { lat: number; lng: number }) => void
|
||||
}
|
||||
|
||||
export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
||||
export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const onClickRef = useRef(onClick)
|
||||
onClickRef.current = onClick
|
||||
const isMapLibre = provider === 'maplibre-gl'
|
||||
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
|
||||
const glStyle = normalizeStyleForProvider(provider, style)
|
||||
const enableMapbox3d = !isMapLibre && enable3d
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !token) return
|
||||
mapboxgl.accessToken = token
|
||||
if (!containerRef.current || (!isMapLibre && !token)) return
|
||||
if (!isMapLibre) mapboxgl.accessToken = token
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
const mapOptions: Record<string, unknown> = {
|
||||
container: containerRef.current,
|
||||
style,
|
||||
style: glStyle,
|
||||
center: [lng, lat],
|
||||
zoom,
|
||||
pitch: enable3d ? 45 : 0,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: quality,
|
||||
projection: quality ? 'globe' : 'mercator',
|
||||
})
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (enable3d) {
|
||||
if (!isStandardFamily(style)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(style)) {
|
||||
if (enableMapbox3d) {
|
||||
if (!isStandardFamily(glStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(glStyle)) {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
addCustom3dBuildings(map, dark)
|
||||
}
|
||||
}
|
||||
if (style === 'mapbox://styles/mapbox/standard') {
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
})
|
||||
@@ -57,7 +68,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d,
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [token, style, enable3d, quality])
|
||||
}, [provider, token, glStyle, enableMapbox3d, quality])
|
||||
|
||||
// Recenter without rebuilding the map when lat/lng/zoom change externally
|
||||
useEffect(() => {
|
||||
@@ -65,7 +76,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d,
|
||||
try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
|
||||
}, [lat, lng, zoom])
|
||||
|
||||
if (!token) {
|
||||
if (!isMapLibre && !token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
Enter a Mapbox access token to preview
|
||||
|
||||
@@ -35,17 +35,19 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Mapbox GL hover popup — the name/category/address card on marker hover.
|
||||
/* GL hover popup — the name/category/address card on marker hover.
|
||||
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
|
||||
onto the popup never steals the marker's mouseleave and causes flicker. */
|
||||
.trek-map-popup { pointer-events: none; }
|
||||
.trek-map-popup .mapboxgl-popup-content {
|
||||
.trek-map-popup .mapboxgl-popup-content,
|
||||
.trek-map-popup .maplibregl-popup-content {
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
.trek-map-popup .mapboxgl-popup-tip {
|
||||
.trek-map-popup .mapboxgl-popup-tip,
|
||||
.trek-map-popup .maplibregl-popup-tip {
|
||||
border-top-color: #fff;
|
||||
border-bottom-color: #fff;
|
||||
border-left-color: #fff;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTripStore } from '../store/tripStore'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill } from '../components/Map/MapCompassPill'
|
||||
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
@@ -211,7 +211,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
} = useTripPlanner()
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||
const [glMap, setGlMap] = useState<CompassMap | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
|
||||
@@ -38,6 +38,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
map_poi_pill_enabled: true,
|
||||
mapbox_access_token: '',
|
||||
mapbox_style: 'mapbox://styles/mapbox/standard',
|
||||
maplibre_style: '',
|
||||
mapbox_3d_enabled: true,
|
||||
mapbox_quality_mode: false,
|
||||
},
|
||||
|
||||
+2
-1
@@ -118,9 +118,10 @@ export interface Settings {
|
||||
map_booking_labels?: boolean
|
||||
map_poi_pill_enabled?: boolean
|
||||
optimize_from_accommodation?: boolean
|
||||
map_provider?: 'leaflet' | 'mapbox-gl'
|
||||
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
maplibre_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ export default defineConfig({
|
||||
cacheableResponse: { statuses: [200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// OpenFreeMap MapLibre style, glyphs, sprites and vector tiles.
|
||||
// Same best-effort offline model as Mapbox GL: viewed resources are
|
||||
// reused from cache, but the vector tile pipeline is not prefetched.
|
||||
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'openfreemap-tiles',
|
||||
expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// API calls — network only. We deliberately do NOT cache API
|
||||
// responses in the Service Worker: Workbox keys entries by URL and
|
||||
|
||||
Reference in New Issue
Block a user