Files
spa/.planning/phase-2-live-map/README.md
T
julian 25dcde093a feat: task 2.5 MapPositions (clustered + selected sources)
- 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).
2026-05-03 09:31:09 +02:00

135 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](./01-mapview-singleton.md) | 🟩 |
| 2.2 | [Tile-source switcher](./02-tile-source-switcher.md) | 🟩 |
| 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | 🟩 |
| 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | 🟩 |
| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 |
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ |
| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ |
| 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ |
| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ |
## 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.