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/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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 [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>
|
<button
|
||||||
{['dark', 'street', 'satellite'].map(t => (
|
onClick={() => setSatellite(s => !s)}
|
||||||
<button
|
title="Map tiles are chosen globally in Profile › Map & Tiles"
|
||||||
key={t}
|
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||||
onClick={() => setMapType(t)}
|
satellite ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||||
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'
|
>
|
||||||
}`}
|
🛰 Satellite
|
||||||
>
|
</button>
|
||||||
{t}
|
|
||||||
</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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 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 */}
|
{/* Password change */}
|
||||||
<Section title="Change Password">
|
<Section title="Change Password">
|
||||||
<div className="space-y-3">
|
<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