Files
spa/.planning/phase-2-live-map/02-tile-source-switcher.md
T

11 KiB

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:
    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

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

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

// 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:

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 styledatamapReady 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.tsdeclare 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.