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