bc54f1e811
- 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.
222 lines
11 KiB
Markdown
222 lines
11 KiB
Markdown
# 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
|
||
|
||
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`.
|