feat: task 2.1 MapView singleton + mapReady gate

- pnpm add maplibre-gl + -D @types/geojson.
- src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2
  replaces with the basemap-switcher descriptor table).
- src/map/core/map-view.tsx: module-level Map singleton lazily created
  on first <MapView> mount, attached to a class="trm-map-host" detached
  <div> that React refs append/remove on mount/unmount. Style-data
  lifecycle flips mapReady false on every styledata event, polls
  loaded() at 33ms intervals, flips ready true once the style is
  loaded — the canonical MapLibre style-swap dance.
- Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via
  useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap()
  throws if called pre-mount; the explicit failure mode beats a
  null-able top-level export.
- src/routes/_authed/monitor.tsx: new /monitor route, full-viewport
  <MapView /> for 2.1 (no children — subsequent tasks plug in here).
- src/routes/_authed/index.tsx: home-page card now links to /monitor.
- eslint.config.js: override for src/map/** + src/live/** disables
  react-refresh/only-export-components. Same pattern as the existing
  overrides for shadcn primitives and route files.

Deviation: spec sketched a top-level `map` constant export; implemented
as `getMap(): MapLibreMap` (a function) so the singleton stays lazy
until <MapView> mounts. Top-level constant would either force eager
init (breaks SSR/tests) or be nullable (footgun). The function form
throws a clear error if called pre-mount.

Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS).
Other routes unaffected. Vite chunk-size warning is harmless.
This commit is contained in:
2026-05-02 21:05:56 +02:00
parent 05543529e4
commit 87a738313e
17 changed files with 522 additions and 70 deletions
@@ -23,7 +23,7 @@ This is the throughput-discipline core. It runs whether or not the map is mounte
deviceId: string;
lat: number;
lon: number;
ts: number; // epoch ms
ts: number; // epoch ms
speed?: number;
course?: number;
accuracy?: number;
@@ -35,9 +35,13 @@ This is the throughput-discipline core. It runs whether or not the map is mounte
- `LiveClient` interface:
```ts
interface LiveClient {
connect(): void; // idempotent
connect(): void; // idempotent
close(): void;
subscribe(topic: string): Promise<{ ok: true; snapshot: PositionEntry[] } | { ok: false; code: string; message?: string }>;
subscribe(
topic: string,
): Promise<
{ ok: true; snapshot: PositionEntry[] } | { ok: false; code: string; message?: string }
>;
unsubscribe(topic: string): Promise<void>;
onPosition(handler: (msg: PositionEntry & { topic: string }) => void): () => void;
}
@@ -50,27 +54,30 @@ This is the throughput-discipline core. It runs whether or not the map is mounte
- `Coalescer.push(p: PositionEntry): void` — non-blocking; writes to the buffer.
- Internally: per-device map of latest-position; rAF loop flushes to the consumer once per frame; clears the buffer.
- **`src/live/position-store.ts`** — Zustand store:
```ts
type PositionState = {
latestByDevice: Map<string, PositionEntry>;
trailsByDevice: Map<string, PositionEntry[]>;
selectedDeviceId: string | null;
activeEventId: string | null; // 2.7 sets this
activeEventId: string | null; // 2.7 sets this
};
type PositionActions = {
applySnapshot(eventId: string, entries: PositionEntry[]): void;
applyPositions(entries: PositionEntry[]): void; // called by coalescer flush
clearForEvent(): void; // on event switch
applyPositions(entries: PositionEntry[]): void; // called by coalescer flush
clearForEvent(): void; // on event switch
selectDevice(deviceId: string | null): void;
};
```
Trail ring buffer is bounded by `MAX_TRAIL_LENGTH` (default 200, see `MaxTrailLength` config below).
- **`src/live/connection-store.ts`** — Zustand store:
```ts
type ConnectionState = {
status: 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
attempt: number; // current reconnect attempt (0 when connected)
attempt: number; // current reconnect attempt (0 when connected)
lastConnectedAt: number | null;
lastErrorMessage: string | null;
};
@@ -78,8 +85,8 @@ This is the throughput-discipline core. It runs whether or not the map is mounte
- **`src/live/index.ts`** — barrel re-exports + a `<LiveBootstrap>` React component that creates the singleton client (using `runtimeConfig.liveWsUrl`) and wires the coalescer to the store. Mounted alongside `<AuthBootstrap>` in `main.tsx`.
- **Constants** — `src/live/constants.ts`:
```ts
export const MAX_TRAIL_LENGTH = 200; // points per device
export const RAF_BUDGET_MS = 16; // soft target; rAF naturally caps to ~60Hz
export const MAX_TRAIL_LENGTH = 200; // points per device
export const RAF_BUDGET_MS = 16; // soft target; rAF naturally caps to ~60Hz
export const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000];
export const RECONNECT_CEILING_MS = 30000;
export const HEARTBEAT_INTERVAL_MS = 60000;
@@ -104,7 +111,10 @@ function createLiveClient({ url }: { url: string }): LiveClient {
let state: ClientState = { kind: 'idle' };
const subscriptions = new Set<string>();
const positionHandlers = new Set<(msg: PositionEntry & { topic: string }) => void>();
const pendingSubscribes = new Map<string, { resolve: (r: SubscribeResult) => void; reject: (e: unknown) => void }>();
const pendingSubscribes = new Map<
string,
{ resolve: (r: SubscribeResult) => void; reject: (e: unknown) => void }
>();
function connect() {
if (state.kind === 'connecting' || state.kind === 'connected') return;
@@ -132,8 +142,7 @@ function createLiveClient({ url }: { url: string }): LiveClient {
function scheduleReconnect() {
if (state.kind === 'closed') return;
const attempt =
state.kind === 'reconnecting' ? state.attempt + 1 : 1;
const attempt = state.kind === 'reconnecting' ? state.attempt + 1 : 1;
const delay = Math.min(
RECONNECT_BACKOFF_MS[attempt - 1] ?? RECONNECT_CEILING_MS,
RECONNECT_CEILING_MS,
@@ -330,7 +339,7 @@ Mount inside `<AuthBootstrap>` so it only connects when authenticated. On logout
- **Map rendering.** That's 2.5 / 2.6 — they read from the store.
- **Event picking.** 2.7 — calls `client.subscribe('event:<id>')` and handles the snapshot.
- **Connection-status UI.** 2.9 reads from `connection-store` and renders chips / banners.
- **Backpressure / drop-oldest.** Defer until measured; the rAF coalescer caps the *flush* rate, not the *receive* rate. If receive ever overwhelms the buffer, add a per-device queue cap.
- **Backpressure / drop-oldest.** Defer until measured; the rAF coalescer caps the _flush_ rate, not the _receive_ rate. If receive ever overwhelms the buffer, add a per-device queue cap.
## Acceptance criteria
@@ -348,7 +357,7 @@ Mount inside `<AuthBootstrap>` so it only connects when authenticated. On logout
- **rAF in background tabs.** Browsers throttle rAF when the tab is backgrounded. The coalescer effectively pauses; positions buffer until the tab is foregrounded again. Acceptable — operators backgrounding the tab don't need real-time updates.
- **Zustand `Map<>` reactivity.** Zustand subscribers fire on reference changes, not deep equality. Wrapping `latest` and `trails` in `new Map(...)` per update is the right pattern; selectors derive specific deviceIds via `usePositionStore((s) => s.latestByDevice.get(deviceId))`.
- **Trail-direction colour.** If we later add speed-coloured per-segment trails (2.6's open question), the trail entries need `speed` carried through. Already in `PositionEntry`. Good.
- **Cookie auth on WS.** Browser sends the session cookie automatically with the WS upgrade *only if same-origin*. Verify the reverse-proxy + Vite-dev-proxy paths preserve same-origin (they do — `/ws-live` is on the page's origin). Worth a smoke test.
- **Cookie auth on WS.** Browser sends the session cookie automatically with the WS upgrade _only if same-origin_. Verify the reverse-proxy + Vite-dev-proxy paths preserve same-origin (they do — `/ws-live` is on the page's origin). Worth a smoke test.
## Done