87a738313e
- pnpm add maplibre-gl + -D @types/geojson. - src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2 replaces with the basemap-switcher descriptor table). - src/map/core/map-view.tsx: module-level Map singleton lazily created on first <MapView> mount, attached to a class="trm-map-host" detached <div> that React refs append/remove on mount/unmount. Style-data lifecycle flips mapReady false on every styledata event, polls loaded() at 33ms intervals, flips ready true once the style is loaded — the canonical MapLibre style-swap dance. - Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap() throws if called pre-mount; the explicit failure mode beats a null-able top-level export. - src/routes/_authed/monitor.tsx: new /monitor route, full-viewport <MapView /> for 2.1 (no children — subsequent tasks plug in here). - src/routes/_authed/index.tsx: home-page card now links to /monitor. - eslint.config.js: override for src/map/** + src/live/** disables react-refresh/only-export-components. Same pattern as the existing overrides for shadcn primitives and route files. Deviation: spec sketched a top-level `map` constant export; implemented as `getMap(): MapLibreMap` (a function) so the singleton stays lazy until <MapView> mounts. Top-level constant would either force eager init (breaks SSR/tests) or be nullable (footgun). The function form throws a clear error if called pre-mount. Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS). Other routes unaffected. Vite chunk-size warning is harmless.
135 lines
9.8 KiB
Markdown
135 lines
9.8 KiB
Markdown
# 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.1–2.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.
|