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.
This commit is contained in:
2026-05-02 20:14:17 +02:00
parent c833d6f3dd
commit 05543529e4
10 changed files with 1938 additions and 27 deletions
+103 -27
View File
@@ -1,6 +1,6 @@
# Phase 2 — Live monitoring map
**Status:** ⬜ Not started — depends on [[processor]] Phase 1.5 landing
**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.
@@ -22,37 +22,113 @@ When Phase 2 is done:
## 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.
- **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 inflate Phase 1 dramatically.
- **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.
## Tasks (sketched, not detailed)
## Sequencing
These get full task files when Phase 2 starts. For now, this is the planned shape:
```
2.1 MapView singleton + mapReady gate ────┐
├─→ 2.2 Tile-source switcher
├─→ 2.3 Sprite preload
└─→ 2.5 MapPositions (also needs 2.4)
| # | Task | Notes |
| --- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 2.1 | MapLibre singleton + `<MapView>` + `mapReady` gate | Module-level instance, detached `<div>`, listener Set for ready transitions |
| 2.2 | Tile-source switcher | Esri / OpenTopoMap / OSM / optional Google via `maplibre-google-maps`; reads from runtime config |
| 2.3 | Sprite preload | Pre-rasterised at `devicePixelRatio` × 4 colour variants; re-`addImage`'d after every style swap |
| 2.4 | WS client + rAF coalescer + Zustand position store | The throughput-discipline core. Per-device latest-position map + per-device bounded trail ring buffer |
| 2.5 | `MapPositions` (clustered + selected sources) | Symbol layer + direction arrow + cluster bubble; selection on click |
| 2.6 | `MapTrails` (bounded ring buffer, polyline rendering) | Default 200 points/device; possibly speed-coloured per segment (post-decision) |
| 2.7 | Event picker + sidebar | Reads events from Directus REST via TanStack Query; subscribes / unsubscribes the WS on switch |
| 2.8 | Camera control trio | One-shot fit / initial framing / reactive follow |
| 2.9 | Connection-status + per-device last-seen indicators | Small chrome elements; non-dominant |
2.4 WS client + rAF coalescer + store ────┐
├─→ 2.5 MapPositions
├─→ 2.6 MapTrails
├─→ 2.7 Event picker
└─→ 2.9 Connection status
## Architectural boundaries to maintain
2.5 + 2.1 ─→ 2.8 Camera trio
```
- `src/map/` is a self-contained module. Imports `@/auth` (for the WS cookie) and `@/config` (for the runtime config), nothing else. The map subsystem must be deletable as a unit if we ever need to reroute (we won't).
- `src/live/` houses the WS client, position store, and the coalescer. Decoupled from the map module so the map renders whatever's in the store, regardless of source.
- No domain logic. The map shows positions; it doesn't know about classes, entries, penalties, or stages. Phase 2.5+ is when domain awareness lands.
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).
## Open questions blocking task-level detail
## Tasks
(These get answered when Phase 2 starts.)
| # | 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) | ⬜ |
1. **Live trail colouring.** Flat colour by device or speed-coloured per segment (Traccar's replay style)? Speed-coloured live is novel and informative for race operators; needs a decision before 2.6.
2. **Event-picker placement.** Top bar (always visible) or sidebar (collapsible)? Depends on the rest of the operator chrome.
3. **Cluster parameters.** Traccar uses `clusterMaxZoom: 14, clusterRadius: 50`. Rally density (50500 vehicles spread across a country-scale stage) may want different values. Defer until we see real positions on the map.
4. **Per-device sidebar list.** Out of scope for v1 of Phase 2 or in scope? Leaning out — the map is the focus; a list is supplementary.
5. **What happens when the user has access to multiple events.** Picker shows all? Only the active ones (between `starts_at` and `ends_at`)? Decide before 2.7.
## 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.