feat: global map provider/tiles setting (Thunderforest, MapTiler, CARTO, OSM, Esri) selectable in Profile with style + API key + live preview; all maps use it via useMapSettings/resolveTile
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s

This commit is contained in:
2026-06-23 12:29:50 +01:00
parent deecf668d2
commit ffe285b7ba
8 changed files with 288 additions and 47 deletions
+1
View File
@@ -120,6 +120,7 @@ docker compose -f docker-compose.deploy.yml up -d
- `hooks/useSync.js` — Zustand store polling Garmin sync status; maps backend status strings to progress percentages - `hooks/useSync.js` — Zustand store polling Garmin sync status; maps backend status strings to progress percentages
- `hooks/useUnits.js` — Zustand store for the global km/mi display preference (`useUnit()` subscribes to the active unit). Distances are stored canonically; the unit only affects display, converted on the fly by the `format.js` helpers. Persisted to `localStorage`. Surfaced in the nav via `components/ui/UnitToggle.jsx` - `hooks/useUnits.js` — Zustand store for the global km/mi display preference (`useUnit()` subscribes to the active unit). Distances are stored canonically; the unit only affects display, converted on the fly by the `format.js` helpers. Persisted to `localStorage`. Surfaced in the nav via `components/ui/UnitToggle.jsx`
- `hooks/useMediaQuery.js` — responsive breakpoint hook (md=768px split); the dashboard widget grid must be conditionally mounted, not just CSS-hidden, on mobile - `hooks/useMediaQuery.js` — responsive breakpoint hook (md=768px split); the dashboard widget grid must be conditionally mounted, not just CSS-hidden, on mobile
- `hooks/useMapSettings.js` — Zustand store (localStorage-persisted, like `useUnits`) for the global map tile preference: provider + style + per-provider API keys. The provider/style catalogue lives in `utils/mapTiles.js` (`MAP_PROVIDERS`, `resolveTile`); every Leaflet map (`ActivityMap`, `RouteTileMap`) resolves its base layer via `useResolvedTile()`. Configured in Profile Map & Tiles. `ActivityMap` takes a `satellite` boolean to override with imagery (MapTiler satellite if keyed, else free Esri)
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler - `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler
- TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth, sync, and unit-preference state - TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth, sync, and unit-preference state
- `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc. - `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import L from 'leaflet' import L from 'leaflet'
import { sportColor } from '../../utils/format' import { sportColor } from '../../utils/format'
import { projectToTrack } from '../../utils/track' import { projectToTrack } from '../../utils/track'
import { useResolvedTile } from '../../hooks/useMapSettings'
delete L.Icon.Default.prototype._getIconUrl delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({ L.Icon.Default.mergeOptions({
@@ -10,24 +11,9 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
}) })
const TILE_LAYERS = {
dark: {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
street: {
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© <a href="https://www.esri.com/">Esri</a>',
},
}
// Tile options tuned for smoother panning/zooming: keep a larger off-screen // Tile options tuned for smoother panning/zooming: keep a larger off-screen
// buffer of tiles and don't defer loads until the map is idle. // buffer of tiles and don't defer loads until the map is idle.
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false } const TILE_OPTS = { keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
// Slow → fast colour ramp for speed-coloured routes (red → purple). // Slow → fast colour ramp for speed-coloured routes (red → purple).
export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7'] export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7']
@@ -145,7 +131,8 @@ function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] }) map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
} }
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) { export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, satellite = false, colorMode = 'speed', onMapClick }) {
const tile = useResolvedTile(satellite)
const mapRef = useRef(null) const mapRef = useRef(null)
const mapInstanceRef = useRef(null) const mapInstanceRef = useRef(null)
const markerRef = useRef(null) const markerRef = useRef(null)
@@ -167,10 +154,6 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
preferCanvas: true, preferCanvas: true,
}) })
const tile = TILE_LAYERS.street
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
.addTo(mapInstanceRef.current)
mapInstanceRef.current.on('click', (e) => { mapInstanceRef.current.on('click', (e) => {
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng }) if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
}) })
@@ -209,11 +192,11 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
if (tileLayerRef.current) tileLayerRef.current.remove() if (tileLayerRef.current) tileLayerRef.current.remove()
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS }) tileLayerRef.current = L.tileLayer(tile.url, {
.addTo(mapInstanceRef.current) attribution: tile.attribution, maxZoom: tile.maxZoom, subdomains: tile.subdomains, ...TILE_OPTS,
}, [mapType]) }).addTo(mapInstanceRef.current)
}, [tile])
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return if (!mapInstanceRef.current) return
+12 -7
View File
@@ -1,11 +1,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import L from 'leaflet' import L from 'leaflet'
import { sportColor } from '../../utils/format' import { sportColor } from '../../utils/format'
import { useResolvedTile } from '../../hooks/useMapSettings'
// Thunderforest Outdoors raster — topo style with dense place/street labels,
// paths and contours. The apikey is a client-side tile key (public by nature).
const THUNDERFOREST_KEY = '872984f587484873a74ea454662ffacb'
const TILE_URL = `https://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png?apikey=${THUNDERFOREST_KEY}`
function decodePolyline(encoded) { function decodePolyline(encoded) {
if (!encoded) return [] if (!encoded) return []
@@ -28,6 +24,8 @@ function decodePolyline(encoded) {
export default function RouteTileMap({ polyline, sportType, className = '' }) { export default function RouteTileMap({ polyline, sportType, className = '' }) {
const elRef = useRef(null) const elRef = useRef(null)
const mapRef = useRef(null) const mapRef = useRef(null)
const tileRef = useRef(null)
const tile = useResolvedTile()
useEffect(() => { useEffect(() => {
if (!elRef.current || mapRef.current) return if (!elRef.current || mapRef.current) return
@@ -40,10 +38,17 @@ export default function RouteTileMap({ polyline, sportType, className = '' }) {
zoomSnap: 0, zoomSnap: 0,
}) })
mapRef.current = map mapRef.current = map
L.tileLayer(TILE_URL, { maxZoom: 22, subdomains: 'abc' }).addTo(map) return () => { map.remove(); mapRef.current = null; tileRef.current = null }
return () => { map.remove(); mapRef.current = null }
}, []) }, [])
// Swap the base layer whenever the global map setting changes.
useEffect(() => {
const map = mapRef.current
if (!map) return
if (tileRef.current) tileRef.current.remove()
tileRef.current = L.tileLayer(tile.url, { maxZoom: tile.maxZoom, subdomains: tile.subdomains }).addTo(map)
}, [tile])
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
if (!map) return if (!map) return
+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from 'react'
import { create } from 'zustand'
import { MAP_PROVIDERS, DEFAULT_MAP_SETTINGS, resolveTile } from '../utils/mapTiles'
// Global map tile preference (provider + style + per-provider API keys). Chosen
// once on the Profile page and used by every Leaflet map in the app. Persisted
// to localStorage so the choice sticks across reloads (mirrors useUnits). API
// keys live client-side only — these are public, client-side tile keys.
const KEY = 'mapSettings'
function load() {
try {
const saved = JSON.parse(localStorage.getItem(KEY))
if (saved && typeof saved === 'object') {
return { ...DEFAULT_MAP_SETTINGS, ...saved, keys: { ...saved.keys } }
}
} catch { /* ignore malformed storage */ }
return { ...DEFAULT_MAP_SETTINGS, keys: {} }
}
function persist(s) {
localStorage.setItem(KEY, JSON.stringify({ provider: s.provider, style: s.style, keys: s.keys }))
}
export const useMapSettingsStore = create((set) => ({
...load(),
setProvider: (provider) => set((s) => {
// Reset the style to the provider's first available style.
const styleId = Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0]
const next = { ...s, provider, style: styleId }
persist(next)
return next
}),
setStyle: (style) => set((s) => {
const next = { ...s, style }
persist(next)
return next
}),
setKey: (provider, value) => set((s) => {
const next = { ...s, keys: { ...s.keys, [provider]: value } }
persist(next)
return next
}),
}))
// Resolve the active tile layer config for a map. Pass satellite=true for an
// imagery override (e.g. the per-activity satellite toggle).
export function useResolvedTile(satellite = false) {
const provider = useMapSettingsStore((s) => s.provider)
const style = useMapSettingsStore((s) => s.style)
const keys = useMapSettingsStore((s) => s.keys)
return useMemo(
() => resolveTile({ provider, style, keys }, { satellite }),
[provider, style, keys, satellite],
)
}
+7 -10
View File
@@ -33,7 +33,7 @@ export default function ActivityDetailPage() {
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
const [hoveredDistance, setHoveredDistance] = useState(null) const [hoveredDistance, setHoveredDistance] = useState(null)
const [mapHeight, setMapHeight] = useState(420) const [mapHeight, setMapHeight] = useState(420)
const [mapType, setMapType] = useState('street') const [satellite, setSatellite] = useState(false)
const [colorMode, setColorMode] = useState('speed') const [colorMode, setColorMode] = useState('speed')
const [segCreate, setSegCreate] = useState(false) const [segCreate, setSegCreate] = useState(false)
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2 const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
@@ -294,18 +294,15 @@ export default function ActivityDetailPage() {
{/* Map toolbar */} {/* Map toolbar */}
<div className="flex flex-wrap items-center justify-between gap-y-2 px-4 py-2 border-b border-gray-800"> <div className="flex flex-wrap items-center justify-between gap-y-2 px-4 py-2 border-b border-gray-800">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-500">Map style:</span>
{['dark', 'street', 'satellite'].map(t => (
<button <button
key={t} onClick={() => setSatellite(s => !s)}
onClick={() => setMapType(t)} title="Map tiles are chosen globally in Profile Map & Tiles"
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${ className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800' satellite ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`} }`}
> >
{t} 🛰 Satellite
</button> </button>
))}
{dataPoints?.length > 0 && ( {dataPoints?.length > 0 && (
<button <button
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }} onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
@@ -381,7 +378,7 @@ export default function ActivityDetailPage() {
dataPoints={dataPoints} dataPoints={dataPoints}
hoveredDistance={hoveredDistance} hoveredDistance={hoveredDistance}
sportType={activity.sport_type} sportType={activity.sport_type}
mapType={mapType} satellite={satellite}
colorMode={colorMode} colorMode={colorMode}
onMapClick={segCreate ? handleMapClick : undefined} onMapClick={segCreate ? handleMapClick : undefined}
/> />
+1 -1
View File
@@ -330,7 +330,7 @@ function FeaturedActivity({ activity, segments }) {
<div className="grid grid-cols-1 lg:grid-cols-3 flex-1 min-h-0"> <div className="grid grid-cols-1 lg:grid-cols-3 flex-1 min-h-0">
<div className="lg:col-span-2 min-h-[180px] bg-gray-950"> <div className="lg:col-span-2 min-h-[180px] bg-gray-950">
{activity.polyline {activity.polyline
? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" mapType="dark" /> ? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" />
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>} : <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start"> <div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
+73
View File
@@ -3,6 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api' import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth' import { useAuthStore } from '../hooks/useAuth'
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync' import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
import { useMapSettingsStore } from '../hooks/useMapSettings'
import { MAP_PROVIDERS } from '../utils/mapTiles'
import RouteTileMap from '../components/ui/RouteTileMap'
// Human-friendly description of the automatic sync cadence, e.g. "every 30 min", // Human-friendly description of the automatic sync cadence, e.g. "every 30 min",
// "hourly", "every 2 h". Driven by the backend's configured interval. // "hourly", "every 2 h". Driven by the backend's configured interval.
@@ -40,6 +43,15 @@ function Input({ type = 'text', value, onChange, placeholder, min, max }) {
) )
} }
function Select({ value, onChange, children }) {
return (
<select value={value} onChange={onChange}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
{children}
</select>
)
}
function SaveButton({ onClick, loading, saved, label = 'Save' }) { function SaveButton({ onClick, loading, saved, label = 'Save' }) {
return ( return (
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-3 pt-1">
@@ -245,6 +257,18 @@ export default function ProfilePage() {
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
}) })
// Global map tiles preference (provider / style / API keys) — stored in
// localStorage, used by every map in the app.
const mapProvider = useMapSettingsStore(s => s.provider)
const mapStyle = useMapSettingsStore(s => s.style)
const mapKeys = useMapSettingsStore(s => s.keys)
const setMapProvider = useMapSettingsStore(s => s.setProvider)
const setMapStyle = useMapSettingsStore(s => s.setStyle)
const setMapKey = useMapSettingsStore(s => s.setKey)
const providerDef = MAP_PROVIDERS[mapProvider] || MAP_PROVIDERS.thunderforest
// A sample track so the preview below reflects the selected tiles live.
const SAMPLE_POLYLINE = 'mniyHpouMm@kBeAoCq@_BqAyCk@iAa@s@m@_AcAuAaAcAyAuAa@]'
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
return ( return (
@@ -323,6 +347,55 @@ export default function ProfilePage() {
)} )}
</Section> </Section>
{/* Map & Tiles — applies to every map in the app */}
<Section title="🗺️ Map & Tiles">
<p className="text-xs text-gray-500">
Choose the map provider and style used everywhere in the app (activity maps, route tiles, mini-maps).
Thunderforest and MapTiler need an API key paste it below. Keys are stored only in this browser.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Map provider">
<Select value={mapProvider} onChange={e => setMapProvider(e.target.value)}>
{Object.entries(MAP_PROVIDERS).map(([id, p]) => (
<option key={id} value={id}>{p.label}</option>
))}
</Select>
</Field>
<Field label="Map style">
<Select value={mapStyle} onChange={e => setMapStyle(e.target.value)}>
{Object.entries(providerDef.styles).map(([id, s]) => (
<option key={id} value={id}>{s.label}</option>
))}
</Select>
</Field>
</div>
{providerDef.needsKey && (
<Field
label={`${providerDef.label} API key${providerDef.keyOptional ? ' (optional)' : ''}`}
hint={providerDef.keyHint}
>
<Input value={mapKeys[mapProvider] || ''} placeholder="Paste your API key"
onChange={e => setMapKey(mapProvider, e.target.value.trim())} />
{providerDef.signupUrl && (
<a href={providerDef.signupUrl} target="_blank" rel="noreferrer"
className="text-xs text-blue-400 hover:text-blue-300 mt-1 inline-block">
Get a free key
</a>
)}
{!providerDef.keyOptional && !mapKeys[mapProvider] && (
<p className="text-yellow-400 text-xs mt-1">A key is required or tiles wont load.</p>
)}
</Field>
)}
<Field label="Preview">
<RouteTileMap polyline={SAMPLE_POLYLINE} sportType="running"
className="h-40 w-full rounded-lg overflow-hidden border border-gray-800" />
</Field>
</Section>
{/* Password change */} {/* Password change */}
<Section title="Change Password"> <Section title="Change Password">
<div className="space-y-3"> <div className="space-y-3">
+126
View File
@@ -0,0 +1,126 @@
// Central catalogue of map tile providers + styles used across the whole app.
// The active provider/style/API-key is chosen globally on the Profile page and
// stored in the useMapSettings store; every Leaflet map resolves its base layer
// through resolveTile() so a single setting drives the entire application.
// Built-in Thunderforest key (a public, client-side tile key) used when the user
// hasn't supplied their own. Matches the historical RouteTileMap default so the
// app keeps working out of the box, but it may be rate-limited under load.
export const DEFAULT_THUNDERFOREST_KEY = '872984f587484873a74ea454662ffacb'
const OSM_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM contributors</a>'
// Each provider lists its selectable styles (keyed by the id used in the tile
// URL) and a build(styleId, key) that returns a Leaflet URL template. needsKey
// providers read the user's key from the store; others ignore it.
export const MAP_PROVIDERS = {
thunderforest: {
label: 'Thunderforest',
needsKey: true,
keyOptional: true, // a built-in default key exists
keyHint: 'Free key at thunderforest.com. A shared default key is built in but may be rate-limited — add your own for reliability.',
signupUrl: 'https://www.thunderforest.com/',
attribution: `© <a href="https://www.thunderforest.com/">Thunderforest</a>, ${OSM_ATTR}`,
maxZoom: 22,
styles: {
outdoors: { label: 'Outdoors' },
cycle: { label: 'OpenCycleMap' },
landscape: { label: 'Landscape' },
atlas: { label: 'Atlas' },
transport: { label: 'Transport' },
'transport-dark': { label: 'Transport Dark' },
pioneer: { label: 'Pioneer' },
neighbourhood: { label: 'Neighbourhood' },
'spinal-map': { label: 'Spinal' },
},
build: (styleId, key) =>
`https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || DEFAULT_THUNDERFOREST_KEY}`,
},
maptiler: {
label: 'MapTiler',
needsKey: true,
keyHint: 'Free key required from maptiler.com/cloud — there is no built-in key.',
signupUrl: 'https://www.maptiler.com/cloud/',
attribution: `© <a href="https://www.maptiler.com/">MapTiler</a>, ${OSM_ATTR}`,
maxZoom: 22,
styles: {
'streets-v2': { label: 'Streets' },
'outdoor-v2': { label: 'Outdoor' },
'topo-v2': { label: 'Topo' },
'winter-v2': { label: 'Winter' },
satellite: { label: 'Satellite' },
hybrid: { label: 'Satellite + labels' },
'basic-v2': { label: 'Basic' },
dataviz: { label: 'Dataviz Light' },
'dataviz-dark': { label: 'Dataviz Dark' },
},
build: (styleId, key) =>
`https://api.maptiler.com/maps/${styleId}/{z}/{x}/{y}.png?key=${key || ''}`,
},
carto: {
label: 'CARTO (no key)',
needsKey: false,
attribution: `${OSM_ATTR} © <a href="https://carto.com/">CARTO</a>`,
maxZoom: 20,
styles: {
dark_all: { label: 'Dark Matter' },
'rastertiles/voyager': { label: 'Voyager' },
light_all: { label: 'Positron (light)' },
},
build: (styleId) => `https://{s}.basemaps.cartocdn.com/${styleId}/{z}/{x}/{y}{r}.png`,
},
osm: {
label: 'OpenStreetMap (no key)',
needsKey: false,
attribution: OSM_ATTR,
maxZoom: 19,
styles: { standard: { label: 'Standard' } },
build: () => 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
},
esri: {
label: 'Esri (no key)',
needsKey: false,
attribution: '© <a href="https://www.esri.com/">Esri</a>',
maxZoom: 19,
styles: {
World_Imagery: { label: 'Satellite' },
World_Topo_Map: { label: 'Topographic' },
World_Street_Map: { label: 'Street' },
},
build: (styleId) =>
`https://server.arcgisonline.com/ArcGIS/rest/services/${styleId}/MapServer/tile/{z}/{y}/{x}`,
},
}
export const DEFAULT_MAP_SETTINGS = { provider: 'thunderforest', style: 'outdoors', keys: {} }
function tileFor(provider, style, keys = {}) {
const p = MAP_PROVIDERS[provider] || MAP_PROVIDERS[DEFAULT_MAP_SETTINGS.provider]
const styleIds = Object.keys(p.styles)
const styleId = p.styles[style] ? style : styleIds[0]
const key = p.needsKey ? (keys[provider] || '') : ''
return {
url: p.build(styleId, key),
attribution: p.attribution,
maxZoom: p.maxZoom || 19,
subdomains: 'abc', // ignored by Leaflet when the URL has no {s}
}
}
// Resolve the active base tile for a map. With { satellite: true } it returns an
// imagery layer instead — MapTiler satellite when the user is on MapTiler with a
// key, otherwise free Esri World Imagery — so a "Satellite" toggle works for any
// configured provider.
export function resolveTile(settings, { satellite = false } = {}) {
if (satellite) {
if (settings?.provider === 'maptiler' && settings?.keys?.maptiler) {
return tileFor('maptiler', 'satellite', settings.keys)
}
return tileFor('esri', 'World_Imagery', {})
}
return tileFor(settings?.provider, settings?.style, settings?.keys || {})
}