Files
spa/.planning/phase-2-live-map/01-mapview-singleton.md
T
julian 05543529e4 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.
2026-05-03 09:28:16 +02:00

194 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Task 2.1 — MapView singleton + mapReady gate
**Phase:** 2 — Live monitoring map
**Status:** ⬜ Not started
**Depends on:** Phase 1 complete (1.11.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.)