From ffe285b7bab45ff2095e116798847a88f7d28ad1 Mon Sep 17 00:00:00 2001 From: owain Date: Tue, 23 Jun 2026 12:29:50 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 1 + .../src/components/activity/ActivityMap.jsx | 33 ++--- frontend/src/components/ui/RouteTileMap.jsx | 19 ++- frontend/src/hooks/useMapSettings.js | 56 ++++++++ frontend/src/pages/ActivityDetailPage.jsx | 25 ++-- frontend/src/pages/DashboardPage.jsx | 2 +- frontend/src/pages/ProfilePage.jsx | 73 ++++++++++ frontend/src/utils/mapTiles.js | 126 ++++++++++++++++++ 8 files changed, 288 insertions(+), 47 deletions(-) create mode 100644 frontend/src/hooks/useMapSettings.js create mode 100644 frontend/src/utils/mapTiles.js diff --git a/CLAUDE.md b/CLAUDE.md index 9bc3331..872d785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx index d8482a0..dc4d10e 100644 --- a/frontend/src/components/activity/ActivityMap.jsx +++ b/frontend/src/components/activity/ActivityMap.jsx @@ -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: '© OSM © CARTO', - }, - street: { - url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', - attribution: '© OSM © CARTO', - }, - satellite: { - url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - attribution: '© Esri', - }, -} - // 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 diff --git a/frontend/src/components/ui/RouteTileMap.jsx b/frontend/src/components/ui/RouteTileMap.jsx index 472ae38..8362776 100644 --- a/frontend/src/components/ui/RouteTileMap.jsx +++ b/frontend/src/components/ui/RouteTileMap.jsx @@ -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 diff --git a/frontend/src/hooks/useMapSettings.js b/frontend/src/hooks/useMapSettings.js new file mode 100644 index 0000000..a5fd2d6 --- /dev/null +++ b/frontend/src/hooks/useMapSettings.js @@ -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], + ) +} diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 9e3eb79..6089dd2 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -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 */}
- Map style: - {['dark', 'street', 'satellite'].map(t => ( - - ))} + {dataPoints?.length > 0 && (