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 7531badbe8
commit 9669642c62
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
+252 -2
View File
@@ -13,7 +13,8 @@
"shared"
],
"devDependencies": {
"concurrently": "^10.0.3"
"concurrently": "^10.0.3",
"unrun": "^0.3.1"
},
"optionalDependencies": {
"@img/sharp-linuxmusl-arm64": "0.35.1",
@@ -37,6 +38,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",
@@ -3918,6 +3920,119 @@
"node": ">=8"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
"integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -6719,7 +6834,6 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
@@ -9582,6 +9696,12 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -11098,6 +11218,12 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -12568,6 +12694,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -12645,6 +12777,12 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -13266,6 +13404,40 @@
"test/build/typings"
]
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -14529,6 +14701,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -15155,6 +15333,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -15461,6 +15651,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -15710,6 +15906,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -16038,6 +16240,12 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -16585,6 +16793,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
@@ -18263,6 +18480,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
@@ -19071,6 +19294,33 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/unrun": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/unrun/-/unrun-0.3.1.tgz",
"integrity": "sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"rolldown": "^1.0.0"
},
"bin": {
"unrun": "dist/cli.mjs"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
},
"funding": {
"url": "https://github.com/sponsors/Gugustinette"
},
"peerDependencies": {
"synckit": "^0.11.11"
},
"peerDependenciesMeta": {
"synckit": {
"optional": true
}
}
},
"node_modules/until-async": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+5 -4
View File
@@ -25,7 +25,8 @@
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
},
"devDependencies": {
"concurrently": "^10.0.3"
"concurrently": "^10.0.3",
"unrun": "^0.3.1"
},
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
@@ -34,9 +35,9 @@
"multer": "^2.2.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.62.0",
"@rollup/rollup-linux-arm64-musl": "4.62.0",
"@img/sharp-linuxmusl-arm64": "0.35.1",
"@img/sharp-linuxmusl-x64": "0.35.1",
"@img/sharp-linuxmusl-arm64": "0.35.1"
"@rollup/rollup-linux-arm64-musl": "4.62.0",
"@rollup/rollup-linux-x64-musl": "4.62.0"
}
}
+2 -1
View File
@@ -114,7 +114,8 @@ export function applyGlobalMiddleware(
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com",
"https://tiles.openfreemap.org"
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
+4 -3
View File
@@ -16,11 +16,12 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'default_currency',
'blur_booking_codes',
'map_tile_url',
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
// whole instance uses Mapbox without each user pasting their own key (#920).
// Instance-wide GL map defaults: admins can set Mapbox token/style or
// tokenless MapLibre/OpenFreeMap style defaults for new users (#920).
'map_provider',
'mapbox_access_token',
'mapbox_style',
'maplibre_style',
'mapbox_3d_enabled',
'mapbox_quality_mode',
] as const;
@@ -32,7 +33,7 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
distance_unit: ['metric', 'imperial'],
time_format: ['12h', '24h'],
dark_mode: [true, false, 'light', 'dark', 'auto'],
map_provider: ['leaflet', 'mapbox-gl'],
map_provider: ['leaflet', 'mapbox-gl', 'maplibre-gl'],
};
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
+1
View File
@@ -335,6 +335,7 @@ const admin: TranslationStrings = {
'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)',
'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك',
'admin.defaultSettings.mapboxTokenHint':
'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',
+3
View File
@@ -18,6 +18,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية',
'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس',
'settings.mapMapLibreSubtitle': 'بلاطات متجهية من OpenFreeMap، بدون رمز',
'settings.mapExperimental': 'تجريبي',
'settings.mapMapboxToken': 'رمز وصول Mapbox',
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
@@ -25,6 +26,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'نمط الخريطة',
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
'settings.mapOpenFreeMapStylePlaceholder': 'اختر نمط OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'إعداد مسبق أو عنوان URL لنمط OpenFreeMap. تعمل أنماط OpenFreeMap بدون رمز.',
'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس',
'settings.map3dHint': 'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.',
'settings.mapHighQuality': 'وضع الجودة العالية',
+1
View File
@@ -342,6 +342,7 @@ const admin: TranslationStrings = {
'O mapa padrão para todos nesta instância. Cada usuário ainda pode substituí-lo nas próprias configurações.',
'admin.defaultSettings.providerLeaflet': 'Padrão (gratuito)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Token compartilhado do Mapbox',
'admin.defaultSettings.mapboxTokenHint':
'Usado para todos os usuários que não inseriram o próprio token — assim toda a instância usa o Mapbox sem compartilhar a chave individualmente. Armazenado de forma criptografada.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
'settings.mapLeafletSubtitle': 'Clássico 2D, quaisquer blocos raster',
'settings.mapMapboxSubtitle': 'Blocos vetoriais, prédios 3D & terreno',
'settings.mapMapLibreSubtitle': 'Blocos vetoriais OpenFreeMap, sem token',
'settings.mapExperimental': 'Experimental',
'settings.mapMapboxToken': 'Token de acesso Mapbox',
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Estilo do mapa',
'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox',
'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Selecionar um estilo OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset ou URL de estilo OpenFreeMap. Os estilos OpenFreeMap funcionam sem token.',
'settings.map3dBuildings': 'Prédios 3D & terreno',
'settings.map3dHint': 'Inclinação + extrusões 3D reais de prédios — funciona em todo estilo, incluindo satélite.',
'settings.mapHighQuality': 'Modo alta qualidade',
+1
View File
@@ -340,6 +340,7 @@ const admin: TranslationStrings = {
'Výchozí mapa pro všechny uživatele na této instanci. Každý uživatel ji může i nadále změnit ve svém vlastním nastavení.',
'admin.defaultSettings.providerLeaflet': 'Standardní (zdarma)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Sdílený token Mapbox',
'admin.defaultSettings.mapboxTokenHint':
'Použije se pro každého uživatele, který nezadal vlastní token — takže celá instance získá Mapbox, aniž byste klíč sdíleli s každým zvlášť. Ukládá se šifrovaně.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.',
'settings.mapLeafletSubtitle': 'Klasické 2D, libovolné rastrové dlaždice',
'settings.mapMapboxSubtitle': 'Vektorové dlaždice, 3D budovy a terén',
'settings.mapMapLibreSubtitle': 'Vektorové dlaždice OpenFreeMap, bez tokenu',
'settings.mapExperimental': 'Experimentální',
'settings.mapMapboxToken': 'Mapbox přístupový token',
'settings.mapMapboxTokenHint': 'Veřejný token (pk.*) z',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Styl mapy',
'settings.mapStylePlaceholder': 'Vyberte styl Mapbox',
'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Vyberte styl OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset nebo URL stylu OpenFreeMap. Styly OpenFreeMap fungují bez tokenu.',
'settings.map3dBuildings': '3D budovy a terén',
'settings.map3dHint': 'Náklon + skutečné 3D vyvýšení budov — funguje s každým stylem, včetně satelitu.',
'settings.mapHighQuality': 'Režim vysoké kvality',
+1
View File
@@ -345,6 +345,7 @@ const admin: TranslationStrings = {
'Die Standardkarte für alle auf dieser Instanz. Jeder Nutzer kann sie weiterhin in den eigenen Einstellungen überschreiben.',
'admin.defaultSettings.providerLeaflet': 'Standard (kostenlos)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Gemeinsames Mapbox-Token',
'admin.defaultSettings.mapboxTokenHint':
'Wird für jeden Nutzer verwendet, der kein eigenes Token eingegeben hat — so erhält die gesamte Instanz Mapbox, ohne den Schlüssel einzeln teilen zu müssen. Verschlüsselt gespeichert.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.',
'settings.mapLeafletSubtitle': 'Klassisch 2D, beliebige Raster-Kacheln',
'settings.mapMapboxSubtitle': 'Vektor-Kacheln, 3D-Gebäude & Terrain',
'settings.mapMapLibreSubtitle': 'OpenFreeMap Vektor-Kacheln, kein Token',
'settings.mapExperimental': 'Experimentell',
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Öffentliches Token (pk.*) von',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Kartenstil',
'settings.mapStylePlaceholder': 'Mapbox-Stil wählen',
'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL',
'settings.mapOpenFreeMapStylePlaceholder': 'OpenFreeMap-Stil wählen',
'settings.mapOpenFreeMapStyleHint': 'Preset oder OpenFreeMap-Stil-URL. OpenFreeMap-Stile funktionieren ohne Token.',
'settings.map3dBuildings': '3D-Gebäude & Terrain',
'settings.map3dHint': 'Neigung + echte 3D-Gebäude-Extrusionen — funktioniert mit jedem Stil, auch Satellit.',
'settings.mapHighQuality': 'Hochqualitäts-Modus',
+1
View File
@@ -184,6 +184,7 @@ const admin: TranslationStrings = {
'The default map for everyone on this instance. Each user can still override it in their own settings.',
'admin.defaultSettings.providerLeaflet': 'Standard (free)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Shared Mapbox token',
'admin.defaultSettings.mapboxTokenHint':
'Used for every user who has not entered their own token — so the whole instance gets Mapbox without sharing the key individually. Stored encrypted.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.',
'settings.mapLeafletSubtitle': 'Classic 2D, any raster tiles',
'settings.mapMapboxSubtitle': 'Vector tiles, 3D buildings & terrain',
'settings.mapMapLibreSubtitle': 'OpenFreeMap vector tiles, no token',
'settings.mapExperimental': 'Experimental',
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Public token (pk.*) from',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Map Style',
'settings.mapStylePlaceholder': 'Select a Mapbox style',
'settings.mapStyleHint': 'Preset or your own mapbox://styles/USER/ID URL',
'settings.mapOpenFreeMapStylePlaceholder': 'Select an OpenFreeMap style',
'settings.mapOpenFreeMapStyleHint': 'Preset or OpenFreeMap style URL. OpenFreeMap styles work without a token.',
'settings.map3dBuildings': '3D Buildings & Terrain',
'settings.map3dHint': 'Pitch + real 3D building extrusions — works on every style, including satellite.',
'settings.mapHighQuality': 'High Quality Mode',
+1
View File
@@ -351,6 +351,7 @@ const admin: TranslationStrings = {
'El mapa predeterminado para todos en esta instancia. Cada usuario puede cambiarlo en sus propios ajustes.',
'admin.defaultSettings.providerLeaflet': 'Estándar (gratis)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Token de Mapbox compartido',
'admin.defaultSettings.mapboxTokenHint':
'Se usa para cada usuario que no haya introducido su propio token, de modo que toda la instancia obtenga Mapbox sin compartir la clave individualmente. Se almacena cifrado.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.',
'settings.mapLeafletSubtitle': 'Clásico 2D, cualquier mosaico raster',
'settings.mapMapboxSubtitle': 'Mosaicos vectoriales, edificios 3D y terreno',
'settings.mapMapLibreSubtitle': 'Mosaicos vectoriales de OpenFreeMap, sin token',
'settings.mapExperimental': 'Experimental',
'settings.mapMapboxToken': 'Token de acceso de Mapbox',
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Estilo de mapa',
'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox',
'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Seleccionar un estilo de OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset o URL de estilo de OpenFreeMap. Los estilos de OpenFreeMap funcionan sin token.',
'settings.map3dBuildings': 'Edificios 3D y terreno',
'settings.map3dHint':
'Inclinación + extrusiones 3D reales de edificios — funciona con todos los estilos, incluyendo satélite.',
+1
View File
@@ -348,6 +348,7 @@ const admin: TranslationStrings = {
'La carte par défaut pour tous les utilisateurs de cette instance. Chaque utilisateur peut toujours la remplacer dans ses propres paramètres.',
'admin.defaultSettings.providerLeaflet': 'Standard (gratuit)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Jeton Mapbox partagé',
'admin.defaultSettings.mapboxTokenHint':
"Utilisé pour chaque utilisateur n'ayant pas saisi son propre jeton — ainsi toute l'instance bénéficie de Mapbox sans partager la clé individuellement. Stocké de façon chiffrée.",
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.',
'settings.mapLeafletSubtitle': 'Classique 2D, toutes tuiles raster',
'settings.mapMapboxSubtitle': 'Tuiles vectorielles, bâtiments 3D & terrain',
'settings.mapMapLibreSubtitle': 'Tuiles vectorielles OpenFreeMap, sans jeton',
'settings.mapExperimental': 'Expérimental',
'settings.mapMapboxToken': "Jeton d'accès Mapbox",
'settings.mapMapboxTokenHint': 'Jeton public (pk.*) depuis',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Style de carte',
'settings.mapStylePlaceholder': 'Sélectionner un style Mapbox',
'settings.mapStyleHint': 'Preset ou votre propre URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Sélectionner un style OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset ou URL de style OpenFreeMap. Les styles OpenFreeMap fonctionnent sans jeton.',
'settings.map3dBuildings': 'Bâtiments 3D & terrain',
'settings.map3dHint':
'Inclinaison + extrusions 3D réelles des bâtiments — fonctionne avec tous les styles, y compris satellite.',
+1
View File
@@ -355,6 +355,7 @@ const admin: TranslationStrings = {
'Ο προεπιλεγμένος χάρτης για όλους σε αυτή την εγκατάσταση. Κάθε χρήστης μπορεί να τον αλλάξει στις δικές του ρυθμίσεις.',
'admin.defaultSettings.providerLeaflet': 'Τυπικός (δωρεάν)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Κοινόχρηστο διακριτικό Mapbox',
'admin.defaultSettings.mapboxTokenHint':
'Χρησιμοποιείται για κάθε χρήστη που δεν έχει εισαγάγει το δικό του διακριτικό — έτσι ολόκληρη η εγκατάσταση αποκτά Mapbox χωρίς να μοιράζεται το κλειδί ξεχωριστά. Αποθηκεύεται κρυπτογραφημένο.',
+3
View File
@@ -21,6 +21,7 @@ const settings: TranslationStrings = {
'Επηρεάζει τους χάρτες του Trip Planner και του Journey. Το Atlas χρησιμοποιεί πάντα Leaflet.',
'settings.mapLeafletSubtitle': 'Κλασικό 2D, οποιαδήποτε raster πλακίδια',
'settings.mapMapboxSubtitle': 'Διανυσματικά πλακίδια, 3D κτίρια & ανάγλυφο',
'settings.mapMapLibreSubtitle': 'Διανυσματικά πλακίδια OpenFreeMap, χωρίς token',
'settings.mapExperimental': 'Πειραματικό',
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Δημόσιο token (pk.*) από',
@@ -28,6 +29,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Στυλ Χάρτη',
'settings.mapStylePlaceholder': 'Επιλέξτε ένα στυλ Mapbox',
'settings.mapStyleHint': 'Προκαθορισμένο ή δικό σας mapbox://styles/USER/ID URL',
'settings.mapOpenFreeMapStylePlaceholder': 'Επιλέξτε ένα στυλ OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Προκαθορισμένο ή URL στυλ OpenFreeMap. Τα στυλ OpenFreeMap λειτουργούν χωρίς token.',
'settings.map3dBuildings': '3D Κτίρια & Ανάγλυφο',
'settings.map3dHint':
'Κλίση + πραγματικές 3D προεξοχές κτιρίων — λειτουργεί σε κάθε στυλ, συμπεριλαμβανομένου του δορυφορικού.',
+1
View File
@@ -347,6 +347,7 @@ const admin: TranslationStrings = {
'Az alapértelmezett térkép mindenkinek ezen a példányon. Minden felhasználó felülírhatja a saját beállításaiban.',
'admin.defaultSettings.providerLeaflet': 'Alapértelmezett (ingyenes)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Megosztott Mapbox-token',
'admin.defaultSettings.mapboxTokenHint':
'Minden olyan felhasználóhoz használatos, aki nem adta meg a saját tokenjét — így az egész példány eléri a Mapboxot anélkül, hogy egyenként kellene megosztani a kulcsot. Titkosítva tárolódik.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.',
'settings.mapLeafletSubtitle': 'Klasszikus 2D, bármilyen raszter csempe',
'settings.mapMapboxSubtitle': 'Vektoros csempék, 3D épületek és terep',
'settings.mapMapLibreSubtitle': 'OpenFreeMap vektoros csempék, token nélkül',
'settings.mapExperimental': 'Kísérleti',
'settings.mapMapboxToken': 'Mapbox hozzáférési token',
'settings.mapMapboxTokenHint': 'Publikus token (pk.*) innen:',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Térkép stílus',
'settings.mapStylePlaceholder': 'Válassz Mapbox stílust',
'settings.mapStyleHint': 'Preset vagy saját mapbox://styles/USER/ID URL',
'settings.mapOpenFreeMapStylePlaceholder': 'Válassz OpenFreeMap stílust',
'settings.mapOpenFreeMapStyleHint': 'Preset vagy OpenFreeMap stílus URL. Az OpenFreeMap stílusok token nélkül működnek.',
'settings.map3dBuildings': '3D épületek és terep',
'settings.map3dHint': 'Dőlés + valódi 3D épület-kiemelés — minden stílussal működik, beleértve a műholdast.',
'settings.mapHighQuality': 'Magas minőség mód',
+1
View File
@@ -343,6 +343,7 @@ const admin: TranslationStrings = {
'Peta default untuk semua orang di instance ini. Setiap pengguna tetap dapat menggantinya di pengaturan masing-masing.',
'admin.defaultSettings.providerLeaflet': 'Standar (gratis)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Token Mapbox bersama',
'admin.defaultSettings.mapboxTokenHint':
'Digunakan untuk setiap pengguna yang belum memasukkan token mereka sendiri — sehingga seluruh instance mendapatkan Mapbox tanpa perlu membagikan kunci satu per satu. Disimpan dalam bentuk terenkripsi.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.',
'settings.mapLeafletSubtitle': 'Klasik 2D, tile raster apa pun',
'settings.mapMapboxSubtitle': 'Tile vektor, bangunan 3D & medan',
'settings.mapMapLibreSubtitle': 'Tile vektor OpenFreeMap, tanpa token',
'settings.mapExperimental': 'Eksperimental',
'settings.mapMapboxToken': 'Token akses Mapbox',
'settings.mapMapboxTokenHint': 'Token publik (pk.*) dari',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Gaya peta',
'settings.mapStylePlaceholder': 'Pilih gaya Mapbox',
'settings.mapStyleHint': 'Preset atau URL mapbox://styles/USER/ID milikmu',
'settings.mapOpenFreeMapStylePlaceholder': 'Pilih gaya OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset atau URL gaya OpenFreeMap. Gaya OpenFreeMap berfungsi tanpa token.',
'settings.map3dBuildings': 'Bangunan 3D & medan',
'settings.map3dHint': 'Kemiringan + ekstrusi bangunan 3D nyata — bekerja di semua gaya, termasuk satelit.',
'settings.mapHighQuality': 'Mode kualitas tinggi',
+1
View File
@@ -346,6 +346,7 @@ const admin: TranslationStrings = {
'La mappa predefinita per tutti gli utenti di questa istanza. Ogni utente può comunque sostituirla nelle proprie impostazioni.',
'admin.defaultSettings.providerLeaflet': 'Standard (gratuito)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Token Mapbox condiviso',
'admin.defaultSettings.mapboxTokenHint':
"Usato per ogni utente che non ha inserito un proprio token — così tutta l'istanza ottiene Mapbox senza dover condividere la chiave individualmente. Archiviato in forma crittografata.",
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.',
'settings.mapLeafletSubtitle': 'Classica 2D, qualsiasi tile raster',
'settings.mapMapboxSubtitle': 'Tile vettoriali, edifici 3D e terreno',
'settings.mapMapLibreSubtitle': 'Tile vettoriali OpenFreeMap, senza token',
'settings.mapExperimental': 'Sperimentale',
'settings.mapMapboxToken': 'Token di accesso Mapbox',
'settings.mapMapboxTokenHint': 'Token pubblico (pk.*) da',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Stile mappa',
'settings.mapStylePlaceholder': 'Seleziona uno stile Mapbox',
'settings.mapStyleHint': 'Preset o il tuo URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Seleziona uno stile OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset o URL di stile OpenFreeMap. Gli stili OpenFreeMap funzionano senza token.',
'settings.map3dBuildings': 'Edifici 3D e terreno',
'settings.map3dHint':
'Inclinazione + estrusioni 3D reali degli edifici — funziona con ogni stile, incluso satellite.',
+1
View File
@@ -331,6 +331,7 @@ const admin: TranslationStrings = {
'このインスタンスの全員に適用される既定の地図です。各ユーザーは自分の設定でこれを上書きできます。',
'admin.defaultSettings.providerLeaflet': '標準(無料)',
'admin.defaultSettings.providerMapbox': 'Mapbox3D',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': '共有 Mapbox トークン',
'admin.defaultSettings.mapboxTokenHint':
'自分のトークンを入力していないすべてのユーザーに使用されます。これにより、キーを個別に共有しなくてもインスタンス全体で Mapbox を利用できます。暗号化して保存されます。',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。',
'settings.mapLeafletSubtitle': 'クラシックな2D、任意のラスタータイル',
'settings.mapMapboxSubtitle': 'ベクタータイル、3D建物・地形',
'settings.mapMapLibreSubtitle': 'OpenFreeMap ベクタータイル、トークン不要',
'settings.mapExperimental': '実験的',
'settings.mapMapboxToken': 'Mapbox アクセストークン',
'settings.mapMapboxTokenHint': 'mapbox.com の公開トークン(pk.*',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': '地図スタイル',
'settings.mapStylePlaceholder': 'Mapboxスタイルを選択',
'settings.mapStyleHint': 'プリセットまたは mapbox://styles/USER/ID のURL',
'settings.mapOpenFreeMapStylePlaceholder': 'OpenFreeMapスタイルを選択',
'settings.mapOpenFreeMapStyleHint': 'プリセットまたは OpenFreeMap スタイルのURL。OpenFreeMap スタイルはトークンなしで動作します。',
'settings.map3dBuildings': '3D建物・地形',
'settings.map3dHint': 'ピッチ+実際の3D押し出し表示。衛星含む全スタイルで動作。',
'settings.mapHighQuality': '高品質モード',
+1
View File
@@ -334,6 +334,7 @@ const admin: TranslationStrings = {
'이 인스턴스의 모든 사용자에게 적용되는 기본 지도입니다. 각 사용자는 자신의 설정에서 이를 변경할 수 있습니다.',
'admin.defaultSettings.providerLeaflet': '표준 (무료)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': '공유 Mapbox 토큰',
'admin.defaultSettings.mapboxTokenHint':
'자신의 토큰을 입력하지 않은 모든 사용자에게 사용됩니다 — 키를 개별적으로 공유하지 않아도 인스턴스 전체에서 Mapbox를 사용할 수 있습니다. 암호화하여 저장됩니다.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.',
'settings.mapLeafletSubtitle': '클래식 2D, 모든 래스터 타일',
'settings.mapMapboxSubtitle': '벡터 타일, 3D 건물 및 지형',
'settings.mapMapLibreSubtitle': 'OpenFreeMap 벡터 타일, 토큰 불필요',
'settings.mapExperimental': '실험적',
'settings.mapMapboxToken': 'Mapbox 액세스 토큰',
'settings.mapMapboxTokenHint': '공개 토큰 (pk.*) 출처',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': '지도 스타일',
'settings.mapStylePlaceholder': 'Mapbox 스타일 선택',
'settings.mapStyleHint': '프리셋 또는 mapbox://styles/USER/ID URL 직접 입력',
'settings.mapOpenFreeMapStylePlaceholder': 'OpenFreeMap 스타일 선택',
'settings.mapOpenFreeMapStyleHint': '프리셋 또는 OpenFreeMap 스타일 URL. OpenFreeMap 스타일은 토큰 없이 작동합니다.',
'settings.map3dBuildings': '3D 건물 및 지형',
'settings.map3dHint': '기울기 + 실제 3D 건물 돌출 — 위성 포함 모든 스타일에서 작동합니다.',
'settings.mapHighQuality': '고품질 모드',
+1
View File
@@ -346,6 +346,7 @@ const admin: TranslationStrings = {
'De standaardkaart voor iedereen op deze instantie. Elke gebruiker kan dit nog steeds aanpassen in zijn eigen instellingen.',
'admin.defaultSettings.providerLeaflet': 'Standaard (gratis)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Gedeeld Mapbox-token',
'admin.defaultSettings.mapboxTokenHint':
'Wordt gebruikt voor elke gebruiker die nog geen eigen token heeft ingevoerd — zo krijgt de hele instantie Mapbox zonder de sleutel apart te delen. Versleuteld opgeslagen.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.',
'settings.mapLeafletSubtitle': 'Klassiek 2D, elke raster-tile',
'settings.mapMapboxSubtitle': 'Vector tiles, 3D-gebouwen & terrein',
'settings.mapMapLibreSubtitle': 'OpenFreeMap vector tiles, geen token',
'settings.mapExperimental': 'Experimenteel',
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Openbaar token (pk.*) van',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Kaartstijl',
'settings.mapStylePlaceholder': 'Kies een Mapbox-stijl',
'settings.mapStyleHint': 'Preset of eigen mapbox://styles/USER/ID URL',
'settings.mapOpenFreeMapStylePlaceholder': 'Kies een OpenFreeMap-stijl',
'settings.mapOpenFreeMapStyleHint': 'Preset of OpenFreeMap-stijl-URL. OpenFreeMap-stijlen werken zonder token.',
'settings.map3dBuildings': '3D-gebouwen & terrein',
'settings.map3dHint': 'Kanteling + echte 3D-gebouwenextrusies — werkt op elke stijl, inclusief satelliet.',
'settings.mapHighQuality': 'Hoge kwaliteit modus',
+1
View File
@@ -350,6 +350,7 @@ const admin: TranslationStrings = {
'Domyślna mapa dla wszystkich na tej instancji. Każdy użytkownik może ją zmienić we własnych ustawieniach.',
'admin.defaultSettings.providerLeaflet': 'Standardowa (bezpłatna)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Współdzielony token Mapbox',
'admin.defaultSettings.mapboxTokenHint':
'Używany dla każdego użytkownika, który nie wprowadził własnego tokena — dzięki temu cała instancja korzysta z Mapbox bez udostępniania klucza każdemu z osobna. Przechowywany w postaci zaszyfrowanej.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.',
'settings.mapLeafletSubtitle': 'Klasyczne 2D, dowolne kafelki rastrowe',
'settings.mapMapboxSubtitle': 'Kafelki wektorowe, budynki 3D i teren',
'settings.mapMapLibreSubtitle': 'Kafelki wektorowe OpenFreeMap, bez tokena',
'settings.mapExperimental': 'Eksperymentalne',
'settings.mapMapboxToken': 'Token dostępu Mapbox',
'settings.mapMapboxTokenHint': 'Token publiczny (pk.*) z',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Styl mapy',
'settings.mapStylePlaceholder': 'Wybierz styl Mapbox',
'settings.mapStyleHint': 'Preset lub własny URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Wybierz styl OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset lub URL stylu OpenFreeMap. Style OpenFreeMap działają bez tokena.',
'settings.map3dBuildings': 'Budynki 3D i teren',
'settings.map3dHint': 'Nachylenie + prawdziwe wytłaczanie budynków 3D — działa w każdym stylu, także satelitarnym.',
'settings.mapHighQuality': 'Tryb wysokiej jakości',
+1
View File
@@ -345,6 +345,7 @@ const admin: TranslationStrings = {
'Карта по умолчанию для всех на этом сервере. Каждый пользователь по-прежнему может изменить её в своих настройках.',
'admin.defaultSettings.providerLeaflet': 'Стандартная (бесплатно)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Общий токен Mapbox',
'admin.defaultSettings.mapboxTokenHint':
'Используется для каждого пользователя, который не ввёл собственный токен — так весь сервер получает Mapbox без необходимости делиться ключом по отдельности. Хранится в зашифрованном виде.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.',
'settings.mapLeafletSubtitle': 'Классические 2D, любые растровые тайлы',
'settings.mapMapboxSubtitle': 'Векторные тайлы, 3D-здания и рельеф',
'settings.mapMapLibreSubtitle': 'Векторные тайлы OpenFreeMap, без токена',
'settings.mapExperimental': 'Экспериментально',
'settings.mapMapboxToken': 'Токен доступа Mapbox',
'settings.mapMapboxTokenHint': 'Публичный токен (pk.*) с',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Стиль карты',
'settings.mapStylePlaceholder': 'Выберите стиль Mapbox',
'settings.mapStyleHint': 'Preset или собственный URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Выберите стиль OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset или URL стиля OpenFreeMap. Стили OpenFreeMap работают без токена.',
'settings.map3dBuildings': '3D-здания и рельеф',
'settings.map3dHint': 'Наклон + настоящие 3D-здания — работает со всеми стилями, включая спутник.',
'settings.mapHighQuality': 'Режим высокого качества',
+1
View File
@@ -184,6 +184,7 @@ const admin: TranslationStrings = {
'Standardkartan för alla användare på denna instans. Varje användare kan fortfarande ändra inställningarna i sina egna inställningar.',
'admin.defaultSettings.providerLeaflet': 'Standard (gratis)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Delat Mapbox-token',
'admin.defaultSettings.mapboxTokenHint':
'Används för alla användare som inte har angett sin egen token på så sätt får hela instansen tillgång till Mapbox utan att nyckeln behöver delas ut individuellt. Lagras i krypterad form.',
+3
View File
@@ -20,6 +20,9 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Påverkar resplaneraren och resedagbokens kartor. Atlas använder alltid Leaflet.',
'settings.mapLeafletSubtitle': 'Klassisk 2D, valfria rasterplattor',
'settings.mapMapboxSubtitle': 'Vektorplattor, 3D-byggnader och terräng',
'settings.mapMapLibreSubtitle': 'OpenFreeMap-vektorplattor, ingen token',
'settings.mapOpenFreeMapStylePlaceholder': 'Välj en OpenFreeMap-stil',
'settings.mapOpenFreeMapStyleHint': 'Förinställning eller OpenFreeMap-stil-URL. OpenFreeMap-stilar fungerar utan token.',
'settings.mapExperimental': 'Experimentell',
'settings.mapMapboxToken': 'Mapbox-åtkomsttoken',
'settings.mapMapboxTokenHint': 'Offentlig token (pk.*) från',
+1
View File
@@ -349,6 +349,7 @@ const admin: TranslationStrings = {
'Bu örnekteki herkes için varsayılan harita. Her kullanıcı bunu yine de kendi ayarlarında değiştirebilir.',
'admin.defaultSettings.providerLeaflet': 'Standart (ücretsiz)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Paylaşılan Mapbox jetonu',
'admin.defaultSettings.mapboxTokenHint':
"Kendi jetonunu girmemiş her kullanıcı için kullanılır — böylece anahtarı tek tek paylaşmadan tüm örnek Mapbox'ı kullanır. Şifrelenmiş olarak saklanır.",
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Seyahat planlayıcı ve Journey haritalarını etkiler. Atlas her zaman Leaflet kullanır.',
'settings.mapLeafletSubtitle': 'Klasik 2D, herhangi bir raster kutucuk',
'settings.mapMapboxSubtitle': 'Vektör kutucuklar, 3D binalar ve arazi',
'settings.mapMapLibreSubtitle': 'OpenFreeMap vektör kutucuklar, anahtar gerekmez',
'settings.mapExperimental': 'Deneysel',
'settings.mapMapboxToken': 'Mapbox Erişim Anahtarı',
'settings.mapMapboxTokenHint': 'Genel anahtar (pk.*) kaynağı:',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Harita Stili',
'settings.mapStylePlaceholder': 'Bir Mapbox stili seçin',
'settings.mapStyleHint': 'Ön ayar veya kendi mapbox://styles/KULLANICI/ID adresiniz',
'settings.mapOpenFreeMapStylePlaceholder': 'Bir OpenFreeMap stili seçin',
'settings.mapOpenFreeMapStyleHint': "Ön ayar veya OpenFreeMap stil URL'si. OpenFreeMap stilleri anahtar gerektirmeden çalışır.",
'settings.map3dBuildings': '3D Binalar ve Arazi',
'settings.map3dHint': 'Eğim + gerçek 3D bina çıkıntıları — uydu dahil her stilde çalışır.',
'settings.mapHighQuality': 'Yüksek Kalite Modu',
+1
View File
@@ -345,6 +345,7 @@ const admin: TranslationStrings = {
'Карта за замовчуванням для всіх на цьому екземплярі. Кожен користувач може змінити її у власних налаштуваннях.',
'admin.defaultSettings.providerLeaflet': 'Стандартна (безкоштовна)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Спільний токен Mapbox',
'admin.defaultSettings.mapboxTokenHint':
'Використовується для кожного користувача, який не ввів власний токен — щоб увесь екземпляр отримав Mapbox без потреби ділитися ключем окремо. Зберігається в зашифрованому вигляді.',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'Застосовується до Trip Planner та Journey. Atlas завжди використовує Leaflet.',
'settings.mapLeafletSubtitle': 'Класичні 2D, будь-які растрові тайли',
'settings.mapMapboxSubtitle': 'Векторні тайли, 3D-будинки та рельєф',
'settings.mapMapLibreSubtitle': 'Векторні тайли OpenFreeMap, без токена',
'settings.mapExperimental': 'Експериментально',
'settings.mapMapboxToken': 'Токен доступу Mapbox',
'settings.mapMapboxTokenHint': 'Публічний токен (pk.*) з',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'Стиль карти',
'settings.mapStylePlaceholder': 'Виберіть стиль Mapbox',
'settings.mapStyleHint': 'Preset або власний URL mapbox://styles/USER/ID',
'settings.mapOpenFreeMapStylePlaceholder': 'Виберіть стиль OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'Preset або URL стилю OpenFreeMap. Стилі OpenFreeMap працюють без токена.',
'settings.map3dBuildings': '3D-будинки та рельєф',
'settings.map3dHint': 'Нахил + справжні 3D-будинки — працює з усіма стилями, включаючи супутник.',
'settings.mapHighQuality': 'Режим високої якості',
+1
View File
@@ -327,6 +327,7 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapProviderHint': '此執行個體上所有人的預設地圖。每位使用者仍可在自己的設定中覆寫此項。',
'admin.defaultSettings.providerLeaflet': '標準(免費)',
'admin.defaultSettings.providerMapbox': 'Mapbox3D',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': '共用的 Mapbox 權杖',
'admin.defaultSettings.mapboxTokenHint':
'用於每一位尚未輸入自己權杖的使用者 — 如此整個執行個體都能使用 Mapbox,而無需個別共享金鑰。以加密方式儲存。',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。',
'settings.mapLeafletSubtitle': '經典 2D,任何柵格瓦片',
'settings.mapMapboxSubtitle': '向量瓦片、3D 建築和地形',
'settings.mapMapLibreSubtitle': 'OpenFreeMap 向量瓦片,無需權杖',
'settings.mapExperimental': '實驗性',
'settings.mapMapboxToken': 'Mapbox 存取權杖',
'settings.mapMapboxTokenHint': '公開權杖 (pk.*) 來自',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': '地圖樣式',
'settings.mapStylePlaceholder': '選擇 Mapbox 樣式',
'settings.mapStyleHint': '預設或您自己的 mapbox://styles/USER/ID URL',
'settings.mapOpenFreeMapStylePlaceholder': '選擇 OpenFreeMap 樣式',
'settings.mapOpenFreeMapStyleHint': '預設或 OpenFreeMap 樣式 URL。OpenFreeMap 樣式無需權杖即可使用。',
'settings.map3dBuildings': '3D 建築和地形',
'settings.map3dHint': '傾斜 + 真實 3D 建築拉伸 — 適用於所有樣式,包括衛星。',
'settings.mapHighQuality': '高畫質模式',
+1
View File
@@ -325,6 +325,7 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapProviderHint': '本实例中所有用户的默认地图。每位用户仍可在自己的设置中更改此项。',
'admin.defaultSettings.providerLeaflet': '标准(免费)',
'admin.defaultSettings.providerMapbox': 'Mapbox3D',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': '共享 Mapbox 令牌',
'admin.defaultSettings.mapboxTokenHint':
'用于所有未输入自己令牌的用户 — 这样无需逐个分享密钥,整个实例即可使用 Mapbox。以加密方式存储。',
+3
View File
@@ -20,6 +20,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': '影响行程规划和旅程地图。Atlas 始终使用 Leaflet。',
'settings.mapLeafletSubtitle': '经典 2D,任何栅格瓦片',
'settings.mapMapboxSubtitle': '矢量瓦片、3D 建筑和地形',
'settings.mapMapLibreSubtitle': 'OpenFreeMap 矢量瓦片,无需令牌',
'settings.mapExperimental': '实验性',
'settings.mapMapboxToken': 'Mapbox 访问令牌',
'settings.mapMapboxTokenHint': '公共令牌 (pk.*) 来自',
@@ -27,6 +28,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': '地图样式',
'settings.mapStylePlaceholder': '选择 Mapbox 样式',
'settings.mapStyleHint': '预设或您自己的 mapbox://styles/USER/ID URL',
'settings.mapOpenFreeMapStylePlaceholder': '选择 OpenFreeMap 样式',
'settings.mapOpenFreeMapStyleHint': '预设或 OpenFreeMap 样式 URL。OpenFreeMap 样式无需令牌即可使用。',
'settings.map3dBuildings': '3D 建筑和地形',
'settings.map3dHint': '倾斜 + 真实 3D 建筑拉伸 — 适用于所有样式,包括卫星。',
'settings.mapHighQuality': '高画质模式',