Files
spa/.planning/phase-2-live-map

Phase 2 — Live monitoring map

Status: Not started — depends on processor Phase 1.5 landing (already shipped).

The dogfood-day deliverable. After Phase 2, an operator opens the SPA, picks the active event, and watches the field move on a real-time map. Inherits the architecture documented in docs/wiki/concepts/maps-architecture.md from docs/wiki/sources/traccar-maps-architecture.md, with the deliberate divergences (rAF coalescer, Zustand, longer trail default, racing sprite set, native PostGIS GeoJSON) baked in from day one.

Outcome statement

When Phase 2 is done:

  • A MapLibre GL JS singleton renders inside a detached <div> mounted by <MapView>. Sources and layers are added by side-effect-only Map* components.
  • The user can switch basemap between Esri World Imagery (satellite), OpenTopoMap, OSM raster, and (if a Google Maps key is in runtime config) Google Satellite via the maplibre-google-maps adapter.
  • Sprite registry is preloaded at app boot: rally-car / quad / ssv / motorcycle / runner / hiker / default × success / error / neutral / info.
  • The SPA opens a WebSocket to Processor (per docs/wiki/synthesis/processor-ws-contract.md), sends subscribe for the active event, receives a snapshot, then streams positions.
  • An rAF-coalescer buffers incoming positions per-device; one setData call per frame regardless of message rate.
  • MapPositions renders devices on two GeoJSON sources (clustered non-selected + unclustered selected). Cluster expansion and selection-on-click work.
  • MapTrails renders a per-device bounded ring buffer of recent positions as a polyline. Default 200 points; configurable.
  • An event picker (sidebar or top bar) lets the operator switch between events they have access to.
  • Camera control trio (MapCamera / MapDefaultCamera / MapSelectedDevice) handles fit-on-load, follow-selected, and manual interaction.
  • A connection-status indicator shows WS state (connected / reconnecting / offline) and last-message-received age per device (subtle UI; doesn't dominate).

Why this is a separate phase

  • Foundation must be solid. Auth, routing, deploy, runtime config — all must work before adding the live channel. Phase 1 ships an empty shell on stage; Phase 2 fills it.
  • Depends on the processor side. The WS contract is locked, but the producing endpoint must exist before the SPA can connect to it. processor Phase 1.5 is the gating dependency — already shipped (c07ea0e / f4b50ca / 2f2cf5c).
  • Map architecture is non-trivial. The singleton + side-effect-component + rAF coalescer + GeoJSON setData stack is a coherent pattern that works as a whole. Bundling it into Phase 1 would have inflated Phase 1 dramatically.

Sequencing

2.1 MapView singleton + mapReady gate ────┐
                                          ├─→ 2.2 Tile-source switcher
                                          ├─→ 2.3 Sprite preload
                                          └─→ 2.5 MapPositions (also needs 2.4)

2.4 WS client + rAF coalescer + store ────┐
                                          ├─→ 2.5 MapPositions
                                          ├─→ 2.6 MapTrails
                                          ├─→ 2.7 Event picker
                                          └─→ 2.9 Connection status

2.5 + 2.1 ─→ 2.8 Camera trio

2.1 and 2.4 are the two parallel foundations — start them in either order or in parallel. Once both land, 2.5 / 2.6 / 2.7 / 2.9 can fan out independently. 2.2 / 2.3 only need 2.1. 2.8 sits at the end, leaning on what 2.5 surfaces (selected device).

Tasks

# Task Status
2.1 MapView singleton + mapReady gate 🟩
2.2 Tile-source switcher 🟩
2.3 Sprite preload (racing categories) 🟩
2.4 WS client + rAF coalescer + Zustand position store
2.5 MapPositions (clustered + selected sources)
2.6 MapTrails (bounded ring buffer, polyline rendering)
2.7 Event picker (subscription driver)
2.8 Camera control trio
2.9 Connection status + per-device last-seen indicators

Files modified

Phase 2 adds these to the existing spa/ layout:

spa/
├── src/
│   ├── routes/
│   │   └── _authed/
│   │       └── monitor.tsx          # new — the live-map page
│   ├── map/
│   │   ├── core/
│   │   │   ├── map-view.tsx         # singleton + <MapView> + mapReady gate
│   │   │   ├── styles.ts            # tile-source descriptors
│   │   │   ├── basemap-switcher.tsx
│   │   │   ├── sprite-preload.ts    # racing sprite registry
│   │   │   └── camera/              # MapDefaultCamera, MapSelectedDevice, MapCamera
│   │   ├── layers/
│   │   │   ├── map-positions.tsx    # symbol + direction + cluster
│   │   │   └── map-trails.tsx       # polyline per device
│   │   └── assets/
│   │       └── icons/               # racing-category SVGs
│   ├── live/
│   │   ├── ws-client.ts             # connect, subscribe, reconnect
│   │   ├── coalescer.ts             # rAF-coalesced WS-to-store pipe
│   │   ├── position-store.ts        # Zustand: latestByDevice, trailsByDevice, selection
│   │   └── connection-store.ts      # Zustand: WS status (connected/reconnecting/offline)
│   └── ui/
│       └── components/
│           ├── event-picker.tsx
│           ├── connection-chip.tsx
│           └── device-last-seen.tsx

Tech stack additions

  • maplibre-gl — the rendering engine. Imported directly (no react-map-gl wrapper — see maps-architecture for why).
  • maplibre-google-maps (optional, runtime-config-gated) — protocol adapter for Google's Map Tiles API. Loaded only if googleMapsKey is present in the runtime config.
  • @types/geojson — devDep for typing Feature / FeatureCollection.
  • pmtiles (optional, defer) — for offline tile archives in remote terrain. Out of scope for v1 of Phase 2.

No new test infra (Vitest is Phase 3.6).

Non-negotiable design rules

These rules govern every task in this phase. Any deviation must be discussed and documented before code lands.

  1. MapLibre is a singleton. One maplibregl.Map instance lives at module scope, attached to a detached <div>. React refs mount/unmount the div across page navigation. Never recreate the WebGL context.
  2. Map* components are side-effect-only. Each returns null and uses useEffect for setup + cleanup, with a separate effect for setData updates. No DOM markers. No react-map-gl.
  3. rAF coalescer at the WS boundary. Position messages buffer per-device; one requestAnimationFrame tick flushes the latest snapshot to the Zustand store. Per-message dispatch is the failure mode traccar-web exhibits — we don't replicate it. See maps-architecture §"WebSocket → map data flow".
  4. Trails are bounded. Per-device ring buffer with default 200 points; never unbounded. Without this, a 24h race accumulates millions of points client-side and the tab dies.
  5. Style swaps reset the world. When the basemap changes, every custom source/layer is wiped. The mapReady gate coordinates remount of all Map* components.
  6. Native GeoJSON, no WKT. Geofences and any future spatial data come from Directus as GeoJSON via ST_AsGeoJSON. The SPA never imports wellknown or runs WKT parsing in the browser.
  7. Connection status is observable, not noisy. WS state is shown in a small chip in the header; per-device "last seen" lives in supplementary UI. Operators glance, they don't have it shoved in their face.

Acceptance for the phase as a whole

  • All nine tasks (2.12.9) done.
  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm build green.
  • Manual smoke against stage with the Rally Albania 2026 seed: open /monitor, see the basemap, see the seed devices' last positions as map markers within the snapshot. Publish a synthetic position to Redis (or wait for a real device to report) and confirm the marker moves within ~100ms.
  • Switch basemap between Esri / OpenTopoMap / OSM. All three render. The custom sources and layers reappear after each switch (no stale state).
  • Click a device. Selected source layers it on top; the camera follows it.
  • Click a cluster. Map zooms to the cluster's extent.
  • Disconnect the network. The connection chip flips to "reconnecting" within a few seconds; reconnect when the network comes back; subscriptions re-issue.
  • No regressions in Phase 1's auth + routing flows.

Out of scope (deferred to Phase 3 / Phase 4)

  • Geometry editor. CRUD on geofences / waypoints / SLZs. Depends on Phase 2 of directus for the collections to exist. → SPA Phase 4 candidate.
  • Replay mode. Historical-position playback. → SPA Phase 4.
  • Heatmaps / hexbin / deck.gl. Density visualisation. → SPA Phase 4.
  • Per-device detail panel. Phase 3 dogfood readiness (3.4).
  • Visual brand pass. TRM design system adoption. Phase 3.8.
  • Vitest setup. Phase 3.6.