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.)
|
||||
Reference in New Issue
Block a user