Files
spa/.planning/phase-2-live-map/01-mapview-singleton.md
T
julian bc54f1e811 feat: task 2.2 tile-source switcher
- pnpm add maplibre-google-maps (runtime; lazy-imported on Google use).
- src/map/core/styles.ts: BasemapDescriptor model with four entries
  (Esri Satellite / OpenTopoMap / OSM / Google Satellite).
  Google entry gated on cfg.googleMapsKey via available(cfg).
  styleCustom() builds a single-raster-source MapLibre style with a
  glyphs URL for future label rendering.
  ensureGoogleProtocol() lazy-loads maplibre-google-maps + addProtocol
  on first use; _googleRegistered guards re-registration.
- src/map/core/map-pref-store.ts: Zustand + persist middleware
  (key: trm-map-prefs, version: 1). Holds basemapId + setBasemap.
- src/map/core/basemap-switcher.tsx: floating control top-right of
  <MapView>. Filters by available(cfg), highlights active, applies via
  getMap().setStyle(). Mount-time bootstrap (no UI state) + user-driven
  onClick (with switching state) split to satisfy React 19's
  react-hooks/set-state-in-effect rule.
- src/routes/_authed/monitor.tsx: renders <BasemapSwitcher /> as a
  child of <MapView>.
- src/vite-env.d.ts: ambient module declaration for
  maplibre-google-maps (no .d.ts ships in the package).

Deviations:
- BasemapDescriptor.style is buildStyle(cfg): StyleSpecification, not
  the spec's StyleSpecification|string union. Google needs the cfg key
  at build time; uniform function shape across all entries is cleaner.
- Bootstrap apply path doesn't use the switching UI state (no visual
  feedback needed during the silent first-paint flip from OSM to user
  preference). User-click path keeps setSwitching for the in-flight
  indicator.

Bundle: MapLibre is now its own chunk (1MB / 273KB gz), shared across
maplibre consumers. Main bundle 371KB / 114KB gz.
2026-05-03 09:29:14 +02:00

222 lines
11 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
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 + `<MapView>` + `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 `<MapView />` full-viewport. No children for 2.1; subsequent tasks plug components in here.
- **`src/routes/_authed/index.tsx`** — home-page card now renders a `<Link to="/monitor">Open live map →</Link>`.
- **`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 `<MapView>` 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 `<MapView>` 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`.