feat: persist map tile settings (provider/style/API keys) server-side on user record; unbake Thunderforest default key into backend config (THUNDERFOREST_DEFAULT_KEY) served via profile
This commit is contained in:
@@ -120,7 +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)
|
- `hooks/useMapSettings.js` — Zustand store for the global map tile preference: provider + style + per-provider API keys. **Persisted server-side on the user record** (`users.map_settings` JSON), with localStorage only as a first-paint cache. `useHydrateMapSettings()` (called in `Layout`) loads it from `GET /profile/`; mutations debounce-save to `PUT /profile/map-settings`. The provider/style catalogue lives in `utils/mapTiles.js` (`MAP_PROVIDERS`, `resolveTile`); every Leaflet map (`ActivityMap`, `RouteTileMap`) resolves its base layer via `useResolvedTile()`. The default Thunderforest key is **not baked into the bundle** — the backend supplies it via `settings.thunderforest_default_key` in the profile response, and `useResolvedTile` falls back to it when the user hasn't set their own. 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.
|
||||||
@@ -175,6 +175,7 @@ Required in `.env` (or passed to Docker Compose):
|
|||||||
| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
|
| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
|
||||||
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) |
|
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) |
|
||||||
| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) |
|
| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) |
|
||||||
|
| `THUNDERFOREST_DEFAULT_KEY` | Default Thunderforest tile key served to clients without their own (public client-side key; has a built-in default) |
|
||||||
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
|
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
|
||||||
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
|
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
|
||||||
| `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` | Optional — enables Strava API OAuth live sync (register an app at strava.com/settings/api) |
|
| `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` | Optional — enables Strava API OAuth live sync (register an app at strava.com/settings/api) |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime, date, timezone
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user, hash_password, verify_password
|
from app.core.security import get_current_user, hash_password, verify_password
|
||||||
|
from app.core.config import settings
|
||||||
from app.models.user import User, WeightLog
|
from app.models.user import User, WeightLog
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -36,6 +37,8 @@ class ProfileOut(BaseModel):
|
|||||||
estimated_max_hr: Optional[int]
|
estimated_max_hr: Optional[int]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
dashboard_layout: Optional[list] = None
|
dashboard_layout: Optional[list] = None
|
||||||
|
map_settings: Optional[dict] = None
|
||||||
|
thunderforest_default_key: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -45,6 +48,12 @@ class DashboardLayoutIn(BaseModel):
|
|||||||
layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h}
|
layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h}
|
||||||
|
|
||||||
|
|
||||||
|
class MapSettingsIn(BaseModel):
|
||||||
|
provider: Optional[str] = None
|
||||||
|
style: Optional[str] = None
|
||||||
|
keys: Optional[dict] = None # {thunderforest, maptiler, ...} public tile keys
|
||||||
|
|
||||||
|
|
||||||
def _estimated_max_hr(user: User) -> Optional[int]:
|
def _estimated_max_hr(user: User) -> Optional[int]:
|
||||||
if user.birth_year:
|
if user.birth_year:
|
||||||
return 220 - (datetime.now().year - user.birth_year)
|
return 220 - (datetime.now().year - user.birth_year)
|
||||||
@@ -55,7 +64,27 @@ def _estimated_max_hr(user: User) -> Optional[int]:
|
|||||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||||
return {**{c.name: getattr(current_user, c.name)
|
return {**{c.name: getattr(current_user, c.name)
|
||||||
for c in User.__table__.columns},
|
for c in User.__table__.columns},
|
||||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
"estimated_max_hr": _estimated_max_hr(current_user),
|
||||||
|
"thunderforest_default_key": settings.thunderforest_default_key}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/map-settings")
|
||||||
|
async def save_map_settings(
|
||||||
|
body: MapSettingsIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Persist the user's global map tile preference (provider/style/keys)."""
|
||||||
|
keys = {}
|
||||||
|
for k, v in (body.keys or {}).items():
|
||||||
|
keys[str(k)] = (v or "").strip()
|
||||||
|
current_user.map_settings = {
|
||||||
|
"provider": body.provider,
|
||||||
|
"style": body.style,
|
||||||
|
"keys": keys,
|
||||||
|
}
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/dashboard-layout")
|
@router.put("/dashboard-layout")
|
||||||
@@ -111,7 +140,8 @@ async def update_profile(
|
|||||||
|
|
||||||
return {**{c.name: getattr(current_user, c.name)
|
return {**{c.name: getattr(current_user, c.name)
|
||||||
for c in User.__table__.columns},
|
for c in User.__table__.columns},
|
||||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
"estimated_max_hr": _estimated_max_hr(current_user),
|
||||||
|
"thunderforest_default_key": settings.thunderforest_default_key}
|
||||||
|
|
||||||
|
|
||||||
# ── Password change ────────────────────────────────────────────────────────
|
# ── Password change ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class Settings(BaseSettings):
|
|||||||
# The Authorization Callback Domain there must match BASE_URL's host.
|
# The Authorization Callback Domain there must match BASE_URL's host.
|
||||||
strava_client_id: Optional[str] = Field(None, env="STRAVA_CLIENT_ID")
|
strava_client_id: Optional[str] = Field(None, env="STRAVA_CLIENT_ID")
|
||||||
strava_client_secret: Optional[str] = Field(None, env="STRAVA_CLIENT_SECRET")
|
strava_client_secret: Optional[str] = Field(None, env="STRAVA_CLIENT_SECRET")
|
||||||
|
# Default Thunderforest tile API key, served to clients that haven't set
|
||||||
|
# their own (a public, client-side tile key). Override per-deployment.
|
||||||
|
thunderforest_default_key: str = Field(
|
||||||
|
"872984f587484873a74ea454662ffacb", env="THUNDERFOREST_DEFAULT_KEY"
|
||||||
|
)
|
||||||
# Files
|
# Files
|
||||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"users.dashboard_layout column migration skipped: {e}")
|
print(f"users.dashboard_layout column migration skipped: {e}")
|
||||||
|
|
||||||
|
# map_settings column on users added after initial creation
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS map_settings JSON"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"users.map_settings column migration skipped: {e}")
|
||||||
|
|
||||||
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
||||||
try:
|
try:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class User(Base):
|
|||||||
# Saved dashboard widget layout (react-grid-layout array). Null = use default.
|
# Saved dashboard widget layout (react-grid-layout array). Null = use default.
|
||||||
dashboard_layout = Column(JSON, nullable=True)
|
dashboard_layout = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Global map tile preference: {provider, style, keys:{thunderforest, maptiler}}.
|
||||||
|
# Null = use defaults. Tile API keys are public client-side keys.
|
||||||
|
map_settings = Column(JSON, nullable=True)
|
||||||
|
|
||||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../hooks/useAuth'
|
import { useAuthStore } from '../../hooks/useAuth'
|
||||||
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
||||||
|
import { useHydrateMapSettings } from '../../hooks/useMapSettings'
|
||||||
import UnitToggle from './UnitToggle'
|
import UnitToggle from './UnitToggle'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
@@ -24,6 +25,9 @@ export default function Layout() {
|
|||||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
|
|
||||||
|
// Load the user's saved map tile preference from the server into the store.
|
||||||
|
useHydrateMapSettings()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startPolling()
|
startPolling()
|
||||||
return () => stopPolling()
|
return () => stopPolling()
|
||||||
|
|||||||
@@ -1,56 +1,116 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useEffect } from 'react'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { useAuthStore } from './useAuth'
|
||||||
import { MAP_PROVIDERS, DEFAULT_MAP_SETTINGS, resolveTile } from '../utils/mapTiles'
|
import { MAP_PROVIDERS, DEFAULT_MAP_SETTINGS, resolveTile } from '../utils/mapTiles'
|
||||||
|
|
||||||
// Global map tile preference (provider + style + per-provider API keys). Chosen
|
// Global map tile preference (provider + style + per-provider API keys). The
|
||||||
// once on the Profile page and used by every Leaflet map in the app. Persisted
|
// source of truth is the user record on the server (so the choice and keys
|
||||||
// to localStorage so the choice sticks across reloads (mirrors useUnits). API
|
// follow the user across devices); localStorage is only a cache so maps render
|
||||||
// keys live client-side only — these are public, client-side tile keys.
|
// correctly on first paint before the server hydrates. The built-in default
|
||||||
const KEY = 'mapSettings'
|
// Thunderforest key is supplied by the server (not baked into this bundle).
|
||||||
|
const CACHE = 'mapSettings'
|
||||||
|
|
||||||
function load() {
|
function loadCache() {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(KEY))
|
const s = JSON.parse(localStorage.getItem(CACHE))
|
||||||
if (saved && typeof saved === 'object') {
|
if (s && typeof s === 'object') return s
|
||||||
return { ...DEFAULT_MAP_SETTINGS, ...saved, keys: { ...saved.keys } }
|
} catch { /* ignore malformed cache */ }
|
||||||
}
|
return {}
|
||||||
} catch { /* ignore malformed storage */ }
|
|
||||||
return { ...DEFAULT_MAP_SETTINGS, keys: {} }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function persist(s) {
|
function writeCache(s) {
|
||||||
localStorage.setItem(KEY, JSON.stringify({ provider: s.provider, style: s.style, keys: s.keys }))
|
localStorage.setItem(CACHE, JSON.stringify({
|
||||||
|
provider: s.provider,
|
||||||
|
style: s.style,
|
||||||
|
keys: s.keys,
|
||||||
|
defaultThunderforestKey: s.defaultThunderforestKey,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMapSettingsStore = create((set) => ({
|
// First style id for a provider, used when switching provider or validating.
|
||||||
...load(),
|
const firstStyle = (provider) => Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0]
|
||||||
setProvider: (provider) => set((s) => {
|
|
||||||
// Reset the style to the provider's first available style.
|
let saveTimer = null
|
||||||
const styleId = Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0]
|
const cache0 = loadCache()
|
||||||
const next = { ...s, provider, style: styleId }
|
|
||||||
persist(next)
|
export const useMapSettingsStore = create((set, get) => ({
|
||||||
return next
|
provider: cache0.provider || DEFAULT_MAP_SETTINGS.provider,
|
||||||
}),
|
style: cache0.style || DEFAULT_MAP_SETTINGS.style,
|
||||||
setStyle: (style) => set((s) => {
|
keys: cache0.keys || {},
|
||||||
const next = { ...s, style }
|
defaultThunderforestKey: cache0.defaultThunderforestKey || '',
|
||||||
persist(next)
|
hydrated: false,
|
||||||
return next
|
|
||||||
}),
|
// Populate from the server profile. Only marks the store ready to persist
|
||||||
setKey: (provider, value) => set((s) => {
|
// after this runs, so we never clobber the server with stale cache values.
|
||||||
const next = { ...s, keys: { ...s.keys, [provider]: value } }
|
hydrate: (server, defaultKey) => set((s) => {
|
||||||
persist(next)
|
const ms = server || {}
|
||||||
|
const provider = MAP_PROVIDERS[ms.provider] ? ms.provider : s.provider
|
||||||
|
const style = MAP_PROVIDERS[provider]?.styles[ms.style] ? ms.style
|
||||||
|
: (MAP_PROVIDERS[provider]?.styles[s.style] ? s.style : firstStyle(provider))
|
||||||
|
const next = {
|
||||||
|
...s,
|
||||||
|
provider,
|
||||||
|
style,
|
||||||
|
keys: ms.keys || s.keys || {},
|
||||||
|
defaultThunderforestKey: defaultKey || s.defaultThunderforestKey || '',
|
||||||
|
hydrated: true,
|
||||||
|
}
|
||||||
|
writeCache(next)
|
||||||
return next
|
return next
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Debounced persist of the full settings to the user record.
|
||||||
|
_save: () => {
|
||||||
|
const s = get()
|
||||||
|
if (!s.hydrated) return
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
api.put('/profile/map-settings', { provider: s.provider, style: s.style, keys: s.keys })
|
||||||
|
.catch(() => { /* non-fatal; cache keeps the local choice */ })
|
||||||
|
}, 600)
|
||||||
|
},
|
||||||
|
|
||||||
|
setProvider: (provider) => {
|
||||||
|
set((s) => { const next = { ...s, provider, style: firstStyle(provider) }; writeCache(next); return next })
|
||||||
|
get()._save()
|
||||||
|
},
|
||||||
|
setStyle: (style) => {
|
||||||
|
set((s) => { const next = { ...s, style }; writeCache(next); return next })
|
||||||
|
get()._save()
|
||||||
|
},
|
||||||
|
setKey: (provider, value) => {
|
||||||
|
set((s) => { const next = { ...s, keys: { ...s.keys, [provider]: value } }; writeCache(next); return next })
|
||||||
|
get()._save()
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Hydrate the store from the server profile once authenticated. Call once near
|
||||||
|
// the app root (Layout). Reuses the shared ['profile'] query cache.
|
||||||
|
export function useHydrateMapSettings() {
|
||||||
|
const token = useAuthStore((s) => s.token)
|
||||||
|
const hydrate = useMapSettingsStore((s) => s.hydrate)
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: () => api.get('/profile/').then((r) => r.data),
|
||||||
|
enabled: !!token,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) hydrate(data.map_settings, data.thunderforest_default_key)
|
||||||
|
}, [data])
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the active tile layer config for a map. Pass satellite=true for an
|
// Resolve the active tile layer config for a map. Pass satellite=true for an
|
||||||
// imagery override (e.g. the per-activity satellite toggle).
|
// imagery override (e.g. the per-activity satellite toggle). The Thunderforest
|
||||||
|
// key falls back to the server-supplied default when the user hasn't set one.
|
||||||
export function useResolvedTile(satellite = false) {
|
export function useResolvedTile(satellite = false) {
|
||||||
const provider = useMapSettingsStore((s) => s.provider)
|
const provider = useMapSettingsStore((s) => s.provider)
|
||||||
const style = useMapSettingsStore((s) => s.style)
|
const style = useMapSettingsStore((s) => s.style)
|
||||||
const keys = useMapSettingsStore((s) => s.keys)
|
const keys = useMapSettingsStore((s) => s.keys)
|
||||||
return useMemo(
|
const defaultTf = useMapSettingsStore((s) => s.defaultThunderforestKey)
|
||||||
() => resolveTile({ provider, style, keys }, { satellite }),
|
return useMemo(() => {
|
||||||
[provider, style, keys, satellite],
|
const effectiveKeys = { ...keys, thunderforest: keys.thunderforest || defaultTf }
|
||||||
)
|
return resolveTile({ provider, style, keys: effectiveKeys }, { satellite })
|
||||||
|
}, [provider, style, keys, defaultTf, satellite])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,6 @@
|
|||||||
// stored in the useMapSettings store; every Leaflet map resolves its base layer
|
// stored in the useMapSettings store; every Leaflet map resolves its base layer
|
||||||
// through resolveTile() so a single setting drives the entire application.
|
// 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>'
|
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
|
// Each provider lists its selectable styles (keyed by the id used in the tile
|
||||||
@@ -34,7 +29,7 @@ export const MAP_PROVIDERS = {
|
|||||||
'spinal-map': { label: 'Spinal' },
|
'spinal-map': { label: 'Spinal' },
|
||||||
},
|
},
|
||||||
build: (styleId, key) =>
|
build: (styleId, key) =>
|
||||||
`https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || DEFAULT_THUNDERFOREST_KEY}`,
|
`https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || ''}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
maptiler: {
|
maptiler: {
|
||||||
|
|||||||
Reference in New Issue
Block a user