Files
spa/.planning/phase-2-live-map/02-tile-source-switcher.md
T
julian 05543529e4 docs(planning): file Phase 2 task specs (live monitoring map)
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec /
Acceptance / Risks / Done). README updated with full sequencing diagram,
files-modified outline, tech stack additions, design rules, and phase
acceptance.

| #   | Task                                                                  |
| --- | --------------------------------------------------------------------- |
| 2.1 | MapView singleton + mapReady gate                                     |
| 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google)     |
| 2.3 | Sprite preload — 7 racing categories x 4 colour variants              |
| 2.4 | WS client + rAF coalescer + Zustand position store + connection store |
| 2.5 | MapPositions — clustered + selected sources                           |
| 2.6 | MapTrails — bounded ring buffer, polyline rendering                   |
| 2.7 | Event picker — TanStack Query + WS subscription orchestration         |
| 2.8 | Camera control trio — default-fit / selected-follow / one-shot        |
| 2.9 | Connection status + per-device last-seen indicators                   |

Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data
pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently.
2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5.

Each task documents its deliverables down to file paths + interface
shapes, includes concrete code sketches in the Specification, lists
explicit out-of-scope items, and surfaces risks for the implementer
to think about. An agent (or future me) can pick up any single task
and ship it without re-deriving the design from the wiki.

Resolved Phase 2 design decisions baked into the task files:
- Trails: flat-colour-per-device for v1, defer speed-coloured segments
  to a Phase 3 polish task.
- Cluster params: 14/50 (traccar default); tune after seeing real data.
- Event picker placement: top-left dropdown.
- Multi-event: out — single-select, one event at a time.
- Stale-position visual: fade icon opacity; defer warning badges.
2026-05-03 09:28:16 +02:00

8.5 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

(Filled in when the task lands.)