- src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale)
returning Map<deviceId, Device>. deviceLabel() formats human-readable
titles ("FMB920 #3458").
- src/map/layers/map-positions.tsx: <MapPositions /> side-effect-only.
Two GeoJSON sources (clustered non-selected + unclustered selected).
Five layers: non-selected symbol + direction + cluster-bubble,
selected symbol + direction. Click handlers: marker -> selectDevice,
cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor.
- src/routes/_authed/monitor.tsx: renders <MapPositions /> inside
<MapView>.
Schema overhaul in src/auth/client.ts:
- Made Schema SDK-compatible. Each entry is an *array* of row types
(devices: DeviceRow[], not just DeviceRow). RegularCollections<Schema>
filters on array-like values; non-arrays collapse to never which
broke readItems('devices', ...) with keyof Schema = never.
- Spelled out the composed client type as DirectusClient<Schema> &
RestClient<Schema> & AuthenticationClient<Schema> — without the
explicit annotation Schema didn't flow through .with(...) chain to
request() call sites.
- Added DeviceRow + EventRow types; exported via @/auth.
useDevicesById uses readItems<Schema, 'devices', Query<Schema, DeviceRow>>
— explicit generics because the SDK doesn't infer Schema from the
receiver's type at call sites.
Deviations:
1. Spec referenced device.kind for category — Phase 1 schema doesn't
have it yet; everything maps to 'default'. Refine when kind lands.
2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated
'cluster-background' (not in 2.3's registry). Swap in 3.8.
3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was
callback-style); used .then().
Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor
chunk includes the new layer module (~10KB).
9.8 KiB
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 JSsingleton renders inside a detached<div>mounted by<MapView>. Sources and layers are added by side-effect-onlyMap*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-mapsadapter. - 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), sendssubscribefor the active event, receives a snapshot, then streams positions. - An rAF-coalescer buffers incoming positions per-device; one
setDatacall per frame regardless of message rate. MapPositionsrenders devices on two GeoJSON sources (clustered non-selected + unclustered selected). Cluster expansion and selection-on-click work.MapTrailsrenders 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
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 (noreact-map-glwrapper — see maps-architecture for why).maplibre-google-maps(optional, runtime-config-gated) — protocol adapter for Google's Map Tiles API. Loaded only ifgoogleMapsKeyis present in the runtime config.@types/geojson— devDep for typingFeature/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.
- MapLibre is a singleton. One
maplibregl.Mapinstance lives at module scope, attached to a detached<div>. React refs mount/unmount the div across page navigation. Never recreate the WebGL context. Map*components are side-effect-only. Each returnsnulland usesuseEffectfor setup + cleanup, with a separate effect forsetDataupdates. No DOM markers. Noreact-map-gl.- rAF coalescer at the WS boundary. Position messages buffer per-device; one
requestAnimationFrametick flushes the latest snapshot to the Zustand store. Per-message dispatch is the failure modetraccar-webexhibits — we don't replicate it. See maps-architecture §"WebSocket → map data flow". - 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.
- Style swaps reset the world. When the basemap changes, every custom source/layer is wiped. The
mapReadygate coordinates remount of allMap*components. - Native GeoJSON, no WKT. Geofences and any future spatial data come from Directus as GeoJSON via
ST_AsGeoJSON. The SPA never importswellknownor runs WKT parsing in the browser. - 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.1–2.9) done.
pnpm typecheck,pnpm lint,pnpm format:check,pnpm buildgreen.- 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.