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

11 KiB
Raw Blame History

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

// 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 layersMapPositions / 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.tsdefaultStyle 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.