230 lines
11 KiB
Markdown
230 lines
11 KiB
Markdown
# 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`** — `<BasemapSwitcher />` 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 `<BasemapSwitcher />` as a child of `<MapView>`.
|
|
- **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 (
|
|
<div className="absolute top-3 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden">
|
|
{available.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => switchTo(s)}
|
|
className={cn(
|
|
'block px-3 py-1.5 text-sm w-full text-left',
|
|
current === s.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
|
|
)}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
`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`** — `<BasemapSwitcher />` 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 `<BasemapSwitcher />` as a child of `<MapView>`.
|
|
- **`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`.
|