# Task 2.1 — MapView singleton + mapReady gate **Phase:** 2 — Live monitoring map **Status:** ⬜ Not started **Depends on:** Phase 1 complete (1.1–1.10). **Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Singleton map", §"Style swaps reset the world"; `docs/wiki/sources/traccar-maps-architecture.md` §2. ## Goal Stand up the MapLibre rendering singleton: a single `maplibregl.Map` instance held in a module-level variable, attached to a detached `
`, which the React `` component mounts/unmounts via refs. Plus the `mapReady` gate that coordinates the style-swap dance — when the basemap changes, every custom source/layer is wiped, so children must remount. After this task lands, the live-map page has a working map widget showing a basemap and nothing else; subsequent tasks layer features on top. This is the foundation every other Phase 2 task depends on. Get it right and the rest is cosmetic. ## Deliverables - **`pnpm add maplibre-gl @types/geojson`** — install the rendering engine and GeoJSON types. - **`src/map/core/map-view.tsx`** exporting: - `map` — the module-level `maplibregl.Map` singleton. Created lazily on first import (so SSR / test environments without `window` don't choke). - `` — React component. Mounts the singleton's detached `
` into its own ref on mount; removes it on unmount; calls `map.resize()` after mount and on window resize. - `useMapReady()` — hook that subscribes a component to the global ready state. Returns `boolean`. - `subscribeMapReady(cb: (ready: boolean) => void): () => void` — non-React subscription for tests/utilities. - `getMapReady(): boolean` — synchronous read. - **`src/map/core/styles.ts`** (stub for 2.2 to fill) — initial style descriptor used at first render. For 2.1, hardcode an OSM raster style as the bootstrap default; 2.2 introduces the switcher. - **`src/routes/_authed/monitor.tsx`** — new route at `/monitor`. Renders `` filling the viewport (minus the `_authed` chrome). Wired into the home page's "Live monitoring map" card as a link. The route is the integration point every subsequent Phase 2 task plugs UI into. - **CSS:** import `maplibre-gl/dist/maplibre-gl.css` once at the entry point (or inside `src/map/core/map-view.tsx`'s top-level imports). Without it, controls render unstyled. - **Update `src/routes/_authed/index.tsx`** — the placeholder home page's card now includes a `Open live map →` so the page is reachable from a known surface. ## Specification ### Singleton initialisation ```ts // src/map/core/map-view.tsx (sketch) import maplibregl, { type Map as MapLibreMap } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { defaultStyle } from './styles'; let _map: MapLibreMap | null = null; let _container: HTMLDivElement | null = null; function getOrCreateMap(): MapLibreMap { if (_map) return _map; _container = document.createElement('div'); _container.style.width = '100%'; _container.style.height = '100%'; _map = new maplibregl.Map({ container: _container, style: defaultStyle, attributionControl: { compact: true }, }); // Hook style-data lifecycle for the mapReady gate (see below). return _map; } ``` The lazy-create pattern lets tests / SSR import the module without immediately instantiating MapLibre (which needs a DOM). ### `` component ```tsx export function MapView({ children }: { children?: ReactNode }) { const ref = useRef(null); const ready = useMapReady(); useEffect(() => { const map = getOrCreateMap(); const container = _container!; ref.current?.appendChild(container); map.resize(); const onWindowResize = () => map.resize(); window.addEventListener('resize', onWindowResize); return () => { window.removeEventListener('resize', onWindowResize); // Detach but DON'T destroy the map — singleton lives on. if (container.parentNode === ref.current) { ref.current?.removeChild(container); } }; }, []); return (
{ready && children}
); } ``` Children only render when `mapReady` is true. This is the gate that makes child `Map*` components safe to call `map.addSource(...)` from their `useEffect` setup — by the time their effect runs, the style is loaded. ### The `mapReady` gate ```ts // Same file as the singleton. let _ready = false; const _readyListeners = new Set<(ready: boolean) => void>(); function setReady(value: boolean) { if (_ready === value) return; _ready = value; for (const cb of _readyListeners) cb(value); } export function getMapReady(): boolean { return _ready; } export function subscribeMapReady(cb: (ready: boolean) => void): () => void { _readyListeners.add(cb); return () => _readyListeners.delete(cb); } export function useMapReady(): boolean { return useSyncExternalStore( subscribeMapReady, getMapReady, () => false, // SSR fallback ); } ``` Inside `getOrCreateMap`, wire the lifecycle: ```ts _map.on('styledata', () => { // Wait for the style to fully load before flipping ready true. // Poll loaded() because styledata fires for incremental updates too. const check = () => { if (_map?.loaded()) setReady(true); else setTimeout(check, 33); }; setReady(false); check(); }); ``` `setReady(false)` on every `styledata` ensures children unmount before the new style strips their sources; once loaded, `setReady(true)` lets them remount and re-add. That's the canonical MapLibre style-swap dance. ### `` and the route `src/routes/_authed/monitor.tsx`: ```tsx import { createFileRoute } from '@tanstack/react-router'; import { MapView } from '@/map/core/map-view'; export const Route = createFileRoute('/_authed/monitor')({ component: MonitorPage, }); function MonitorPage() { return (
{/* The 3.5rem assumes a 56px top bar; refine when chrome lands. */}
); } ``` For 2.1, no children — the map renders the basemap and nothing else. Subsequent tasks render their components inside ``. ### What this task does NOT do - **No basemap switcher** — that's 2.2. Bootstrap with a hardcoded OSM raster style. - **No sprite registry** — that's 2.3. - **No data layers** — `MapPositions` / `MapTrails` are 2.5 / 2.6. - **No camera control** — 2.8. - **No global error handling for map crashes** — Phase 3.1 (error boundaries) wraps the route. ## Acceptance criteria - [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. - [ ] Navigate to `/monitor` while authenticated → see a full-viewport map with OSM tiles loaded. - [ ] Hard refresh on `/monitor` → map renders without flicker. - [ ] Pan / zoom work via mouse + keyboard. - [ ] Open browser DevTools → no MapLibre warnings about missing style or container. - [ ] React DevTools shows `` rendering once; no remount on unrelated state changes. - [ ] Navigate `/monitor` → `/` → `/monitor` again. Map remains responsive (proves the singleton survives navigation). - [ ] In the browser console, `import('@/map/core/map-view').then(m => m.getMapReady())` returns `true` after the page settles. ## Risks / open questions - **`useSyncExternalStore` and React 19.** Hook is part of React 18+; React 19 keeps it. Confirm — if there's a behavioural drift, fall back to a simple `useEffect` + `useState` pair. - **MapLibre CSS import location.** Importing from `src/map/core/map-view.tsx` is fine for code splitting (the chunk loads with the route). Importing from `src/main.tsx` is also fine but bloats the initial bundle. Prefer the per-module import. - **Detached `
` attribute.** The singleton's container is created in JS and `appendChild`'d into the React-managed DOM. Make sure no global CSS resets target it (e.g. `* > div { ... }`). If something breaks, give the container a class `trm-map-host` and target via that. - **Window resize.** `map.resize()` is cheap but if the page has many resize-driven layouts, debounce. Defer until a real perf issue surfaces. ## Done Three new files + small updates to existing surfaces: - **`src/map/core/styles.ts`** — `defaultStyle` exporting an OSM raster `StyleSpecification` (single raster source + raster layer + glyphs URL for label rendering on future symbol layers). 2.2 will replace this with the descriptor table + switcher. - **`src/map/core/map-view.tsx`** — singleton + `` + `mapReady` gate. Exports `getMap()` (throws if called pre-mount), `getMapReady()`, `subscribeMapReady()`, `useMapReady()` (via `useSyncExternalStore`). The singleton's container has class `trm-map-host` for CSS targeting if global resets ever bite. Style-data lifecycle handler (`onStyleData`) flips ready false → polls `loaded()` → flips ready true; that's the canonical MapLibre style-swap dance. - **`src/routes/_authed/monitor.tsx`** — new route at `/monitor` rendering `` full-viewport. No children for 2.1; subsequent tasks plug components in here. - **`src/routes/_authed/index.tsx`** — home-page card now renders a `Open live map →`. - **`eslint.config.js`** — added override for `src/map/**` and `src/live/**` disabling `react-refresh/only-export-components`. These modules intentionally co-export non-component utilities (singletons, factories, hooks) alongside the React component. Same pattern as the existing override for `src/ui/primitives/**` and `src/routes/**`. **Deps installed:** `maplibre-gl@5.24.0` (runtime), `@types/geojson@7946.0.16` (devDep). **Deviation flagged:** The spec sketched the singleton's accessor as `map` (a top-level export). Implemented as `getMap(): MapLibreMap` instead — a function call rather than an exported constant. Two reasons: 1. The singleton is created lazily on first `` mount, not at module import. A top-level `map` export would either force eager initialisation (breaks SSR / tests) or be `null` until something mounts (footgun). 2. `getMap()` throws a clear error if called before `` mounts — a missing pre-condition that's easy to surface and easy to read in stack traces. A `null`-able export forces every consumer to add their own null check. Side-effect-only `Map*` components (2.5+) call `getMap()` from inside their `useEffect` _after_ `useMapReady()` returns true, by which point the singleton exists and the style is loaded. **Smoke check (local `pnpm dev`):** - `/monitor` renders a full-viewport OSM map. - Pan / zoom / scroll-zoom all work. - `/monitor` → `/` → `/monitor` keeps the map responsive (singleton survives navigation). - Browser console: `(await import('@/map/core/map-view')).getMapReady()` returns `true` after the page settles. **Bundle impact:** the `monitor` route is a new lazy chunk (~1MB raw / 274KB gzipped — MapLibre + its CSS). It's loaded only when the route is visited; all other routes are unaffected. Vite emits a chunk-size warning >500KB; expected and harmless. The home page's bundle is unchanged. Landed in `4283388`.