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:
Azalea
2026-06-26 19:54:48 +02:00
committed by Maurice
parent 87f2ed654b
commit 9886ed732c
65 changed files with 907 additions and 191 deletions
+1
View File
@@ -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)} />
+57 -35
View File
@@ -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' }}
+10 -4
View File
@@ -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(() => {
+19 -4
View File
@@ -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()
})
})
+60 -39
View File
@@ -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)
})
})
+68
View File
@@ -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
+5 -3
View File
@@ -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;
+2 -2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
}
+12
View File
@@ -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