diff --git a/.planning/phase-2-live-map/01-mapview-singleton.md b/.planning/phase-2-live-map/01-mapview-singleton.md new file mode 100644 index 0000000..8ce76d5 --- /dev/null +++ b/.planning/phase-2-live-map/01-mapview-singleton.md @@ -0,0 +1,193 @@ +# Task 2.1 — MapView singleton + mapReady gate + +**Phase:** 2 — Live monitoring map +**Status:** ⬜ Not started +**Depends on:** Phase 1 complete (1.1–1.10). +**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Singleton map", §"Style swaps reset the world"; `docs/wiki/sources/traccar-maps-architecture.md` §2. + +## Goal + +Stand up the MapLibre rendering singleton: a single `maplibregl.Map` instance held in a module-level variable, attached to a detached `
`, which the React `` component mounts/unmounts via refs. Plus the `mapReady` gate that coordinates the style-swap dance — when the basemap changes, every custom source/layer is wiped, so children must remount. After this task lands, the live-map page has a working map widget showing a basemap and nothing else; subsequent tasks layer features on top. + +This is the foundation every other Phase 2 task depends on. Get it right and the rest is cosmetic. + +## Deliverables + +- **`pnpm add maplibre-gl @types/geojson`** — install the rendering engine and GeoJSON types. +- **`src/map/core/map-view.tsx`** exporting: + - `map` — the module-level `maplibregl.Map` singleton. Created lazily on first import (so SSR / test environments without `window` don't choke). + - `` — React component. Mounts the singleton's detached `
` into its own ref on mount; removes it on unmount; calls `map.resize()` after mount and on window resize. + - `useMapReady()` — hook that subscribes a component to the global ready state. Returns `boolean`. + - `subscribeMapReady(cb: (ready: boolean) => void): () => void` — non-React subscription for tests/utilities. + - `getMapReady(): boolean` — synchronous read. +- **`src/map/core/styles.ts`** (stub for 2.2 to fill) — initial style descriptor used at first render. For 2.1, hardcode an OSM raster style as the bootstrap default; 2.2 introduces the switcher. +- **`src/routes/_authed/monitor.tsx`** — new route at `/monitor`. Renders `` filling the viewport (minus the `_authed` chrome). Wired into the home page's "Live monitoring map" card as a link. The route is the integration point every subsequent Phase 2 task plugs UI into. +- **CSS:** import `maplibre-gl/dist/maplibre-gl.css` once at the entry point (or inside `src/map/core/map-view.tsx`'s top-level imports). Without it, controls render unstyled. +- **Update `src/routes/_authed/index.tsx`** — the placeholder home page's card now includes a `Open live map →` so the page is reachable from a known surface. + +## Specification + +### Singleton initialisation + +```ts +// src/map/core/map-view.tsx (sketch) +import maplibregl, { type Map as MapLibreMap } from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { defaultStyle } from './styles'; + +let _map: MapLibreMap | null = null; +let _container: HTMLDivElement | null = null; + +function getOrCreateMap(): MapLibreMap { + if (_map) return _map; + _container = document.createElement('div'); + _container.style.width = '100%'; + _container.style.height = '100%'; + _map = new maplibregl.Map({ + container: _container, + style: defaultStyle, + attributionControl: { compact: true }, + }); + // Hook style-data lifecycle for the mapReady gate (see below). + return _map; +} +``` + +The lazy-create pattern lets tests / SSR import the module without immediately instantiating MapLibre (which needs a DOM). + +### `` component + +```tsx +export function MapView({ children }: { children?: ReactNode }) { + const ref = useRef(null); + const ready = useMapReady(); + + useEffect(() => { + const map = getOrCreateMap(); + const container = _container!; + ref.current?.appendChild(container); + map.resize(); + + const onWindowResize = () => map.resize(); + window.addEventListener('resize', onWindowResize); + + return () => { + window.removeEventListener('resize', onWindowResize); + // Detach but DON'T destroy the map — singleton lives on. + if (container.parentNode === ref.current) { + ref.current?.removeChild(container); + } + }; + }, []); + + return ( +
+ {ready && children} +
+ ); +} +``` + +Children only render when `mapReady` is true. This is the gate that makes child `Map*` components safe to call `map.addSource(...)` from their `useEffect` setup — by the time their effect runs, the style is loaded. + +### The `mapReady` gate + +```ts +// Same file as the singleton. +let _ready = false; +const _readyListeners = new Set<(ready: boolean) => void>(); + +function setReady(value: boolean) { + if (_ready === value) return; + _ready = value; + for (const cb of _readyListeners) cb(value); +} + +export function getMapReady(): boolean { + return _ready; +} + +export function subscribeMapReady(cb: (ready: boolean) => void): () => void { + _readyListeners.add(cb); + return () => _readyListeners.delete(cb); +} + +export function useMapReady(): boolean { + return useSyncExternalStore( + subscribeMapReady, + getMapReady, + () => false, // SSR fallback + ); +} +``` + +Inside `getOrCreateMap`, wire the lifecycle: + +```ts +_map.on('styledata', () => { + // Wait for the style to fully load before flipping ready true. + // Poll loaded() because styledata fires for incremental updates too. + const check = () => { + if (_map?.loaded()) setReady(true); + else setTimeout(check, 33); + }; + setReady(false); + check(); +}); +``` + +`setReady(false)` on every `styledata` ensures children unmount before the new style strips their sources; once loaded, `setReady(true)` lets them remount and re-add. That's the canonical MapLibre style-swap dance. + +### `` and the route + +`src/routes/_authed/monitor.tsx`: + +```tsx +import { createFileRoute } from '@tanstack/react-router'; +import { MapView } from '@/map/core/map-view'; + +export const Route = createFileRoute('/_authed/monitor')({ + component: MonitorPage, +}); + +function MonitorPage() { + return ( +
+ {/* The 3.5rem assumes a 56px top bar; refine when chrome lands. */} + +
+ ); +} +``` + +For 2.1, no children — the map renders the basemap and nothing else. Subsequent tasks render their components inside ``. + +### What this task does NOT do + +- **No basemap switcher** — that's 2.2. Bootstrap with a hardcoded OSM raster style. +- **No sprite registry** — that's 2.3. +- **No data layers** — `MapPositions` / `MapTrails` are 2.5 / 2.6. +- **No camera control** — 2.8. +- **No global error handling for map crashes** — Phase 3.1 (error boundaries) wraps the route. + +## Acceptance criteria + +- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. +- [ ] Navigate to `/monitor` while authenticated → see a full-viewport map with OSM tiles loaded. +- [ ] Hard refresh on `/monitor` → map renders without flicker. +- [ ] Pan / zoom work via mouse + keyboard. +- [ ] Open browser DevTools → no MapLibre warnings about missing style or container. +- [ ] React DevTools shows `` rendering once; no remount on unrelated state changes. +- [ ] Navigate `/monitor` → `/` → `/monitor` again. Map remains responsive (proves the singleton survives navigation). +- [ ] In the browser console, `import('@/map/core/map-view').then(m => m.getMapReady())` returns `true` after the page settles. + +## Risks / open questions + +- **`useSyncExternalStore` and React 19.** Hook is part of React 18+; React 19 keeps it. Confirm — if there's a behavioural drift, fall back to a simple `useEffect` + `useState` pair. +- **MapLibre CSS import location.** Importing from `src/map/core/map-view.tsx` is fine for code splitting (the chunk loads with the route). Importing from `src/main.tsx` is also fine but bloats the initial bundle. Prefer the per-module import. +- **Detached `
` attribute.** The singleton's container is created in JS and `appendChild`'d into the React-managed DOM. Make sure no global CSS resets target it (e.g. `* > div { ... }`). If something breaks, give the container a class `trm-map-host` and target via that. +- **Window resize.** `map.resize()` is cheap but if the page has many resize-driven layouts, debounce. Defer until a real perf issue surfaces. + +## Done + +(Filled in when the task lands.) diff --git a/.planning/phase-2-live-map/02-tile-source-switcher.md b/.planning/phase-2-live-map/02-tile-source-switcher.md new file mode 100644 index 0000000..9408730 --- /dev/null +++ b/.planning/phase-2-live-map/02-tile-source-switcher.md @@ -0,0 +1,206 @@ +# Task 2.2 — Tile-source switcher + +**Phase:** 2 — Live monitoring map +**Status:** ⬜ Not started +**Depends on:** 2.1. +**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Tile sources"; `docs/wiki/sources/traccar-maps-architecture.md` §3. + +## Goal + +Let the operator switch the basemap between Esri World Imagery (satellite), OpenTopoMap (terrain), and OSM (street) — plus an optional Google Satellite source when an operator-supplied API key is present in the runtime config. Style swaps trigger the `mapReady` gate (2.1) so child components remount cleanly. + +After this task, a small floating control on the map lets operators pick the basemap that fits the rally context (satellite for navigation, topo for mountain stages, OSM for sanity). + +## Deliverables + +- **`src/map/core/styles.ts`** (replacing the 2.1 stub) — exports a `BASEMAP_STYLES: BasemapDescriptor[]` array. Each descriptor: + ```ts + type BasemapDescriptor = { + id: 'esri-satellite' | 'opentopo' | 'osm' | 'google-satellite'; + label: string; + style: maplibregl.StyleSpecification | string; // inline raster style or URL + available: (cfg: RuntimeConfig) => boolean; // gates Google on googleMapsKey + attribution: string; + }; + ``` + Inline-built raster styles for Esri / OpenTopoMap / OSM via a `styleCustom({ tiles, attribution })` helper that mirrors traccar-web's pattern (single raster source + raster layer). Google variant builds a `google://` source when the key is present. +- **`src/map/core/basemap-switcher.tsx`** — `` floating control: + - Top-right of the map (a small ink-bordered card with three / four buttons). + - Selected basemap stored in Zustand (`useMapPrefStore`) so the choice persists across route navigations within the session. + - On click, calls `map.setStyle(descriptor.style)` and lets the `mapReady` gate handle the unmount/remount of children. +- **`src/map/core/map-pref-store.ts`** — small Zustand store for map preferences: + - `basemapId: BasemapDescriptor['id']` + - `setBasemap(id): void` + - Persists to `localStorage` via Zustand's `persist` middleware (key: `trm-map-prefs`). +- **`src/routes/_authed/monitor.tsx`** updated — render `` as a child of ``. +- **Optional: `pnpm add maplibre-google-maps`** — only if Google Satellite is being shipped at the same time. The module registers a `google://` protocol handler. Loaded lazily so the `~50KB` adapter doesn't ship on instances that don't use Google tiles. Concretely: a dynamic `import()` inside `BASEMAP_STYLES`'s init code path, gated on `runtimeConfig.googleMapsKey` presence. + +## Specification + +### `styleCustom` helper + +```ts +function styleCustom(opts: { + tiles: string[]; + attribution: string; + minZoom?: number; + maxZoom?: number; +}): maplibregl.StyleSpecification { + return { + version: 8, + sources: { + raster: { + type: 'raster', + tiles: opts.tiles, + tileSize: 256, + attribution: opts.attribution, + minzoom: opts.minZoom ?? 0, + maxzoom: opts.maxZoom ?? 19, + }, + }, + glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf', + layers: [{ id: 'raster', type: 'raster', source: 'raster' }], + }; +} +``` + +The `glyphs` URL lets symbol layers (sprites + labels added in 2.3+) render text on raster basemaps. + +### Initial set + +```ts +export const BASEMAP_STYLES: BasemapDescriptor[] = [ + { + id: 'esri-satellite', + label: 'Satellite', + style: styleCustom({ + tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], + attribution: 'Tiles © Esri', + maxZoom: 19, + }), + available: () => true, + attribution: 'Esri World Imagery', + }, + { + id: 'opentopo', + label: 'Topo', + style: styleCustom({ + tiles: [ + 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png', + 'https://b.tile.opentopomap.org/{z}/{x}/{y}.png', + 'https://c.tile.opentopomap.org/{z}/{x}/{y}.png', + ], + attribution: '© OpenTopoMap (CC-BY-SA)', + maxZoom: 17, + }), + available: () => true, + attribution: 'OpenTopoMap', + }, + { + id: 'osm', + label: 'Street', + style: styleCustom({ + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }), + available: () => true, + attribution: 'OpenStreetMap', + }, + { + id: 'google-satellite', + label: 'Google', + style: ((cfg) => + styleCustom({ + tiles: [`google://satellite/{z}/{x}/{y}?key=${cfg.googleMapsKey}`], + attribution: '© Google', + maxZoom: 22, + })) as unknown as maplibregl.StyleSpecification, + // ↑ build lazily inside the switcher; the descriptor above is illustrative. + available: (cfg) => Boolean(cfg.googleMapsKey), + attribution: 'Google Maps', + }, +]; +``` + +(Build the Google entry's `style` lazily at switch time using the runtime config; the descriptor table above shows the shape.) + +### Lazy `maplibre-google-maps` registration + +```ts +// Inside the basemap switcher, on first use of google-satellite: +async function ensureGoogleProtocol() { + if ((maplibregl as any).__trmGoogleRegistered) return; + const { googleProtocol } = await import('maplibre-google-maps'); + maplibregl.addProtocol('google', googleProtocol); + (maplibregl as any).__trmGoogleRegistered = true; +} +``` + +Skip the lazy-load entirely if `runtimeConfig.googleMapsKey` is empty — the descriptor's `available()` returns false and the option doesn't appear in the switcher. + +### Basemap-switcher UI + +Small floating card, top-right of the map: + +```tsx +export function BasemapSwitcher() { + const cfg = useRuntimeConfig(); + const current = useMapPrefStore((s) => s.basemapId); + const setBasemap = useMapPrefStore((s) => s.setBasemap); + const available = BASEMAP_STYLES.filter((s) => s.available(cfg)); + + return ( +
+ {available.map((s) => ( + + ))} +
+ ); +} +``` + +`switchTo(descriptor)` is the function that: + +1. (If google) `await ensureGoogleProtocol()`. +2. Calls `map.setStyle(descriptor.style)`. +3. Updates `useMapPrefStore` so the choice persists. + +The actual setStyle triggers `styledata` → `mapReady` flips false → flips true once loaded → children remount. + +### Persistence + +Zustand `persist` middleware on `useMapPrefStore`. Key `trm-map-prefs`. Selected basemap survives reloads. + +On first visit, default to `esri-satellite` — the most universally useful for rally context. + +## Acceptance criteria + +- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. +- [ ] `/monitor` shows the basemap switcher top-right with three buttons (Satellite / Topo / Street). +- [ ] Clicking a button changes the basemap. The selected button is visually highlighted. +- [ ] Reloading the page restores the previously selected basemap. +- [ ] If `googleMapsKey` is set in `config.json`, a fourth "Google" button appears. Clicking it loads Google satellite tiles via the adapter. +- [ ] If `googleMapsKey` is empty/missing, the Google button is hidden — and `maplibre-google-maps` is not imported (verify in DevTools network tab; the chunk should not appear). +- [ ] After a basemap switch, `useMapReady()` flips to false then back to true within ~500ms. Console shows no errors. + +## Risks / open questions + +- **OSM tile-server policy.** `tile.openstreetmap.org` is rate-limited and asks for a User-Agent. For low-traffic dogfood it's fine; for production we'd self-host or switch to a paid OSM mirror (MapTiler / Stadia). Document but defer. +- **Esri ToS.** Esri's "World Imagery" raster service is free for non-commercial use with attribution. Confirm before going public; for a closed dogfood it's clearly fine. +- **OpenTopoMap rate limits.** The official tile servers are best-effort. Same defer-until-it-bites posture. +- **Google's official Map Tiles API requires session tokens.** `maplibre-google-maps` handles this internally per the adapter's docs. Confirm the API key has the Map Tiles API enabled (not just JS Maps). +- **Style switcher placement.** Top-right floats over the map; if Phase 3.4's per-device detail panel goes top-right too, we'll need to reposition. For Phase 2 v1, top-right is fine. + +## Done + +(Filled in when the task lands.) diff --git a/.planning/phase-2-live-map/03-sprite-preload.md b/.planning/phase-2-live-map/03-sprite-preload.md new file mode 100644 index 0000000..e8f49fd --- /dev/null +++ b/.planning/phase-2-live-map/03-sprite-preload.md @@ -0,0 +1,149 @@ +# Task 2.3 — Sprite preload (racing categories) + +**Phase:** 2 — Live monitoring map +**Status:** ⬜ Not started +**Depends on:** 2.1. +**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Icon sprites"; `docs/wiki/sources/traccar-maps-architecture.md` §5. + +## Goal + +Pre-rasterise every `category × colour` combination once at app startup and register them with the singleton via `map.addImage()` after each style swap. Layers in 2.5+ then reference sprites by name (e.g. `'icon-image': 'rally-car-success'`) and the GPU paints them with zero per-frame JavaScript work. + +Categories: **rally-car / quad / ssv / motorcycle / runner / hiker / default**. Colours: **success / error / neutral / info** (mapped from device status — 2.5 sets the property). + +## Deliverables + +- **`src/map/assets/icons/*.svg`** — racing-category SVG icons. One file per category, ~24×24 viewBox, 2 px stroke, `currentColor` for tint surfaces. Plus `background.svg` (the icon's plate) and `direction.svg` (the heading arrow). Source from a permissive open-icon set (Lucide doesn't cover most racing categories — borrow from `Phosphor` / `Mage Icons` / commission). For v1, simple geometric silhouettes are enough. +- **`src/map/core/sprite-preload.ts`** exporting: + - `preloadSprites(): Promise` — runs once at app boot. Loads each SVG into an `Image`, draws it onto a `canvas` at `devicePixelRatio` scale tinted with the colour, composites with the background, stores the `ImageData` in a module-level registry keyed `${category}-${color}`. + - `getSpriteRegistry(): ReadonlyMap` — accessor for ``'s init step. + - `installSprites(map: maplibregl.Map): void` — calls `map.addImage(key, imageData, { pixelRatio })` for every entry. Called from ``'s `mapReady` listener after each style swap (sprites are wiped along with sources). +- **`src/map/core/categories.ts`** — `mapCategoryToSprite(category: string): SpriteCategory` — normalises Directus `vehicles.kind` (or `entry_devices.role` later) to one of the 7 sprite categories. Unknown → `default`. +- **`src/main.tsx`** updated — `void preloadSprites()` called once at boot (before or alongside ``). Doesn't block rendering; the registry fills in the background. +- **`src/map/core/map-view.tsx`** updated — ``'s `mapReady` flow calls `installSprites(map)` once `loaded()` is true, before flipping ready to children. + +## Specification + +### Why pre-rasterise + +MapLibre accepts `addImage` with raw `ImageData` or an `HTMLImageElement`. Rasterising once at boot and reusing the buffer means: + +- Style swaps re-run `addImage` on prebuilt buffers (microseconds), not re-rasterise SVGs (milliseconds). +- Every device on the map shares a single GPU texture per (category, colour) combo. Memory cost is bounded. +- The `pixelRatio` option on `addImage` lets us pre-rasterise at `devicePixelRatio` so retina screens render crisp. + +### Composition pattern + +```ts +async function prepareSprite( + background: HTMLImageElement, + icon: HTMLImageElement, + color: string, +): Promise { + const dpr = window.devicePixelRatio ?? 1; + const size = 32; // logical px + const canvas = document.createElement('canvas'); + canvas.width = size * dpr; + canvas.height = size * dpr; + const ctx = canvas.getContext('2d')!; + ctx.scale(dpr, dpr); + + // 1. Background plate. + ctx.drawImage(background, 0, 0, size, size); + + // 2. Tint icon with the colour using destination-atop. + const iconSize = 18; + const off = (size - iconSize) / 2; + const iconCanvas = document.createElement('canvas'); + iconCanvas.width = iconSize * dpr; + iconCanvas.height = iconSize * dpr; + const iconCtx = iconCanvas.getContext('2d')!; + iconCtx.scale(dpr, dpr); + iconCtx.drawImage(icon, 0, 0, iconSize, iconSize); + iconCtx.globalCompositeOperation = 'source-in'; + iconCtx.fillStyle = color; + iconCtx.fillRect(0, 0, iconSize, iconSize); + + ctx.drawImage(iconCanvas, off, off, iconSize, iconSize); + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} +``` + +`destination-in` / `source-in` is the canvas trick for "tint an image with a colour" — the icon's alpha mask becomes the alpha channel of the tinted result. This is the same technique traccar-web uses. + +### Colour mapping + +```ts +const COLOR_MAP: Record = { + success: '#2E8C4A', // green — moving / OK + error: '#E8412B', // race-flag red — alert / panic / lost + neutral: '#0E0E0C', // ink — default + info: '#2563C8', // blue — selected / informational +}; +``` + +These are the design system's semantic colours. Swap to TRM's tokens (when 3.8 lands) by reading from CSS variables. For v1, hardcode. + +### Registry shape + +```ts +type SpriteCategory = 'rally-car' | 'quad' | 'ssv' | 'motorcycle' | 'runner' | 'hiker' | 'default'; +type SpriteColor = 'success' | 'error' | 'neutral' | 'info'; +type SpriteKey = `${SpriteCategory}-${SpriteColor}`; // template literal type + +type SpriteEntry = { + key: SpriteKey; + imageData: ImageData; + pixelRatio: number; +}; + +const _registry = new Map(); +``` + +7 categories × 4 colours = 28 entries. ~5KB ImageData per entry → ~140KB total in memory. Trivial. + +### `installSprites` + +```ts +export function installSprites(map: maplibregl.Map): void { + for (const entry of _registry.values()) { + if (map.hasImage(entry.key)) { + map.removeImage(entry.key); + } + map.addImage(entry.key, entry.imageData, { pixelRatio: entry.pixelRatio }); + } +} +``` + +Called from inside ``'s style-load wait, just before `setReady(true)`. Idempotent — if a sprite is already registered, `removeImage` first to handle the post-style-swap case. + +### Where the `direction` arrow comes in + +The direction arrow (`direction.svg`) is a separate sprite registered as `direction-${color}`. 2.5's `MapPositions` uses a separate symbol layer with `'icon-image': ['concat', 'direction-', ['get', 'color']]` and `'icon-rotation-alignment': 'map'` rotated by the `course` property. + +For 2.3, just include it in the registry and it'll be there when 2.5 needs it. + +### What this task does NOT include + +- **Custom TRM-branded icon set.** That's design system 3.8. For 2.3, use whichever permissive open icons fit (Phosphor, Mage). Document the source in a comment in `src/map/assets/icons/README.md`. +- **Per-class colour mapping.** Class info isn't in the position payload; it would need a Directus join. 2.5 reads the device status from the position record (or a derived field) to pick the colour. +- **Animated sprites.** No sprite animation in v1. + +## Acceptance criteria + +- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. +- [ ] `preloadSprites()` resolves within ~500 ms after app boot on a warm cache. +- [ ] In the browser console after the page settles: `getSpriteRegistry().size` returns 28 (7 categories × 4 colours, plus a small fixed set for the direction arrow if you treat it as part of the same registry, otherwise 28+4=32 — either is fine, just be consistent). +- [ ] After every basemap switch, all sprites still render at correct size on retina screens (verify by zooming / panning after a switch). +- [ ] No "Image with id X is missing" warnings in the console after style swaps. + +## Risks / open questions + +- **SVG-to-canvas drift.** Browsers occasionally render SVG slightly differently than expected (font fallbacks, stroke-width interpretation). Test on Firefox and Safari before declaring 2.3 done — Chromium's canvas is the reference but the others must work. +- **Memory cost on low-end devices.** 140KB of ImageData is fine on desktop; on mid-range Android phones (Phase 3.3 mobile baseline) it's still trivial. +- **CORS on icon SVGs.** SVGs served from the same origin as the SPA bundle have no CORS issues. If we ever fetch icons from a CDN, set `image.crossOrigin = 'anonymous'`. +- **Direction-arrow colour.** Should the arrow follow the device's status colour, or always be a fixed accent? Default to "follows status" — readable at a glance which devices are moving fast vs slow vs stopped. + +## Done + +(Filled in when the task lands.) diff --git a/.planning/phase-2-live-map/04-ws-client-and-position-store.md b/.planning/phase-2-live-map/04-ws-client-and-position-store.md new file mode 100644 index 0000000..4655486 --- /dev/null +++ b/.planning/phase-2-live-map/04-ws-client-and-position-store.md @@ -0,0 +1,355 @@ +# Task 2.4 — WS client + rAF coalescer + Zustand position store + +**Phase:** 2 — Live monitoring map +**Status:** ⬜ Not started +**Depends on:** Phase 1 complete; processor Phase 1.5 deployed reachable. +**Wiki refs:** `docs/wiki/synthesis/processor-ws-contract.md` (the wire spec); `docs/wiki/concepts/maps-architecture.md` §"WebSocket → map data flow"; `docs/wiki/concepts/live-channel-architecture.md`. + +## Goal + +Build the data pipeline that brings live positions from Processor's WebSocket into a Zustand store the map components subscribe to. Three concerns: + +1. **WS client** — connect to `/ws-live`, send `subscribe`, handle `subscribed` (with snapshot), `position`, `unsubscribed`, `error`. Reconnect on close with exponential backoff. +2. **rAF coalescer** — incoming position messages buffer per-device; one `requestAnimationFrame` flush per frame writes the latest snapshot to the store. Without this, traccar-web's per-message dispatch cascade is reproduced. +3. **Zustand position store** — `latestByDevice: Map`, `trailsByDevice: Map>`, `selectedDeviceId: string | null`. Plus a separate `connection-store` for WS state. + +This is the throughput-discipline core. It runs whether or not the map is mounted; the map subscribes to the store and renders whatever's in it. + +## Deliverables + +- **`src/live/protocol.ts`** — zod schemas for outbound (`subscribe` / `unsubscribe`) and inbound (`subscribed` / `position` / `unsubscribed` / `error`) messages per [[processor-ws-contract]]. Plus `PositionEntry` type: + ```ts + type PositionEntry = { + deviceId: string; + lat: number; + lon: number; + ts: number; // epoch ms + speed?: number; + course?: number; + accuracy?: number; + attributes?: Record; + }; + ``` +- **`src/live/ws-client.ts`** exporting: + - `createLiveClient(opts: { url: string }): LiveClient` — factory. + - `LiveClient` interface: + ```ts + interface LiveClient { + connect(): void; // idempotent + close(): void; + subscribe(topic: string): Promise<{ ok: true; snapshot: PositionEntry[] } | { ok: false; code: string; message?: string }>; + unsubscribe(topic: string): Promise; + onPosition(handler: (msg: PositionEntry & { topic: string }) => void): () => void; + } + ``` + - Reconnect with exponential backoff: 1s / 2s / 4s / 8s / 16s / 30s ceiling. + - Re-subscribes to all previously-active topics on reconnect. + - Heartbeat: client pings every 60s if no message in that window; closes if no pong in 10s. +- **`src/live/coalescer.ts`** exporting: + - `createCoalescer(onFlush: (snapshot: PositionEntry[]) => void): Coalescer` + - `Coalescer.push(p: PositionEntry): void` — non-blocking; writes to the buffer. + - Internally: per-device map of latest-position; rAF loop flushes to the consumer once per frame; clears the buffer. +- **`src/live/position-store.ts`** — Zustand store: + ```ts + type PositionState = { + latestByDevice: Map; + trailsByDevice: Map; + selectedDeviceId: string | null; + activeEventId: string | null; // 2.7 sets this + }; + + type PositionActions = { + applySnapshot(eventId: string, entries: PositionEntry[]): void; + applyPositions(entries: PositionEntry[]): void; // called by coalescer flush + clearForEvent(): void; // on event switch + selectDevice(deviceId: string | null): void; + }; + ``` + Trail ring buffer is bounded by `MAX_TRAIL_LENGTH` (default 200, see `MaxTrailLength` config below). +- **`src/live/connection-store.ts`** — Zustand store: + ```ts + type ConnectionState = { + status: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; + attempt: number; // current reconnect attempt (0 when connected) + lastConnectedAt: number | null; + lastErrorMessage: string | null; + }; + ``` +- **`src/live/index.ts`** — barrel re-exports + a `` React component that creates the singleton client (using `runtimeConfig.liveWsUrl`) and wires the coalescer to the store. Mounted alongside `` in `main.tsx`. +- **Constants** — `src/live/constants.ts`: + ```ts + export const MAX_TRAIL_LENGTH = 200; // points per device + export const RAF_BUDGET_MS = 16; // soft target; rAF naturally caps to ~60Hz + export const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000]; + export const RECONNECT_CEILING_MS = 30000; + export const HEARTBEAT_INTERVAL_MS = 60000; + export const HEARTBEAT_TIMEOUT_MS = 10000; + ``` + +## Specification + +### Connection lifecycle + +```ts +// src/live/ws-client.ts (sketch) + +type ClientState = + | { kind: 'idle' } + | { kind: 'connecting'; ws: WebSocket } + | { kind: 'connected'; ws: WebSocket } + | { kind: 'reconnecting'; attempt: number; timer: ReturnType } + | { kind: 'closed' }; + +function createLiveClient({ url }: { url: string }): LiveClient { + let state: ClientState = { kind: 'idle' }; + const subscriptions = new Set(); + const positionHandlers = new Set<(msg: PositionEntry & { topic: string }) => void>(); + const pendingSubscribes = new Map void; reject: (e: unknown) => void }>(); + + function connect() { + if (state.kind === 'connecting' || state.kind === 'connected') return; + setConnectionState({ status: 'connecting' }); + + const ws = new WebSocket(toAbsoluteWsUrl(url)); + state = { kind: 'connecting', ws }; + + ws.addEventListener('open', () => { + state = { kind: 'connected', ws }; + setConnectionState({ status: 'connected', attempt: 0, lastConnectedAt: Date.now() }); + // Re-subscribe to everything that was active before disconnect. + for (const topic of subscriptions) { + sendRaw({ type: 'subscribe', topic }); + } + }); + + ws.addEventListener('message', (ev) => onMessage(ev.data)); + ws.addEventListener('close', () => scheduleReconnect()); + ws.addEventListener('error', (err) => { + logger.warn({ err }, 'WS error'); + // The 'close' event will fire next; reconnect from there. + }); + } + + function scheduleReconnect() { + if (state.kind === 'closed') return; + const attempt = + state.kind === 'reconnecting' ? state.attempt + 1 : 1; + const delay = Math.min( + RECONNECT_BACKOFF_MS[attempt - 1] ?? RECONNECT_CEILING_MS, + RECONNECT_CEILING_MS, + ); + setConnectionState({ status: 'reconnecting', attempt }); + const timer = setTimeout(() => connect(), delay); + state = { kind: 'reconnecting', attempt, timer }; + } + + // ... close, subscribe, unsubscribe, onMessage handlers below +} +``` + +The state machine keeps the reconnect logic linear and testable. Subscriptions are remembered across disconnects in `subscriptions` Set so we can replay them on reopen. + +### URL resolution + +The runtime config gives `liveWsUrl: '/ws-live'` (relative — same-origin via reverse proxy). The WebSocket constructor accepts relative URLs and resolves to `ws(s):///...`, so we don't need explicit resolution. But the test path: when running unit tests under jsdom, `window.location` is `localhost`, so `new WebSocket('/ws-live')` resolves to `ws://localhost/ws-live`. For tests, the WS server fixture binds to localhost. Document this in the test files. + +If we ever switch to absolute URLs in the runtime config, the same constructor handles it. + +### `subscribe` flow with correlation IDs + +```ts +async subscribe(topic: string): Promise { + if (state.kind !== 'connected') { + return { ok: false, code: 'not-connected' }; + } + subscriptions.add(topic); + const id = nanoid(); + const promise = new Promise((resolve, reject) => { + pendingSubscribes.set(id, { resolve, reject }); + setTimeout(() => { + const pending = pendingSubscribes.get(id); + if (pending) { + pending.reject(new Error('subscribe timeout')); + pendingSubscribes.delete(id); + } + }, 5000); + }); + sendRaw({ type: 'subscribe', topic, id }); + return promise; +} + +function onMessage(raw: unknown) { + const parsed = InboundMessage.safeParse(JSON.parse(String(raw))); + if (!parsed.success) return; // malformed; log and drop + + const msg = parsed.data; + switch (msg.type) { + case 'subscribed': { + const pending = msg.id ? pendingSubscribes.get(msg.id) : null; + pending?.resolve({ ok: true, snapshot: msg.snapshot ?? [] }); + if (msg.id) pendingSubscribes.delete(msg.id); + break; + } + case 'position': + for (const h of positionHandlers) h({ ...msg, topic: msg.topic }); + break; + case 'error': { + const pending = msg.id ? pendingSubscribes.get(msg.id) : null; + pending?.resolve({ ok: false, code: msg.code, message: msg.message }); + if (msg.id) pendingSubscribes.delete(msg.id); + break; + } + case 'unsubscribed': + // Idle for now; could resolve a pending unsubscribe promise if we track them. + break; + } +} +``` + +### rAF coalescer + +```ts +// src/live/coalescer.ts +export function createCoalescer(onFlush: (snapshot: PositionEntry[]) => void) { + const buffer = new Map(); + let scheduled = false; + + function flush() { + scheduled = false; + if (buffer.size === 0) return; + const snapshot = Array.from(buffer.values()); + buffer.clear(); + onFlush(snapshot); + } + + return { + push(p: PositionEntry) { + buffer.set(p.deviceId, p); // keep latest per device + if (!scheduled) { + scheduled = true; + requestAnimationFrame(flush); + } + }, + cancel() { + buffer.clear(); + scheduled = false; + }, + }; +} +``` + +This is the magic — the entire throughput-discipline pattern in <30 lines. The store's `applyPositions` is the `onFlush` consumer. + +### Trail ring buffer + +```ts +// In the position store: +applyPositions(entries: PositionEntry[]) { + set((state) => { + const latest = new Map(state.latestByDevice); + const trails = new Map(state.trailsByDevice); + for (const e of entries) { + latest.set(e.deviceId, e); + const tail = trails.get(e.deviceId) ?? []; + // Skip if same position as the last (don't add duplicates from no-movement reports). + const last = tail[tail.length - 1]; + if (!last || last.lat !== e.lat || last.lon !== e.lon) { + const next = [...tail, e]; + if (next.length > MAX_TRAIL_LENGTH) next.shift(); + trails.set(e.deviceId, next); + } + } + return { latestByDevice: latest, trailsByDevice: trails }; + }); +}, +``` + +`Map` makes selector subscriptions cheap (deviceId-keyed lookups). The push-shift pattern keeps each device's array bounded; allocating a new array per update is the Zustand-idiomatic way to trigger subscribers. + +### Snapshot vs streaming + +```ts +applySnapshot(eventId: string, entries: PositionEntry[]) { + // Reset all device-keyed state for the new event. + const latest = new Map(); + const trails = new Map(); + for (const e of entries) { + latest.set(e.deviceId, e); + trails.set(e.deviceId, [e]); + } + set({ + activeEventId: eventId, + latestByDevice: latest, + trailsByDevice: trails, + selectedDeviceId: null, + }); +}, +``` + +Snapshot wipes the previous event's state (called on event switch in 2.7) and seeds the trail with the snapshot's single position per device. Streaming positions from there append to the trail. + +### `` wiring + +```tsx +// src/live/index.tsx +let _client: LiveClient | null = null; + +export function getLiveClient(): LiveClient { + if (!_client) throw new Error('LiveBootstrap has not mounted yet'); + return _client; +} + +export function LiveBootstrap({ children }: { children: ReactNode }) { + const cfg = useRuntimeConfig(); + const status = useAuthStore((s) => s.status); + + useEffect(() => { + if (status !== 'authenticated') return; + _client = createLiveClient({ url: cfg.liveWsUrl }); + const coalescer = createCoalescer((snapshot) => { + usePositionStore.getState().applyPositions(snapshot); + }); + const off = _client.onPosition((msg) => coalescer.push(msg)); + _client.connect(); + return () => { + off(); + _client?.close(); + _client = null; + coalescer.cancel(); + }; + }, [status, cfg.liveWsUrl]); + + return <>{children}; +} +``` + +Mount inside `` so it only connects when authenticated. On logout (status flips to anonymous), the cleanup closes the WS. + +### What this task does NOT do + +- **Map rendering.** That's 2.5 / 2.6 — they read from the store. +- **Event picking.** 2.7 — calls `client.subscribe('event:')` and handles the snapshot. +- **Connection-status UI.** 2.9 reads from `connection-store` and renders chips / banners. +- **Backpressure / drop-oldest.** Defer until measured; the rAF coalescer caps the *flush* rate, not the *receive* rate. If receive ever overwhelms the buffer, add a per-device queue cap. + +## Acceptance criteria + +- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. +- [ ] In the browser console after the page settles: `usePositionStore.getState()` shows the empty state (no event selected yet — 2.7 sets it). +- [ ] After the user is authenticated, `connection-store` flips through `connecting` → `connected`. +- [ ] Manually pushing a synthetic message into the WS (via DevTools) and confirming the position store receives it on the next animation frame. +- [ ] Disconnecting the network → `connection-store.status === 'reconnecting'` within ~5 s, attempt counter increments, eventually reconnects when the network returns. +- [ ] Logging out → WS closes, `connection-store.status === 'disconnected'`. +- [ ] No memory growth across 1000 synthetic positions in a single session (verify via `latestByDevice.size` stays bounded by device count, `trailsByDevice` per-device array length stays ≤ 200). + +## Risks / open questions + +- **Snapshot too large at high event scale.** 500 devices × ~200 bytes = ~100KB. Tolerable. If we ever push to 5000 devices, snapshot streaming via multiple `subscribed` frames would help — defer. +- **rAF in background tabs.** Browsers throttle rAF when the tab is backgrounded. The coalescer effectively pauses; positions buffer until the tab is foregrounded again. Acceptable — operators backgrounding the tab don't need real-time updates. +- **Zustand `Map<>` reactivity.** Zustand subscribers fire on reference changes, not deep equality. Wrapping `latest` and `trails` in `new Map(...)` per update is the right pattern; selectors derive specific deviceIds via `usePositionStore((s) => s.latestByDevice.get(deviceId))`. +- **Trail-direction colour.** If we later add speed-coloured per-segment trails (2.6's open question), the trail entries need `speed` carried through. Already in `PositionEntry`. Good. +- **Cookie auth on WS.** Browser sends the session cookie automatically with the WS upgrade *only if same-origin*. Verify the reverse-proxy + Vite-dev-proxy paths preserve same-origin (they do — `/ws-live` is on the page's origin). Worth a smoke test. + +## Done + +(Filled in when the task lands.) diff --git a/.planning/phase-2-live-map/05-map-positions.md b/.planning/phase-2-live-map/05-map-positions.md new file mode 100644 index 0000000..fce6b8a --- /dev/null +++ b/.planning/phase-2-live-map/05-map-positions.md @@ -0,0 +1,266 @@ +# Task 2.5 — MapPositions (clustered + selected sources) + +**Phase:** 2 — Live monitoring map +**Status:** ⬜ Not started +**Depends on:** 2.1, 2.3, 2.4. +**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Two GeoJSON sources for live positions"; `docs/wiki/sources/traccar-maps-architecture.md` §6. + +## Goal + +Render every device on the map as a sprite, colour-coded by status, rotated by heading. Two GeoJSON sources: one clustered (non-selected devices), one unclustered (the selected device, always on top). Click a marker → select. Click a cluster → zoom to its extent. The map updates via `setData` on every store change; never via DOM marker manipulation. + +After this task, the live map is functionally complete for the dogfood — operators see racers move in real time, can pick one to follow. + +## Deliverables + +- **`src/map/layers/map-positions.tsx`** — `` side-effect-only component: + - Returns `null`. + - Two-effect pattern: setup effect (`[]` deps) adds two GeoJSON sources + 5 layers; update effect (depends on store data) calls `setData`. + - Click handlers wired via `map.on('click', layerId, handler)`. + - Cluster expansion via `map.getSource(id).getClusterExpansionZoom(clusterId)` + `map.easeTo`. +- **Five layers per source** — actually two sources × layers each: + - **Non-selected source** (`cluster: true, clusterMaxZoom: 14, clusterRadius: 50`): + - Symbol layer for unclustered features (icon + title). + - Symbol layer for direction arrows (filtered to features where `course != null`). + - Symbol layer for cluster bubbles (filtered to `['has', 'point_count']`, shows count). + - **Selected source** (no clustering): + - Symbol layer (icon + title), always on top. + - Direction-arrow layer. +- **Feature builder** `buildPositionFeature(p, opts): Feature`: + - `properties.deviceId`, `category`, `color`, `course`, `title`, `direction` (boolean controlling whether the arrow renders). +- **Selection wiring** — clicks dispatch `usePositionStore.getState().selectDevice(deviceId)`. The component re-renders both sources whenever `selectedDeviceId` changes (devices move from non-selected → selected source). +- **`useId()` for unique source/layer ids** so the same component can be mounted twice safely (e.g. a future overview map). + +## Specification + +### Source configuration + +```ts +// On setup: +map.addSource(nonSelectedId, { + type: 'geojson', + data: emptyFeatureCollection, + cluster: true, + clusterMaxZoom: 14, + clusterRadius: 50, +}); +map.addSource(selectedId, { + type: 'geojson', + data: emptyFeatureCollection, +}); +``` + +`clusterMaxZoom: 14` and `clusterRadius: 50` are traccar-web's defaults, sensible for a country-scale rally. We can tune after seeing real data on the map. + +### Layer order + +Order matters — later layers paint over earlier. Order: + +1. Non-selected: symbol (icon + title) +2. Non-selected: direction arrow +3. Cluster bubbles +4. Selected: symbol (icon + title) +5. Selected: direction arrow + +Selected always on top. Cluster bubbles above device markers (so a clustered point looks like a bubble, not an obscured icon). + +### Symbol layer (non-selected, unclustered) + +```ts +map.addLayer({ + id: nonSelectedSymbolId, + source: nonSelectedId, + type: 'symbol', + filter: ['!', ['has', 'point_count']], // exclude clusters + layout: { + 'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']], + 'icon-size': 1, + 'icon-allow-overlap': true, + 'text-field': ['get', 'title'], + 'text-font': ['Open Sans Regular'], + 'text-size': 11, + 'text-offset': [0, 1.4], + 'text-anchor': 'top', + 'text-allow-overlap': false, + }, + paint: { + 'text-color': '#0E0E0C', + 'text-halo-color': '#FAFAF7', + 'text-halo-width': 1.5, + }, +}); +``` + +The `'icon-image'` expression resolves at GPU paint time to a sprite key like `rally-car-success`, registered in 2.3. + +### Direction arrow + +```ts +map.addLayer({ + id: nonSelectedDirectionId, + source: nonSelectedId, + type: 'symbol', + filter: ['all', ['!', ['has', 'point_count']], ['get', 'direction']], + layout: { + 'icon-image': ['concat', 'direction-', ['get', 'color']], + 'icon-size': 1, + 'icon-rotation-alignment': 'map', + 'icon-rotate': ['coalesce', ['get', 'course'], 0], + 'icon-allow-overlap': true, + }, +}); +``` + +`icon-rotation-alignment: 'map'` rotates with the map (so the arrow points the device's actual heading regardless of map bearing). + +### Cluster bubbles + +```ts +map.addLayer({ + id: clusterId, + source: nonSelectedId, + type: 'symbol', + filter: ['has', 'point_count'], + layout: { + 'icon-image': 'cluster-background', // a dedicated sprite from 2.3 + 'icon-size': 1, + 'text-field': ['get', 'point_count_abbreviated'], + 'text-font': ['Open Sans Bold'], + 'text-size': 12, + }, + paint: { + 'text-color': '#0E0E0C', + }, +}); +``` + +`'cluster-background'` is a separate sprite (small dot or pill) registered in 2.3 — extend the registry if it isn't there yet. + +### Click handlers + +```ts +map.on('click', nonSelectedSymbolId, (ev) => { + const feature = ev.features?.[0]; + if (!feature) return; + const deviceId = feature.properties?.deviceId as string | undefined; + if (deviceId) usePositionStore.getState().selectDevice(deviceId); +}); + +map.on('click', clusterId, (ev) => { + const feature = ev.features?.[0]; + if (!feature) return; + const clusterId = feature.properties?.cluster_id as number | undefined; + if (clusterId == null) return; + const source = map.getSource(nonSelectedId) as maplibregl.GeoJSONSource; + source.getClusterExpansionZoom(clusterId, (err, zoom) => { + if (err) return; + map.easeTo({ + center: (feature.geometry as GeoJSON.Point).coordinates as [number, number], + zoom: zoom ?? map.getZoom() + 1, + }); + }); +}); + +// Cursor feedback. +map.on('mouseenter', nonSelectedSymbolId, () => (map.getCanvas().style.cursor = 'pointer')); +map.on('mouseleave', nonSelectedSymbolId, () => (map.getCanvas().style.cursor = '')); +// Same for clusterId and selectedSymbolId. +``` + +Click handlers are added in the setup effect; cleanup function removes them. + +### `setData` update path + +```ts +useEffect(() => { + const features: Feature[] = []; + const selectedFeatures: Feature[] = []; + for (const [deviceId, position] of latestByDevice) { + const feat = buildPositionFeature(position, devices.get(deviceId)); + if (deviceId === selectedDeviceId) selectedFeatures.push(feat); + else features.push(feat); + } + (map.getSource(nonSelectedId) as maplibregl.GeoJSONSource | undefined)?.setData({ + type: 'FeatureCollection', + features, + }); + (map.getSource(selectedId) as maplibregl.GeoJSONSource | undefined)?.setData({ + type: 'FeatureCollection', + features: selectedFeatures, + }); +}, [latestByDevice, selectedDeviceId, devices]); +``` + +`devices` is a TanStack Query result holding the Directus-side device records (category, vehicle, crew name) joined client-side by deviceId. 2.5 fetches it on mount. If `devices` is empty (initial load), default category to `'default'`. + +### Feature shape + +```ts +function buildPositionFeature(p: PositionEntry, device?: Device): Feature { + const category = mapCategoryToSprite(device?.kind ?? 'default'); + const status = inferStatus(p); // 'success' | 'error' | 'neutral' | 'info' + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, + properties: { + deviceId: p.deviceId, + category, + color: status, + course: p.course ?? 0, + direction: p.course != null && (p.speed ?? 0) > 1, // arrow only when moving + title: device?.label ?? p.deviceId.slice(0, 8), + }, + }; +} + +function inferStatus(p: PositionEntry): 'success' | 'error' | 'neutral' | 'info' { + // Phase 2 heuristic — refine in Phase 3.4 with real domain rules. + if ((p.speed ?? 0) > 1) return 'success'; // moving + return 'neutral'; // stopped / idle +} +``` + +Phase 2's status inference is a placeholder; Phase 3.4 (per-device detail) will refine with offline-detection, panic-button awareness, etc. + +### `` lives inside `` + +```tsx +// In src/routes/_authed/monitor.tsx + + + {/* 2.6 */} + {/* 2.8 */} + {/* 2.8 */} + +``` + +The `mapReady` gate (2.1) ensures these only mount when the style is loaded. + +### What this task does NOT include + +- **Trail rendering.** 2.6 — separate component on a separate source. +- **Camera follow.** 2.8 — `` watches `selectedDeviceId` and `easeTo`s. +- **Per-device detail panel.** Phase 3.4. +- **Selection persistence.** `selectedDeviceId` resets on reload. Acceptable for v1. + +## Acceptance criteria + +- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. +- [ ] With the Rally Albania 2026 seed and synthetic positions published to Redis, `/monitor` shows the seeded devices as map markers within ~1s of subscribe. +- [ ] Clicking a marker selects the device — visually it moves to the selected source's layers (potentially pulled out of a cluster). +- [ ] Clicking a cluster zooms the map in to expand it. +- [ ] Marker positions update on every position arrival via `setData`. No DOM markers. +- [ ] Direction arrow rotates correctly with `course` and stays oriented to the map (rotates with the map). +- [ ] Stopping a device (speed=0) hides its direction arrow. +- [ ] Style swap (basemap change in 2.2) — markers re-appear after the swap. The mapReady gate handles the unmount/remount. + +## Risks / open questions + +- **Cluster parameters.** `clusterMaxZoom: 14, clusterRadius: 50` is traccar's default. Rally density is much sparser than urban GPS fleet — clusters might be too aggressive at low zoom. Tune after seeing real positions; document the changed values in this task's Done section. +- **Title text overlap.** With `text-allow-overlap: false`, MapLibre suppresses overlapping titles. At dense clusters this hides labels; the cluster-bubble layer's count compensates. Verify visually. +- **Selection on cluster expansion.** Clicking a cluster zooms in but doesn't pre-select anything. The user clicks a marker after the cluster expands. Acceptable — auto-selecting one of the cluster's devices would be arbitrary. +- **`devices` query cache.** TanStack Query default `staleTime: 0` means devices refetch on every focus. For a list that changes daily, set `staleTime: 5 * 60 * 1000` on this query specifically. + +## Done + +(Filled in when the task lands.) diff --git a/.planning/phase-2-live-map/06-map-trails.md b/.planning/phase-2-live-map/06-map-trails.md new file mode 100644 index 0000000..814f212 --- /dev/null +++ b/.planning/phase-2-live-map/06-map-trails.md @@ -0,0 +1,152 @@ +# Task 2.6 — MapTrails (bounded ring buffer, polyline rendering) + +**Phase:** 2 — Live monitoring map +**Status:** ⬜ Not started +**Depends on:** 2.1, 2.4. +**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Routes (history and live trails)"; `docs/wiki/sources/traccar-maps-architecture.md` §8. + +## Goal + +Render the trailing path of every (or only the selected) device as a polyline. Reads from the per-device ring buffer in the position store (2.4 capped at 200 points). Visible as a thin line behind the moving marker — operators see "where this racer has been in the last few minutes." + +After this task lands, the live map shows motion *history*, not just current position. That's what makes "live tracking" feel live. + +## Deliverables + +- **`src/map/layers/map-trails.tsx`** — `` side-effect-only component: + - Returns `null`. + - Two-effect pattern: setup adds one GeoJSON source + one line layer; update calls `setData`. + - Reads `trailsByDevice` and `selectedDeviceId` from the position store. + - Honours a user preference: `none` / `selected` / `all` for which devices' trails render. +- **`src/live/position-store.ts`** updated to expose `trailMode` preference (`'none' | 'selected' | 'all'`, default `'selected'`) and a setter. Persisted via Zustand `persist` middleware on the prefs slice (or a separate prefs store — same shape as the map-pref store from 2.2). +- **A toggle UI control** in the basemap-switcher card (or a sibling control): "Trails: none / selected / all". 3-state toggle. +- **Decision: speed-coloured per-segment.** Two flavours possible (see Specification below). For v1 of this task, **start with flat colour per device** (the simpler path). Add a feature-flagged speed-coloured variant if there's appetite during the build; defer to a Phase 3 polish task otherwise. + +## Specification + +### Single source, single layer + +```ts +// Setup effect: +map.addSource(trailsId, { + type: 'geojson', + data: emptyFC, +}); + +map.addLayer( + { + id: trailsLineId, + source: trailsId, + type: 'line', + layout: { + 'line-join': 'round', + 'line-cap': 'round', + }, + paint: { + 'line-color': ['get', 'color'], + 'line-width': 2, + 'line-opacity': 0.85, + }, + }, + // Insert BEFORE map-positions' icon layers so trails sit under markers. + // The id of the first non-selected symbol layer from 2.5 is the anchor. + firstSymbolLayerId, +); +``` + +The third arg to `addLayer` puts the line layer below the position symbols; trails draw under markers without obscuring them. + +### Feature builder — flat colour per device + +```ts +function buildTrailFeature(deviceId: string, points: PositionEntry[], device?: Device): Feature | null { + if (points.length < 2) return null; + return { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: points.map((p) => [p.lon, p.lat]), + }, + properties: { + deviceId, + // Flat colour per device's status (or category-driven palette). + color: deviceColour(device), + }, + }; +} + +function deviceColour(device?: Device): string { + // Defer to a per-class palette later. For v1, a small rotating set keyed by deviceId hash: + const palette = ['#2563C8', '#2E8C4A', '#6B46C1', '#188C8A', '#C9296F', '#5A5A53']; + const h = hashString(device?.id ?? 'default'); + return palette[h % palette.length]; +} +``` + +Per-device colour means operators can visually track who's whose trail without reading labels. + +### Update effect + +```ts +useEffect(() => { + const features: Feature[] = []; + if (trailMode === 'none') { + // empty FC + } else if (trailMode === 'selected' && selectedDeviceId) { + const points = trailsByDevice.get(selectedDeviceId) ?? []; + const f = buildTrailFeature(selectedDeviceId, points, devices.get(selectedDeviceId)); + if (f) features.push(f); + } else if (trailMode === 'all') { + for (const [deviceId, points] of trailsByDevice) { + const f = buildTrailFeature(deviceId, points, devices.get(deviceId)); + if (f) features.push(f); + } + } + (map.getSource(trailsId) as maplibregl.GeoJSONSource | undefined)?.setData({ + type: 'FeatureCollection', + features, + }); +}, [trailMode, selectedDeviceId, trailsByDevice, devices]); +``` + +### Speed-coloured per-segment (deferred decision) + +Traccar's replay mode renders one line *segment* per consecutive position pair, coloured by the second point's speed. For live trails this would mean N-1 features per device per update — heavier but visually richer. Operators glance at colour to read "fast / slow / stopped." + +If we decide to ship this in v1: replace `LineString` with multiple two-point `LineString`s and colour per segment. The setData payload grows; the line layer's `'line-color'` becomes `['get', 'segmentColor']` instead of the device's flat colour. + +For 2.6 v1: **flat colour**, deferred speed-colouring to a Phase 3 polish task. Decision rationale documented in the open question below. + +### Trail length + +`MAX_TRAIL_LENGTH = 200` (from 2.4's constants). At 1Hz position rate, that's 200 seconds (~3.3 minutes) of trail. At 0.2Hz (default Teltonika rate), it's ~17 minutes. Both are reasonable. + +If operators want a longer-trail mode: a slider in the prefs (`50 / 200 / 500 / 1000`). Out of scope for 2.6 — defer to a Phase 3 polish task. + +### What this task does NOT include + +- **Speed-coloured segments.** Decided as flat for v1; revisit in Phase 3 polish. +- **Trail-length user control.** Hardcoded to 200 via 2.4's constant. +- **Trail persistence across page reloads.** Trails reset on every reload (live state, not durable). +- **Snapshotting old trails.** No "show me the last 24h of this device" — that's replay (Phase 4). + +## Acceptance criteria + +- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. +- [ ] With the dogfood seed and synthetic positions, `/monitor` shows a polyline trailing each device as it moves. +- [ ] Switching the trail-mode toggle (none / selected / all) immediately changes which trails render. None → empty layer. Selected → only the selected device. All → every device. +- [ ] Trail follows the device's most recent N positions; old points fall off the front as new ones append (verify trail is always exactly the configured length once steady-state). +- [ ] Trails sit *underneath* markers (verifiable visually — markers paint over the polyline). +- [ ] Style swap → trails reappear after the basemap change. +- [ ] No `setData` warnings (e.g. coordinate-array mismatch). + +## Risks / open questions + +- **Speed-coloured segments — ship in v1?** Decided "no, defer". Reasons: (a) flat colour is simpler and visually cleaner; (b) operators don't actually need a real-time speed colourmap when they have the moving marker's status colour; (c) the per-segment feature explosion at high update rates is a minor perf concern. Revisit if early dogfood feedback says the trail isn't informative enough. +- **Visual clutter at `trailMode: 'all'`.** With 50+ devices in a tight rally area, all trails on screen is noisy. Operators will probably default to `'selected'`. The default is `'selected'`. Acceptable. +- **Trail accuracy.** With `faulty` filtering on the snapshot but NOT on streaming positions (per [[processor-ws-contract]] §"Faulty-flag visibility"), trails can include positions that an operator later flags faulty. Live trail = observed-as-streamed; correction is post-hoc, not retro-active. Document. +- **Trail-mode toggle placement.** Adjacent to the basemap-switcher (2.2) is the natural home — same floating-card pattern. UI-wise, three small radio buttons. Operator-facing label: "Trails: none / selected / all" or "Trail mode: …". + +## Done + +(Filled in when the task lands.) diff --git a/.planning/phase-2-live-map/07-event-picker.md b/.planning/phase-2-live-map/07-event-picker.md new file mode 100644 index 0000000..e1d3c0c --- /dev/null +++ b/.planning/phase-2-live-map/07-event-picker.md @@ -0,0 +1,176 @@ +# Task 2.7 — Event picker (subscription driver) + +**Phase:** 2 — Live monitoring map +**Status:** ⬜ Not started +**Depends on:** 2.4. +**Wiki refs:** `docs/wiki/synthesis/processor-ws-contract.md` §"Subscription model"; `docs/wiki/synthesis/directus-schema-draft.md`. + +## Goal + +Let the operator pick which event to monitor. The picker reads the events the user has access to from Directus (via TanStack Query), drives the WS subscription via `client.subscribe('event:')`, applies the snapshot to the position store, and switches subscriptions cleanly when the user changes events. + +After this task, the live map is operator-driven — no hardcoded event id, no dev-only sample data. + +## Deliverables + +- **`src/data/events.ts`** — TanStack Query hook + types: + ```ts + export type EventSummary = { + id: string; + name: string; + slug: string; + discipline: 'rally' | 'time-trial' | 'regatta' | 'trail-run' | 'hike'; + starts_at: string; + ends_at: string; + organization_id: string; + }; + + export function useUserEvents(): UseQueryResult; + ``` + Fetches `/items/events?fields=id,name,slug,discipline,starts_at,ends_at,organization_id&sort=-starts_at`. Directus's RLS handles "what events does this user have access to" via the cookie. Stale time 5 minutes. +- **`src/ui/components/event-picker.tsx`** — `` component. Top-of-page dropdown showing the events list. Selected event highlights. On select, calls a callback (passed in by the monitor route). +- **`src/routes/_authed/monitor.tsx`** updated — orchestrates the picker → subscribe → snapshot → store flow: + ```tsx + function MonitorPage() { + const activeEventId = usePositionStore((s) => s.activeEventId); + const setActiveEvent = useActiveEventOrchestration(); + return ( + <> +
+ +
+ + + + + + + + {/* 2.9 */} + + ); + } + ``` +- **`src/live/active-event.ts`** — `useActiveEventOrchestration()` hook that wraps: + 1. Unsubscribe from the previous event's topic if any. + 2. Subscribe to the new event's topic. + 3. On `subscribed`, call `usePositionStore.applySnapshot(eventId, snapshot)`. + 4. On error, surface a toast (or inline alert) — wrong event id, no permission, etc. + 5. Persist the selected event id to localStorage so reload returns to the same event. +- **`src/live/index.ts`** — re-export `useActiveEventOrchestration`. + +## Specification + +### Filter for "events the user is meaningfully looking at" + +The naive query `GET /items/events` returns every event in every org the user belongs to. For a multi-tenant pilot with one org and a handful of events, that's fine — sort by `starts_at DESC` and the most recent shows first. + +For a future where the user is in multiple orgs with dozens of past events: filter by date window: + +```ts +const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); +const url = `/api/items/events?filter[ends_at][_gte]=${oneWeekAgo}&fields=...&sort=-starts_at`; +``` + +Defaults the picker to "events that ended in the last week or are still ongoing." Document the decision; for the dogfood it's overkill but harmless. + +### Picker UI + +A small dropdown / combobox in the top-left of the monitor page. shadcn's `Popover` + `Command` primitives (or a vanilla `