# Task 2.2 — Tile-source switcher **Phase:** 2 — Live monitoring map **Status:** ⬜ Not started **Depends on:** 2.1. **Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Tile sources"; `docs/wiki/sources/traccar-maps-architecture.md` §3. ## Goal Let the operator switch the basemap between Esri World Imagery (satellite), OpenTopoMap (terrain), and OSM (street) — plus an optional Google Satellite source when an operator-supplied API key is present in the runtime config. Style swaps trigger the `mapReady` gate (2.1) so child components remount cleanly. After this task, a small floating control on the map lets operators pick the basemap that fits the rally context (satellite for navigation, topo for mountain stages, OSM for sanity). ## Deliverables - **`src/map/core/styles.ts`** (replacing the 2.1 stub) — exports a `BASEMAP_STYLES: BasemapDescriptor[]` array. Each descriptor: ```ts type BasemapDescriptor = { id: 'esri-satellite' | 'opentopo' | 'osm' | 'google-satellite'; label: string; style: maplibregl.StyleSpecification | string; // inline raster style or URL available: (cfg: RuntimeConfig) => boolean; // gates Google on googleMapsKey attribution: string; }; ``` Inline-built raster styles for Esri / OpenTopoMap / OSM via a `styleCustom({ tiles, attribution })` helper that mirrors traccar-web's pattern (single raster source + raster layer). Google variant builds a `google://` source when the key is present. - **`src/map/core/basemap-switcher.tsx`** — `` floating control: - Top-right of the map (a small ink-bordered card with three / four buttons). - Selected basemap stored in Zustand (`useMapPrefStore`) so the choice persists across route navigations within the session. - On click, calls `map.setStyle(descriptor.style)` and lets the `mapReady` gate handle the unmount/remount of children. - **`src/map/core/map-pref-store.ts`** — small Zustand store for map preferences: - `basemapId: BasemapDescriptor['id']` - `setBasemap(id): void` - Persists to `localStorage` via Zustand's `persist` middleware (key: `trm-map-prefs`). - **`src/routes/_authed/monitor.tsx`** updated — render `` as a child of ``. - **Optional: `pnpm add maplibre-google-maps`** — only if Google Satellite is being shipped at the same time. The module registers a `google://` protocol handler. Loaded lazily so the `~50KB` adapter doesn't ship on instances that don't use Google tiles. Concretely: a dynamic `import()` inside `BASEMAP_STYLES`'s init code path, gated on `runtimeConfig.googleMapsKey` presence. ## Specification ### `styleCustom` helper ```ts function styleCustom(opts: { tiles: string[]; attribution: string; minZoom?: number; maxZoom?: number; }): maplibregl.StyleSpecification { return { version: 8, sources: { raster: { type: 'raster', tiles: opts.tiles, tileSize: 256, attribution: opts.attribution, minzoom: opts.minZoom ?? 0, maxzoom: opts.maxZoom ?? 19, }, }, glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf', layers: [{ id: 'raster', type: 'raster', source: 'raster' }], }; } ``` The `glyphs` URL lets symbol layers (sprites + labels added in 2.3+) render text on raster basemaps. ### Initial set ```ts export const BASEMAP_STYLES: BasemapDescriptor[] = [ { id: 'esri-satellite', label: 'Satellite', style: styleCustom({ tiles: [ 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', ], attribution: 'Tiles © Esri', maxZoom: 19, }), available: () => true, attribution: 'Esri World Imagery', }, { id: 'opentopo', label: 'Topo', style: styleCustom({ tiles: [ 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png', 'https://b.tile.opentopomap.org/{z}/{x}/{y}.png', 'https://c.tile.opentopomap.org/{z}/{x}/{y}.png', ], attribution: '© OpenTopoMap (CC-BY-SA)', maxZoom: 17, }), available: () => true, attribution: 'OpenTopoMap', }, { id: 'osm', label: 'Street', style: styleCustom({ tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], attribution: '© OpenStreetMap contributors', maxZoom: 19, }), available: () => true, attribution: 'OpenStreetMap', }, { id: 'google-satellite', label: 'Google', style: ((cfg) => styleCustom({ tiles: [`google://satellite/{z}/{x}/{y}?key=${cfg.googleMapsKey}`], attribution: '© Google', maxZoom: 22, })) as unknown as maplibregl.StyleSpecification, // ↑ build lazily inside the switcher; the descriptor above is illustrative. available: (cfg) => Boolean(cfg.googleMapsKey), attribution: 'Google Maps', }, ]; ``` (Build the Google entry's `style` lazily at switch time using the runtime config; the descriptor table above shows the shape.) ### Lazy `maplibre-google-maps` registration ```ts // Inside the basemap switcher, on first use of google-satellite: async function ensureGoogleProtocol() { if ((maplibregl as any).__trmGoogleRegistered) return; const { googleProtocol } = await import('maplibre-google-maps'); maplibregl.addProtocol('google', googleProtocol); (maplibregl as any).__trmGoogleRegistered = true; } ``` Skip the lazy-load entirely if `runtimeConfig.googleMapsKey` is empty — the descriptor's `available()` returns false and the option doesn't appear in the switcher. ### Basemap-switcher UI Small floating card, top-right of the map: ```tsx export function BasemapSwitcher() { const cfg = useRuntimeConfig(); const current = useMapPrefStore((s) => s.basemapId); const setBasemap = useMapPrefStore((s) => s.setBasemap); const available = BASEMAP_STYLES.filter((s) => s.available(cfg)); return (
{available.map((s) => ( ))}
); } ``` `switchTo(descriptor)` is the function that: 1. (If google) `await ensureGoogleProtocol()`. 2. Calls `map.setStyle(descriptor.style)`. 3. Updates `useMapPrefStore` so the choice persists. The actual setStyle triggers `styledata` → `mapReady` flips false → flips true once loaded → children remount. ### Persistence Zustand `persist` middleware on `useMapPrefStore`. Key `trm-map-prefs`. Selected basemap survives reloads. On first visit, default to `esri-satellite` — the most universally useful for rally context. ## Acceptance criteria - [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. - [ ] `/monitor` shows the basemap switcher top-right with three buttons (Satellite / Topo / Street). - [ ] Clicking a button changes the basemap. The selected button is visually highlighted. - [ ] Reloading the page restores the previously selected basemap. - [ ] If `googleMapsKey` is set in `config.json`, a fourth "Google" button appears. Clicking it loads Google satellite tiles via the adapter. - [ ] If `googleMapsKey` is empty/missing, the Google button is hidden — and `maplibre-google-maps` is not imported (verify in DevTools network tab; the chunk should not appear). - [ ] After a basemap switch, `useMapReady()` flips to false then back to true within ~500ms. Console shows no errors. ## Risks / open questions - **OSM tile-server policy.** `tile.openstreetmap.org` is rate-limited and asks for a User-Agent. For low-traffic dogfood it's fine; for production we'd self-host or switch to a paid OSM mirror (MapTiler / Stadia). Document but defer. - **Esri ToS.** Esri's "World Imagery" raster service is free for non-commercial use with attribution. Confirm before going public; for a closed dogfood it's clearly fine. - **OpenTopoMap rate limits.** The official tile servers are best-effort. Same defer-until-it-bites posture. - **Google's official Map Tiles API requires session tokens.** `maplibre-google-maps` handles this internally per the adapter's docs. Confirm the API key has the Map Tiles API enabled (not just JS Maps). - **Style switcher placement.** Top-right floats over the map; if Phase 3.4's per-device detail panel goes top-right too, we'll need to reposition. For Phase 2 v1, top-right is fine. ## Done - **`src/map/core/styles.ts`** — replaces 2.1's stub with the full `BasemapDescriptor` model. Each entry has `id`, `label`, `available(cfg)`, `buildStyle(cfg)`. Four entries: Esri World Imagery (satellite), OpenTopoMap (topo), OSM (street), Google Satellite (gated on `cfg.googleMapsKey`). `styleCustom()` helper synthesises a single-raster-source MapLibre style with a `glyphs` URL so future symbol layers render text. `defaultStyle` retained as the bootstrap. `ensureGoogleProtocol()` lazy-loads `maplibre-google-maps` on first use of the Google variant; `_googleRegistered` guards re-registration. - **`src/map/core/map-pref-store.ts`** — Zustand store with `persist` middleware (`name: 'trm-map-prefs'`, `version: 1`). Holds `basemapId` + `setBasemap`. Survives reloads via localStorage. - **`src/map/core/basemap-switcher.tsx`** — `` floating control top-right. Reads available basemaps (filtered by `available(cfg)`), highlights the active one, applies on click via `getMap().setStyle(...)`. Mount-time bootstrap effect applies the persisted choice once at first paint; user-driven `onClick` keeps a `switching` state for visual feedback. - **`src/routes/_authed/monitor.tsx`** — renders `` as a child of ``. - **`src/vite-env.d.ts`** — `declare module 'maplibre-google-maps'` ambient declaration; the package ships JS only, no `.d.ts`. **Deps installed:** `maplibre-google-maps@1.1.0` (runtime; lazy-imported only when the Google basemap is selected). **Deviations from spec:** 1. Spec sketched `applyBasemap` as a unified function used by both the mount-bootstrap effect and the user-driven `onClick`. React 19's new `react-hooks/set-state-in-effect` rule flagged the `setSwitching` call inside the bootstrap effect as a cascading-render risk. Split into two paths: bootstrap calls `setStyle` directly without UI state (no need — the map is already showing OSM, the swap is silent); `onClick` keeps `setSwitching` for the per-click visual feedback. Cleaner anyway. 2. Spec sketched `BasemapDescriptor.style` as `StyleSpecification | string`. Implemented as `buildStyle: (cfg) => StyleSpecification` — a function. Reason: the Google variant's `style` depends on `cfg.googleMapsKey`, so it has to be computed at switch time. Uniform function shape across all entries keeps the type clean. **Smoke check (local `pnpm dev`):** - `/monitor` shows three buttons top-right: Satellite / Topo / Street. Clicking each switches the basemap. - Selected button highlights via `bg-accent`. - Reloading the page restores the previously selected basemap. - With `googleMapsKey` empty in `public/config.json`, the Google button is hidden and `maplibre-google-maps` doesn't appear in the network tab. **Bundle:** MapLibre is now its own chunk (`maplibre-gl-*.js`, 1MB / 273KB gzipped). Main bundle 371KB / 114KB gzipped. Landed in `3a9e07b`.