- 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.
11 KiB
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.tsxexporting:map— the module-levelmaplibregl.Mapsingleton. Created lazily on first import (so SSR / test environments withoutwindowdon't choke).<MapView />— React component. Mounts the singleton's detached<div>into its own ref on mount; removes it on unmount; callsmap.resize()after mount and on window resize.useMapReady()— hook that subscribes a component to the global ready state. Returnsboolean.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_authedchrome). 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.cssonce at the entry point (or insidesrc/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
// 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
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
// 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:
_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:
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/MapTrailsare 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 buildclean.- Navigate to
/monitorwhile 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→/→/monitoragain. Map remains responsive (proves the singleton survives navigation). - In the browser console,
import('@/map/core/map-view').then(m => m.getMapReady())returnstrueafter the page settles.
Risks / open questions
useSyncExternalStoreand React 19. Hook is part of React 18+; React 19 keeps it. Confirm — if there's a behavioural drift, fall back to a simpleuseEffect+useStatepair.- MapLibre CSS import location. Importing from
src/map/core/map-view.tsxis fine for code splitting (the chunk loads with the route). Importing fromsrc/main.tsxis also fine but bloats the initial bundle. Prefer the per-module import. - Detached
<div>attribute. The singleton's container is created in JS andappendChild'd into the React-managed DOM. Make sure no global CSS resets target it (e.g.* > div { ... }). If something breaks, give the container a classtrm-map-hostand 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—defaultStyleexporting an OSM rasterStyleSpecification(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>+mapReadygate. ExportsgetMap()(throws if called pre-mount),getMapReady(),subscribeMapReady(),useMapReady()(viauseSyncExternalStore). The singleton's container has classtrm-map-hostfor CSS targeting if global resets ever bite. Style-data lifecycle handler (onStyleData) flips ready false → pollsloaded()→ flips ready true; that's the canonical MapLibre style-swap dance.src/routes/_authed/monitor.tsx— new route at/monitorrendering<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 forsrc/map/**andsrc/live/**disablingreact-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 forsrc/ui/primitives/**andsrc/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:
- The singleton is created lazily on first
<MapView>mount, not at module import. A top-levelmapexport would either force eager initialisation (breaks SSR / tests) or benulluntil something mounts (footgun). 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. Anull-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):
/monitorrenders a full-viewport OSM map.- Pan / zoom / scroll-zoom all work.
/monitor→/→/monitorkeeps the map responsive (singleton survives navigation).- Browser console:
(await import('@/map/core/map-view')).getMapReady()returnstrueafter 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.