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:
@@ -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 `<div>`, which the React `<MapView>` 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).
|
||||||
|
- `<MapView />` — React component. Mounts the singleton's detached `<div>` 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 `<MapView />` 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 `<Link to="/monitor">Open live map →</Link>` 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).
|
||||||
|
|
||||||
|
### `<MapView>` component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function MapView({ children }: { children?: ReactNode }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} className="absolute inset-0">
|
||||||
|
{ready && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `<MapView>` 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 (
|
||||||
|
<div className="relative h-[calc(100vh-3.5rem)] w-full">
|
||||||
|
{/* The 3.5rem assumes a 56px top bar; refine when chrome lands. */}
|
||||||
|
<MapView />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For 2.1, no children — the map renders the basemap and nothing else. Subsequent tasks render their components inside `<MapView>`.
|
||||||
|
|
||||||
|
### 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 `<MapView>` 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 `<div>` 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.)
|
||||||
@@ -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`** — `<BasemapSwitcher />` 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 `<BasemapSwitcher />` as a child of `<MapView>`.
|
||||||
|
- **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 (
|
||||||
|
<div className="absolute top-3 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden">
|
||||||
|
{available.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => switchTo(s)}
|
||||||
|
className={cn(
|
||||||
|
'block px-3 py-1.5 text-sm w-full text-left',
|
||||||
|
current === s.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.)
|
||||||
@@ -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<void>` — 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<string, SpriteEntry>` — accessor for `<MapView>`'s init step.
|
||||||
|
- `installSprites(map: maplibregl.Map): void` — calls `map.addImage(key, imageData, { pixelRatio })` for every entry. Called from `<MapView>`'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 `<AuthBootstrap>`). Doesn't block rendering; the registry fills in the background.
|
||||||
|
- **`src/map/core/map-view.tsx`** updated — `<MapView>`'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<ImageData> {
|
||||||
|
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<SpriteColor, string> = {
|
||||||
|
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<SpriteKey, SpriteEntry>();
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `<MapView>`'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.)
|
||||||
@@ -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<deviceId, PositionEntry>`, `trailsByDevice: Map<deviceId, RingBuffer<PositionEntry>>`, `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<string, unknown>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- **`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<void>;
|
||||||
|
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<string, PositionEntry>;
|
||||||
|
trailsByDevice: Map<string, PositionEntry[]>;
|
||||||
|
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 `<LiveBootstrap>` React component that creates the singleton client (using `runtimeConfig.liveWsUrl`) and wires the coalescer to the store. Mounted alongside `<AuthBootstrap>` 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<typeof setTimeout> }
|
||||||
|
| { kind: 'closed' };
|
||||||
|
|
||||||
|
function createLiveClient({ url }: { url: string }): LiveClient {
|
||||||
|
let state: ClientState = { kind: 'idle' };
|
||||||
|
const subscriptions = new Set<string>();
|
||||||
|
const positionHandlers = new Set<(msg: PositionEntry & { topic: string }) => void>();
|
||||||
|
const pendingSubscribes = new Map<string, { resolve: (r: SubscribeResult) => 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)://<origin>/...`, 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<SubscribeResult> {
|
||||||
|
if (state.kind !== 'connected') {
|
||||||
|
return { ok: false, code: 'not-connected' };
|
||||||
|
}
|
||||||
|
subscriptions.add(topic);
|
||||||
|
const id = nanoid();
|
||||||
|
const promise = new Promise<SubscribeResult>((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<string, PositionEntry>();
|
||||||
|
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<string, PositionEntry[]>` 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<string, PositionEntry>();
|
||||||
|
const trails = new Map<string, PositionEntry[]>();
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `<LiveBootstrap>` 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 `<AuthBootstrap>` 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:<id>')` 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.)
|
||||||
@@ -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`** — `<MapPositions />` 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.
|
||||||
|
|
||||||
|
### `<MapPositions>` lives inside `<MapView>`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In src/routes/_authed/monitor.tsx
|
||||||
|
<MapView>
|
||||||
|
<MapPositions />
|
||||||
|
<MapTrails /> {/* 2.6 */}
|
||||||
|
<MapDefaultCamera /> {/* 2.8 */}
|
||||||
|
<MapSelectedDevice /> {/* 2.8 */}
|
||||||
|
</MapView>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 — `<MapSelectedDevice>` 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.)
|
||||||
@@ -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`** — `<MapTrails />` 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.)
|
||||||
@@ -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:<id>')`, 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<EventSummary[]>;
|
||||||
|
```
|
||||||
|
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`** — `<EventPicker />` 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 (
|
||||||
|
<>
|
||||||
|
<header className="absolute top-3 left-3 z-10">
|
||||||
|
<EventPicker activeEventId={activeEventId} onChange={setActiveEvent} />
|
||||||
|
</header>
|
||||||
|
<MapView>
|
||||||
|
<MapPositions />
|
||||||
|
<MapTrails />
|
||||||
|
<MapDefaultCamera />
|
||||||
|
<MapSelectedDevice />
|
||||||
|
</MapView>
|
||||||
|
<BasemapSwitcher />
|
||||||
|
<ConnectionChip /> {/* 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 `<select>` for v1 — keep it boring).
|
||||||
|
|
||||||
|
For v1 a vanilla button + popover list is fine:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button className="...">
|
||||||
|
{activeEvent?.name ?? 'Select event'}
|
||||||
|
<ChevronDown />
|
||||||
|
</button>
|
||||||
|
<Popover>
|
||||||
|
<ul>
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e.id} onClick={() => onChange(e.id)}>
|
||||||
|
<span>{e.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(e.starts_at)} · {e.discipline}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Active-event orchestration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useActiveEventOrchestration() {
|
||||||
|
const setActiveEventInStore = usePositionStore((s) => s.applySnapshot);
|
||||||
|
const clearStore = usePositionStore((s) => s.clearForEvent);
|
||||||
|
const activeEventId = usePositionStore((s) => s.activeEventId);
|
||||||
|
|
||||||
|
return useCallback(async (eventId: string | null) => {
|
||||||
|
const client = getLiveClient();
|
||||||
|
|
||||||
|
// Tear down the previous subscription.
|
||||||
|
if (activeEventId) {
|
||||||
|
await client.unsubscribe(`event:${activeEventId}`).catch(() => {});
|
||||||
|
clearStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
|
// Subscribe to the new one.
|
||||||
|
const result = await client.subscribe(`event:${eventId}`);
|
||||||
|
if (!result.ok) {
|
||||||
|
// Surface to UI (toast or inline error).
|
||||||
|
console.warn('subscribe failed', result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveEventInStore(eventId, result.snapshot);
|
||||||
|
localStorage.setItem('trm-active-event-id', eventId);
|
||||||
|
}, [activeEventId, clearStore, setActiveEventInStore]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-select on first mount
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('trm-active-event-id');
|
||||||
|
if (saved && events.some((e) => e.id === saved)) {
|
||||||
|
void setActiveEvent(saved);
|
||||||
|
} else if (events.length > 0) {
|
||||||
|
// Auto-select the most recent.
|
||||||
|
void setActiveEvent(events[0].id);
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
```
|
||||||
|
|
||||||
|
So the user lands on `/monitor` and the picker auto-fills with the last-used event (or the most recent). They can switch via the picker.
|
||||||
|
|
||||||
|
### What this task does NOT include
|
||||||
|
|
||||||
|
- **Multi-event view.** Subscribing to two events simultaneously isn't supported by v1. The picker is single-select.
|
||||||
|
- **Event creation / editing.** That's the Directus admin UI's job; the SPA only consumes events.
|
||||||
|
- **Event-list filtering / search.** Linear list sorted by date is enough for the dogfood. Add search if the list grows past ~20 events.
|
||||||
|
- **"All events" mode.** No "everything everywhere" view. Always one event at a time.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean.
|
||||||
|
- [ ] `/monitor` shows the event picker top-left, populated with the user's accessible events.
|
||||||
|
- [ ] On first visit, the most recent event is auto-selected; markers appear within ~1s as the snapshot arrives.
|
||||||
|
- [ ] Switching events: the previous event's markers disappear, the new event's markers appear, no leftover state.
|
||||||
|
- [ ] Reloading the page restores the previously selected event.
|
||||||
|
- [ ] Selecting an event the user doesn't have permission for surfaces a clear error (the WS returns `error/forbidden`); the picker stays on the previous event.
|
||||||
|
- [ ] Logging out clears the localStorage-saved event id (handled by Phase 1's logout-clears-cache flow — the event id key follows the same lifecycle).
|
||||||
|
|
||||||
|
## Risks / open questions
|
||||||
|
|
||||||
|
- **`useUserEvents` and Directus RLS.** Directus filters server-side based on the user's permission policies. Phase 4 of `trm/directus` (permissions) is the gate for "user X sees only events from their orgs." Until then, all authenticated users see all events. Acceptable for dogfood; Phase 4 closes it.
|
||||||
|
- **Multi-org users.** Picker doesn't group by organization. With one org per pilot user, that's fine. For multi-org users (post-Phase-4), add a sub-header in the picker.
|
||||||
|
- **Event time window.** Defaulted to "ended in the last week or ongoing." Operators reviewing post-event data more than a week old need to bump the window — defer until they ask.
|
||||||
|
- **Active-event persistence + logout.** localStorage `trm-active-event-id` survives logout if not explicitly cleared. Update Phase 1's `performLogout` to clear it (or namespace the key under a session-scoped storage key).
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
(Filled in when the task lands.)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Task 2.8 — Camera control trio
|
||||||
|
|
||||||
|
**Phase:** 2 — Live monitoring map
|
||||||
|
**Status:** ⬜ Not started
|
||||||
|
**Depends on:** 2.1, 2.5.
|
||||||
|
**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Camera control patterns"; `docs/wiki/sources/traccar-maps-architecture.md` §10.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Three small components, each handling one camera concern, kept separate so dependency graphs stay small and "the camera jumped unexpectedly" bugs don't appear:
|
||||||
|
|
||||||
|
1. **`<MapDefaultCamera />`** — initial framing on event-load. Fits the bounds of the event's snapshot (or the user's saved view). Runs at most once per event-id change.
|
||||||
|
2. **`<MapSelectedDevice />`** — reactive follow. Watches `selectedDeviceId`; when the selected device's position updates and `mapFollow` is on, `easeTo` the new position.
|
||||||
|
3. **`<MapCamera />`** — one-shot fit, called imperatively when needed (e.g. "fit to selection" button later, replay mode in Phase 4).
|
||||||
|
|
||||||
|
Plus a `mapFollow` user preference (boolean), wired to a small toggle in the chrome.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- **`src/map/core/camera/`** directory:
|
||||||
|
- `default-camera.tsx` — `<MapDefaultCamera />` side-effect-only component.
|
||||||
|
- `selected-device.tsx` — `<MapSelectedDevice />` side-effect-only component.
|
||||||
|
- `map-camera.tsx` — `<MapCamera coordinates={...} />` props-driven one-shot fit.
|
||||||
|
- `index.ts` — barrel.
|
||||||
|
- **`src/live/position-store.ts`** updated — adds `mapFollow: boolean` (default `true`) and `setMapFollow(v): void`. Persisted via Zustand's `persist` middleware on the prefs slice.
|
||||||
|
- **A toggle UI control** in the chrome — labelled "Follow selected" or just a small lock-pin icon. When on, selecting a device auto-pans to it; manual pan disables follow until the next selection change.
|
||||||
|
- **Manual-pan detection** — when the user pans/zooms the map, `mapFollow` flips to `false` automatically. Prevents the "I tried to scroll the map and it kept snapping back" frustration.
|
||||||
|
- **`src/routes/_authed/monitor.tsx`** updated — render `<MapDefaultCamera />` and `<MapSelectedDevice />` as children of `<MapView>`, alongside `<MapPositions />` / `<MapTrails />`.
|
||||||
|
|
||||||
|
## Specification
|
||||||
|
|
||||||
|
### `<MapDefaultCamera />` — initial fit
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MapDefaultCamera() {
|
||||||
|
const activeEventId = usePositionStore((s) => s.activeEventId);
|
||||||
|
const latestByDevice = usePositionStore((s) => s.latestByDevice);
|
||||||
|
const initialised = useRef<string | null>(null); // tracks last event we fitted
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEventId || initialised.current === activeEventId) return;
|
||||||
|
if (latestByDevice.size === 0) return; // wait for snapshot
|
||||||
|
|
||||||
|
const positions = Array.from(latestByDevice.values());
|
||||||
|
if (positions.length === 1) {
|
||||||
|
const [p] = positions;
|
||||||
|
map.easeTo({ center: [p.lon, p.lat], zoom: 12, duration: 0 });
|
||||||
|
} else {
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
for (const p of positions) bounds.extend([p.lon, p.lat]);
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: Math.min(map.getCanvas().clientWidth, map.getCanvas().clientHeight) * 0.1,
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
initialised.current = activeEventId;
|
||||||
|
}, [activeEventId, latestByDevice]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `initialised` ref ensures the camera only fits once per event change. After the initial fit, the user owns the camera (they pan / zoom freely; the camera doesn't snap back).
|
||||||
|
|
||||||
|
### `<MapSelectedDevice />` — reactive follow
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MapSelectedDevice() {
|
||||||
|
const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId);
|
||||||
|
const latest = usePositionStore((s) => s.latestByDevice);
|
||||||
|
const mapFollow = usePositionStore((s) => s.mapFollow);
|
||||||
|
const lastSelectionRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const position = latest.get(selectedDeviceId);
|
||||||
|
if (!position) return;
|
||||||
|
|
||||||
|
// On selection change, always pan.
|
||||||
|
if (lastSelectionRef.current !== selectedDeviceId) {
|
||||||
|
lastSelectionRef.current = selectedDeviceId;
|
||||||
|
map.easeTo({
|
||||||
|
center: [position.lon, position.lat],
|
||||||
|
zoom: Math.max(map.getZoom(), 14),
|
||||||
|
duration: 600,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On position update, only pan if mapFollow is on.
|
||||||
|
if (mapFollow) {
|
||||||
|
map.easeTo({
|
||||||
|
center: [position.lon, position.lat],
|
||||||
|
duration: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedDeviceId, latest, mapFollow]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two behaviours:
|
||||||
|
|
||||||
|
- **Selection change:** always pans (zoom in if zoomed out). The user just clicked the device; they want to see it.
|
||||||
|
- **Position update with follow on:** smooth pan without a zoom change.
|
||||||
|
|
||||||
|
### `<MapCamera coordinates>` — imperative one-shot
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MapCamera({ coordinates, padding }: { coordinates: [number, number][]; padding?: number }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (coordinates.length === 0) return;
|
||||||
|
if (coordinates.length === 1) {
|
||||||
|
map.easeTo({ center: coordinates[0], zoom: 14, duration: 600 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
for (const c of coordinates) bounds.extend(c);
|
||||||
|
map.fitBounds(bounds, { padding: padding ?? 60, duration: 600 });
|
||||||
|
}, [coordinates, padding]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by future features (replay scrubbing, "fit to all" button). Phase 2 may not call it directly; just shipping the primitive.
|
||||||
|
|
||||||
|
### Manual-pan auto-disables follow
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Inside MapSelectedDevice or as a separate effect:
|
||||||
|
useEffect(() => {
|
||||||
|
let lastReason: string | undefined;
|
||||||
|
|
||||||
|
const onMoveStart = (ev: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) => {
|
||||||
|
// The map fires moveStart for both programmatic easeTo and user gesture.
|
||||||
|
// We only want to disable follow on user gestures.
|
||||||
|
if (ev.originalEvent != null) {
|
||||||
|
// user gesture
|
||||||
|
if (usePositionStore.getState().mapFollow) {
|
||||||
|
usePositionStore.getState().setMapFollow(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('movestart', onMoveStart);
|
||||||
|
return () => map.off('movestart', onMoveStart);
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
`originalEvent` is present on user gestures, absent on programmatic moves. The check distinguishes "user dragged the map" from "our easeTo fired."
|
||||||
|
|
||||||
|
### Follow toggle UI
|
||||||
|
|
||||||
|
A small button or icon-button in the chrome (next to the basemap switcher works). Two states:
|
||||||
|
|
||||||
|
- "Follow on" — solid-fill location pin, tooltip "Following selected device."
|
||||||
|
- "Follow off" — outline pin, tooltip "Click to follow selected device."
|
||||||
|
|
||||||
|
Click toggles `mapFollow`. Selecting a device with follow off still pans to it once (selection change), then leaves the camera alone.
|
||||||
|
|
||||||
|
### What this task does NOT include
|
||||||
|
|
||||||
|
- **Auto-fit to all visible devices.** No "show me everyone" zoom button in v1. Defer.
|
||||||
|
- **Pitch / bearing controls.** MapLibre's default pinch-rotate is enough; no custom 3D camera controls.
|
||||||
|
- **Custom keyboard navigation.** Arrow keys to pan are MapLibre default and work fine.
|
||||||
|
- **Replay-mode camera.** Phase 4 — reuses `<MapCamera>` as the imperative primitive.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean.
|
||||||
|
- [ ] On `/monitor`, picking an event auto-fits the map to all devices in the snapshot.
|
||||||
|
- [ ] Clicking a device pans + zooms to it (zooms in if currently zoomed out, doesn't zoom out if already zoomed in).
|
||||||
|
- [ ] With "Follow on", selecting a moving device follows its position smoothly.
|
||||||
|
- [ ] With "Follow on", manually panning the map disables follow (the toggle visibly switches off).
|
||||||
|
- [ ] With "Follow off", selecting still pans once but doesn't follow subsequent updates.
|
||||||
|
- [ ] Switching events triggers a new initial fit (ignoring the previous event's "I last fit this" memory).
|
||||||
|
|
||||||
|
## Risks / open questions
|
||||||
|
|
||||||
|
- **Pan-disables-follow false positives.** Pinch-zoom on touch devices may also fire `movestart` with `originalEvent`. That's correct — pinch-zooming counts as user-driven and should disable follow.
|
||||||
|
- **Snapshot empty at fit time.** If the snapshot returns zero devices (edge case: event with no `entry_devices` rows), no fit happens. That's fine — the map stays at its previous extent.
|
||||||
|
- **Easing duration on update.** 300ms is smooth but at high update rates (>3Hz position updates) it can feel laggy. If real dogfood feedback complains, reduce to 150ms or 0.
|
||||||
|
- **Padding on `fitBounds`.** 10% of canvas width is reasonable but on a 4K display gives ~200px padding which may feel excessive. Cap at e.g. `Math.min(canvas * 0.1, 80)`.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
(Filled in when the task lands.)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Task 2.9 — Connection status + per-device last-seen indicators
|
||||||
|
|
||||||
|
**Phase:** 2 — Live monitoring map
|
||||||
|
**Status:** ⬜ Not started
|
||||||
|
**Depends on:** 2.4.
|
||||||
|
**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Visible system state".
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Show operators *just enough* about system state so they can answer two questions at a glance:
|
||||||
|
|
||||||
|
1. "Is the SPA still connected to live data?" — global WS status.
|
||||||
|
2. "Is this specific device still reporting?" — per-device last-seen age.
|
||||||
|
|
||||||
|
Not noisy. Subtle UI; not banners and modal warnings. The design ethos: operators trust the map until something goes wrong, at which point they need a quick read of *what* went wrong without a wall of red.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- **`src/ui/components/connection-chip.tsx`** — `<ConnectionChip />` rendered in the monitor route's chrome (top-right corner of the map UI, near the basemap switcher). Reads `useConnectionStore`. Three visible states:
|
||||||
|
- **Connected** — small green dot, hidden text (or "Live" only on hover/expand). Subtle.
|
||||||
|
- **Reconnecting** — amber dot pulsing, text "Reconnecting…" with attempt count if useful.
|
||||||
|
- **Disconnected / offline** — red dot, text "Offline — last live: 14:02:11" showing the last successful contact time.
|
||||||
|
- **`src/ui/components/device-last-seen.tsx`** — `<DeviceLastSeen deviceId>` component. Renders nothing in normal state; renders a small subscript / icon when the device's last position is older than threshold (default: 60s).
|
||||||
|
- Used inside `<MapPositions>`'s feature properties (or as a separate symbol layer keyed off the `lastSeenAge` derived field), and in any future per-device sidebar.
|
||||||
|
- **`src/live/connection-store.ts`** updated — adds `lastConnectedAt: number | null` (already in 2.4's spec) and `lastDisconnectedAt: number | null`. Toast / chip use these for "last live: N min ago" formatting.
|
||||||
|
- **`src/live/last-seen.ts`** — utility: `formatLastSeen(ts: number, now = Date.now()): string` returns `"now"` / `"5s ago"` / `"2m ago"` / `"14:02"` based on age. Used by both the chip and the per-device indicator.
|
||||||
|
- **A "stale-position" derived signal** — at coalescer flush time, the position store can compute `staleByDevice: Set<string>` for devices whose last update is older than `STALE_THRESHOLD_MS` (default 60s). Map layers reading this Set apply a faded-icon variant. Or: a separate symbol layer renders a "warning" badge over stale devices' markers.
|
||||||
|
|
||||||
|
## Specification
|
||||||
|
|
||||||
|
### `<ConnectionChip />`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function ConnectionChip() {
|
||||||
|
const status = useConnectionStore((s) => s.status);
|
||||||
|
const lastConnectedAt = useConnectionStore((s) => s.lastConnectedAt);
|
||||||
|
|
||||||
|
if (status === 'connected') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500" aria-hidden />
|
||||||
|
<span className="text-muted-foreground">Live</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'connecting' || status === 'reconnecting') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" aria-hidden />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{status === 'connecting' ? 'Connecting…' : 'Reconnecting…'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-destructive" aria-hidden />
|
||||||
|
<span className="text-destructive">
|
||||||
|
Offline {lastConnectedAt ? `· last live ${formatLastSeen(lastConnectedAt)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Position: top-right of the monitor route, in a small horizontal bar with the basemap switcher and other controls. Inline-flex.
|
||||||
|
|
||||||
|
### Per-device last-seen — two strategies
|
||||||
|
|
||||||
|
**Strategy A: Faded marker.** A separate symbol layer with a `'icon-opacity'` expression based on a `staleSec` property on each feature:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'icon-opacity': [
|
||||||
|
'interpolate', ['linear'], ['get', 'staleSec'],
|
||||||
|
0, 1.0, // fresh
|
||||||
|
60, 1.0, // 60s — full opacity
|
||||||
|
300, 0.4, // 5min — faded
|
||||||
|
1800, 0.2, // 30min — very faded
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
The position store includes `staleSec` in each feature's properties at coalescer-flush time.
|
||||||
|
|
||||||
|
**Strategy B: Badge / overlay.** A second symbol layer drawing a small "no-signal" icon over stale markers, filtered to `['>=', ['get', 'staleSec'], 60]`.
|
||||||
|
|
||||||
|
**Pick A for v1.** Simpler, fewer layers, cleaner visually. B can layer on top later if A isn't legible enough.
|
||||||
|
|
||||||
|
### Updating `staleSec`
|
||||||
|
|
||||||
|
Recomputed every second by a setInterval inside the position store (not on every position update — that would invalidate `latestByDevice` constantly):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// In src/live/position-store.ts
|
||||||
|
let staleTickerId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function startStaleTicker() {
|
||||||
|
if (staleTickerId) return;
|
||||||
|
staleTickerId = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
set((state) => {
|
||||||
|
// Only bump versionTick if any device's stale-bucket changed.
|
||||||
|
// ... or just trigger subscribers wholesale every second.
|
||||||
|
return { stalenessTick: now };
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Map layers re-derive `staleSec` from `latestByDevice` + `stalenessTick` on every flush. The store doesn't mutate position data; it just bumps a version tick that triggers selectors that compute `staleSec` themselves.
|
||||||
|
|
||||||
|
### Logout / unmount cleanup
|
||||||
|
|
||||||
|
The stale ticker runs as long as the SPA is mounted. Stop on logout (status flips to anonymous) or when navigating away from `/monitor`. Use a `useEffect` in `<MonitorPage>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
startStaleTicker();
|
||||||
|
return () => stopStaleTicker();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### What this task does NOT include
|
||||||
|
|
||||||
|
- **Loud "device offline!" notifications.** No toast, no modal. The faded icon is the signal. If operators need louder, Phase 3.4's per-device detail panel can add an alert badge.
|
||||||
|
- **Per-event "X devices offline" summary.** Phase 3 polish.
|
||||||
|
- **Auto-recovery testing for the WS reconnect.** That's covered by 2.4's acceptance.
|
||||||
|
- **Notification API integration.** Browser push on disconnect — Phase 4.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean.
|
||||||
|
- [ ] On `/monitor`, the connection chip top-right shows "Live" with a green dot during normal operation.
|
||||||
|
- [ ] Disconnecting the network: the chip flips to "Reconnecting…" within a few seconds; on reconnect, back to "Live"; on permanent disconnect (close + no reconnect for 30s+), shows "Offline · last live HH:MM:SS".
|
||||||
|
- [ ] A device that hasn't reported in 5+ minutes appears noticeably faded on the map (not invisible — operators still see *where it was last seen*).
|
||||||
|
- [ ] On a fresh page load with no positions yet, the chip says "Connecting…" briefly, then "Live" once the snapshot arrives.
|
||||||
|
- [ ] No banner / toast spam during normal operation.
|
||||||
|
|
||||||
|
## Risks / open questions
|
||||||
|
|
||||||
|
- **Threshold tuning.** 60s = "fresh", 5min = "fading", 30min = "stale". For Teltonika devices reporting at 1Hz this is generous; for 0.2Hz devices the 60s window catches a device that just paused at a checkpoint. Watch dogfood data and adjust.
|
||||||
|
- **Setinterval ticker accuracy.** `setInterval(1000ms)` is approximate; on a slow tab it can drift. The visual effect doesn't need wall-clock precision — "this marker is faded because it's been a while" is still correct even if the timer drifted by a few seconds.
|
||||||
|
- **Race with reconnect on offline.** If the SPA is offline and reconnects, the snapshot replays; some markers' last-seen will jump from "stale" to "fresh" in one frame. Verify the visual transition is clean (no flicker).
|
||||||
|
- **Connection chip during boot.** Right after login, the WS hasn't connected yet — chip says "Connecting…". Should this be its own state, or just folded into "reconnecting"? Spec above keeps `connecting` distinct for clarity; either is defensible.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
(Filled in when the task lands.)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 2 — Live monitoring map
|
# 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.
|
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
|
## 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.
|
- **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.
|
- **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 inflate Phase 1 dramatically.
|
- **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.4 WS client + rAF coalescer + store ────┐
|
||||||
| --- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
├─→ 2.5 MapPositions
|
||||||
| 2.1 | MapLibre singleton + `<MapView>` + `mapReady` gate | Module-level instance, detached `<div>`, listener Set for ready transitions |
|
├─→ 2.6 MapTrails
|
||||||
| 2.2 | Tile-source switcher | Esri / OpenTopoMap / OSM / optional Google via `maplibre-google-maps`; reads from runtime config |
|
├─→ 2.7 Event picker
|
||||||
| 2.3 | Sprite preload | Pre-rasterised at `devicePixelRatio` × 4 colour variants; re-`addImage`'d after every style swap |
|
└─→ 2.9 Connection status
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## 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).
|
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).
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 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.
|
## Files modified
|
||||||
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 (50–500 vehicles spread across a country-scale stage) may want different values. Defer until we see real positions on the map.
|
Phase 2 adds these to the existing `spa/` layout:
|
||||||
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.
|
```
|
||||||
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user