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
This commit is contained in:
@@ -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/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/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
|
||||
- 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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
import { projectToTrack } from '../../utils/track'
|
||||
import { useResolvedTile } from '../../hooks/useMapSettings'
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
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',
|
||||
})
|
||||
|
||||
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
|
||||
// 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).
|
||||
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] })
|
||||
}
|
||||
|
||||
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 mapInstanceRef = useRef(null)
|
||||
const markerRef = useRef(null)
|
||||
@@ -167,10 +154,6 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
||||
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) => {
|
||||
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(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
|
||||
if (tileLayerRef.current) tileLayerRef.current.remove()
|
||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
|
||||
.addTo(mapInstanceRef.current)
|
||||
}, [mapType])
|
||||
tileLayerRef.current = L.tileLayer(tile.url, {
|
||||
attribution: tile.attribution, maxZoom: tile.maxZoom, subdomains: tile.subdomains, ...TILE_OPTS,
|
||||
}).addTo(mapInstanceRef.current)
|
||||
}, [tile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
|
||||
// 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}`
|
||||
import { useResolvedTile } from '../../hooks/useMapSettings'
|
||||
|
||||
function decodePolyline(encoded) {
|
||||
if (!encoded) return []
|
||||
@@ -28,6 +24,8 @@ function decodePolyline(encoded) {
|
||||
export default function RouteTileMap({ polyline, sportType, className = '' }) {
|
||||
const elRef = useRef(null)
|
||||
const mapRef = useRef(null)
|
||||
const tileRef = useRef(null)
|
||||
const tile = useResolvedTile()
|
||||
|
||||
useEffect(() => {
|
||||
if (!elRef.current || mapRef.current) return
|
||||
@@ -40,10 +38,17 @@ export default function RouteTileMap({ polyline, sportType, className = '' }) {
|
||||
zoomSnap: 0,
|
||||
})
|
||||
mapRef.current = map
|
||||
L.tileLayer(TILE_URL, { maxZoom: 22, subdomains: 'abc' }).addTo(map)
|
||||
return () => { map.remove(); mapRef.current = null }
|
||||
return () => { map.remove(); mapRef.current = null; tileRef.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(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export default function ActivityDetailPage() {
|
||||
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||
const [mapHeight, setMapHeight] = useState(420)
|
||||
const [mapType, setMapType] = useState('street')
|
||||
const [satellite, setSatellite] = useState(false)
|
||||
const [colorMode, setColorMode] = useState('speed')
|
||||
const [segCreate, setSegCreate] = useState(false)
|
||||
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
|
||||
@@ -294,18 +294,15 @@ export default function ActivityDetailPage() {
|
||||
{/* 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 gap-2">
|
||||
<span className="text-xs text-gray-500">Map style:</span>
|
||||
{['dark', 'street', 'satellite'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setMapType(t)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
|
||||
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
onClick={() => setSatellite(s => !s)}
|
||||
title="Map tiles are chosen globally in Profile › Map & Tiles"
|
||||
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||
satellite ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
🛰 Satellite
|
||||
</button>
|
||||
))}
|
||||
{dataPoints?.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
|
||||
@@ -381,7 +378,7 @@ export default function ActivityDetailPage() {
|
||||
dataPoints={dataPoints}
|
||||
hoveredDistance={hoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
mapType={mapType}
|
||||
satellite={satellite}
|
||||
colorMode={colorMode}
|
||||
onMapClick={segCreate ? handleMapClick : undefined}
|
||||
/>
|
||||
|
||||
@@ -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="lg:col-span-2 min-h-[180px] bg-gray-950">
|
||||
{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>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from '../hooks/useAuth'
|
||||
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",
|
||||
// "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' }) {
|
||||
return (
|
||||
<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) },
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
return (
|
||||
@@ -323,6 +347,55 @@ export default function ProfilePage() {
|
||||
)}
|
||||
</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 won’t 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 */}
|
||||
<Section title="Change Password">
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -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 || {})
|
||||
}
|
||||
Reference in New Issue
Block a user