# 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`.