Files
spa/.planning/phase-2-live-map/README.md
T
julian 0b54f87860 feat: task 2.3 sprite preload (racing categories)
- src/map/assets/icons/: 9 placeholder SVGs (background plate, direction
  arrow, 7 categories: rally-car / quad / ssv / motorcycle / runner /
  hiker / default). Hand-authored simple silhouettes; replaced in
  Phase 3.8 with the branded set.
- src/map/core/categories.ts: SpriteCategory/SpriteColor types,
  mapCategoryToSprite() normaliser, inferColor() helper.
- src/map/core/sprite-preload.ts: idempotent preloadSprites() with
  memoised promise, whenSpritesReady() alias, getSpriteRegistry()
  read-only access, installSprites(map) for the mapReady flow.
  Composition pipeline draws the plate + composites a tinted icon
  centred on it (category sprites) or tints the arrow alone (direction
  sprites). Tint via canvas globalCompositeOperation: 'source-in'.
- src/main.tsx: void preloadSprites() fired at boot; the promise is
  memoised so mapReady flow awaits the same instance.
- src/map/core/map-view.tsx: onStyleData() awaits whenSpritesReady()
  AND _map.loaded() before installing sprites and flipping mapReady
  true. Sprites reinstall on every style swap.

Registry: 7 categories x 4 colours + 4 direction-only entries = 32
total. ~160KB in memory.

Deviations:
1. Direction sprites have no plate (it's a separate symbol layer in
   2.5 overlaid on the device sprite; double-plate would look wrong).
2. Hardcoded the design-system palette (#2E8C4A / #E8412B / #0E0E0C /
   #2563C8) directly. When 3.8 lands, these rebind to TRM tokens via
   CSS variables.
2026-05-03 09:30:02 +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.