Files
docs/wiki/concepts/maps-architecture.md
T
julian f92595a62a docs: TRACCAR ingest + processor-ws-contract synthesis + auth-mode realignment
Catches up the wiki with several pieces of work accumulated during this
session.

INGEST: TRACCAR_MAPS_ARCHITECTURE.md
- raw/TRACCAR_MAPS_ARCHITECTURE.md (source doc, read-only).
- wiki/sources/traccar-maps-architecture.md — TL;DR + key claims +
  notable quotes + TRM divergences (PostGIS-native GeoJSON, rAF
  coalescer, Zustand, longer trail, racing sprite set).
- wiki/concepts/maps-architecture.md — distilled patterns for the SPA's
  map subsystem: singleton MapLibre + side-effect-only Map* components +
  two GeoJSON sources + style-swap mapReady gate + sprite preload + WS-
  to-map data flow (with rAF coalescer) + geofence editing + camera
  control trio.
- wiki/entities/react-spa.md — corrected the "talks exclusively to
  Directus" contradiction with [[live-channel-architecture]] (SPA
  connects to two endpoints — Directus + Processor); locked stack (raw
  MapLibre over react-map-gl, Zustand over Redux); added Auth section.
- wiki/concepts/live-channel-architecture.md — single sentence cross-
  referencing [[maps-architecture]] for consumer-side throughput
  discipline.
- index.md — Sources + Concepts entries.

SYNTHESIS: processor-ws-contract
- wiki/synthesis/processor-ws-contract.md — wire-level spec for the
  live-position WebSocket: endpoint, transport, auth handshake,
  subscribe/snapshot/streaming/unsubscribe protocol, reconnect, multi-
  instance behaviour, connection limits, versioning, open questions.
  Implementation-agnostic; the producer is cookie-name-agnostic so the
  spec doesn't pin to a specific Directus auth mode.
- index.md — Synthesis entry.

AUTH-MODE REALIGNMENT (cookie -> session)
- SPA implementation surfaced that Directus SDK 'cookie' mode doesn't
  survive a hard reload cleanly. Switched the SPA to 'session' mode
  (separate commit in trm/spa). Wiki updates here:
- wiki/entities/react-spa.md §Auth pattern — describes session mode
  (single httpOnly session cookie, no separate access token, no
  /auth/refresh dance). Added "Mode choice context" note.
- wiki/synthesis/processor-ws-contract.md §Auth handshake — emphasises
  the producer is cookie-name-agnostic; reframed "Cookie refresh while
  connected" as "Session expiry while connected".

Plus all the chronological log.md entries documenting the above plus
Phase 1.5 planning, SPA Phase 1 planning, and stage verify+seed work
from earlier in the session.

Skipped from this commit: .claude/agent-memory/* (user-local agent
state, not project content); .gitignore (already-modified by user
outside this session's scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:15:09 +02:00

200 lines
12 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.
---
title: Maps architecture
type: concept
created: 2026-05-02
updated: 2026-05-02
sources: [traccar-maps-architecture, gps-tracking-architecture]
tags: [frontend, maps, maplibre, architecture, decision]
---
# Maps architecture
The pattern the [[react-spa]] uses to render real-time positions, trails, geofences, and replay tracks. Inherited (with deliberate refinements) from [[traccar-maps-architecture]], which already fields the same shape — MapLibre GL JS + GeoJSON sources + WebSocket-fed updates — at production scale. Reading this page tells you how the SPA's maps subsystem is structured; reading the source tells you the original implementation in detail.
## Why this shape
DOM-rendered markers (Leaflet's default, or React-Leaflet's `<Marker>` components) thrash layout and repaint on every map move. ~1000 markers is enough to choke a browser. WebGL-rendered markers handle 100k+ at 60fps — the GPU is purpose-built for it. **The decision tree is short:** any map that updates frequently or shows more than a few hundred markers must be WebGL.
MapLibre GL JS is the open-source WebGL map renderer. Vector tiles, raster tiles, GeoJSON sources, symbol/circle/line/fill style layers — all GPU-rendered. Free, no API key required for OSM-derived tiles. Standard tooling.
The architecture that follows is what makes MapLibre tractable inside React without losing the imperative escape hatches you need for streaming-data performance.
## Singleton map
A **single `maplibregl.Map` instance lives for the entire app lifetime**, attached to a detached `<div>` held in a module-level variable. The `<MapView>` React component just appends that detached `<div>` into its own ref on mount and removes it on unmount; it then calls `map.resize()`.
Consequences:
- **Page navigation doesn't recreate the WebGL context.** Live → replay → geofence editor → reports — all share the same instance.
- **Icon sprites stay registered across navigations** — no re-`addImage` cost per page change.
- **State outside React's tree.** Sources, layers, camera position, event listeners are MapLibre's, not React's. React's reconciler doesn't compete.
Trade-off: if multiple tabs of the SPA need *different* maps (e.g. side-by-side comparison), the singleton breaks. For TRM's operator-facing flow this is a non-concern. Revisit only if a use case demands it.
## Side-effect-only `Map*` components
**Every component that participates in the map (`MapPositions`, `MapGeofences`, `MapTrails`, `MapAccuracy`, etc.) returns `null`.** It uses `useEffect` for setup (add source + layer) with cleanup (remove source + layer), and a separate `useEffect` for updates that calls `map.getSource(id)?.setData(...)`.
The two-effect pattern:
```ts
function MapPositions({ positions }: Props) {
const sourceId = useId();
const layerId = useId();
useEffect(() => {
map.addSource(sourceId, { type: 'geojson', data: emptyFC, cluster: true });
map.addLayer({ id: layerId, type: 'symbol', source: sourceId, layout: {...} });
return () => {
map.removeLayer(layerId);
map.removeSource(sourceId);
};
}, []); // setup: runs once
useEffect(() => {
map.getSource(sourceId)?.setData(toFeatureCollection(positions));
}, [positions]); // update: runs on every change
return null;
}
```
`useId()` for source/layer ids lets the same component mount multiple times safely. The empty render keeps React's reconciler out of the rendering hot path.
## Two GeoJSON sources for live positions
Live device rendering uses **two GeoJSON sources**, not one:
- **Non-selected source** with `cluster: true, clusterMaxZoom: 14, clusterRadius: 50`. MapLibre's built-in clustering aggregates dense regions automatically.
- **Selected source**, unclustered, always rendered on top.
This keeps the UX clean without manual z-ordering: clicking a device pulls it out of the clustered layer and into the always-on-top selected layer. Layers reference the appropriate source.
## Style swaps reset the world
Calling `map.setStyle(...)` wipes every custom source and layer the SPA has added. This is MapLibre's native behaviour, not a quirk to work around.
Coordination via a `mapReady` gate:
1. A module-level `ready` flag plus a listener `Set` lets components subscribe to global ready state.
2. Basemap switcher fires `onBeforeSwitch → updateReadyValue(false)`. All children unmount and clean up their sources/layers.
3. After `map.setStyle(...)` resolves (`styledata` event + `map.loaded()` polling), `initMap()` re-adds icon sprites, then `updateReadyValue(true)` flips the gate.
4. `<MapView>` re-renders its `children`; every `Map*` component remounts and re-adds its source + layer.
The gate is the contract: **don't add anything to the map until `mapReady` is true after a style swap.** Violating it causes phantom-source errors that look like Heisenbugs.
## Icon sprites
Device-category icons (rally car / quad / SSV / motorcycle / runner / hiker for TRM, plus a generic `default`) are SVGs **pre-rasterised once at app startup** at `devicePixelRatio` scale, with all colour variants composited up-front, stored in a module-level registry, and `addImage`'d after every style swap.
Layers reference sprites by name in style expressions:
```ts
'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']]
```
So a feature with `properties.category === 'rally-car'` and `properties.color === 'success'` resolves to the `rally-car-success` sprite at GPU paint time. Zero per-frame work in JavaScript.
## WebSocket → map data flow (with rAF coalescer)
The end-to-end live data flow:
```
Processor WS → coalesce buffer → Zustand store → useStore selectors → setData → GPU
```
**The coalesce buffer is the discipline this architecture lives or dies on.** [[traccar-maps-architecture]] dispatches one Redux action per WS message; at 200 racers × 1Hz this fires 200 dispatches/sec, each cascading through `useSelector` consumers, `useFilter` recomputes over the full position array, and a freshly-built FeatureCollection passed to `setData`. That cascade is the most likely source of the lag operators see in production Traccar deployments.
TRM's pattern:
```ts
const buffer = new Map<string, Position>(); // deviceId → latest position
let rafScheduled = false;
socket.onmessage = (msg) => {
const pos = JSON.parse(msg.data);
buffer.set(pos.deviceId, pos);
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(flush);
}
};
function flush() {
rafScheduled = false;
const snapshot = Array.from(buffer.values());
buffer.clear();
positionStore.getState().applyPositions(snapshot);
}
```
Properties of this shape:
- **At most one state update per frame** (~60Hz cap). Dozens of incoming messages per frame collapse to one snapshot.
- **Per-device coalescing.** If the same device reports five times in 16ms, only the latest position is kept.
- **No back-pressure on the WS.** Buffer is bounded by device count, not message rate.
- **Trail history isn't lossy.** This buffer holds *latest visible positions for the map*, not the per-device trail. The trail is a separate per-device ring buffer in the store, written from `applyPositions`.
Throttling-by-user-choice (slider for "1Hz / 5Hz / 30Hz redraw rate") is a future feature layered on top of this — change the rAF callback to a `setInterval` keyed on user preference. The rAF coalescer is the always-on hygiene.
## State management
- **Zustand** for high-frequency live state: positions store, trails store, selection. Granular subscribers, no provider boilerplate, no `useSelector` cascades.
- **TanStack Query** for Directus REST: events, classes, entries, vehicles, devices. Caching, invalidation, refetch on focus.
- **No Redux.** The Traccar Redux pattern works correctly but the dispatch + selector machinery is overhead at our update rates and doesn't earn its weight given Zustand's existence.
## Geofence editing
`@mapbox/mapbox-gl-draw` wrapped to look native to MapLibre (CSS class patches). Modes: polygon, line_string, trash. On `draw.create` / `draw.update` / `draw.delete`, the SPA POSTs / PUTs / DELETEs to Directus's REST API. Directus stores PostGIS geometry directly; the SPA reads back GeoJSON via `ST_AsGeoJSON(geometry)`**no WKT round-trip** unlike [[traccar-maps-architecture]] which uses WKT in storage.
Phase 2 of [[directus]] introduces the `geofences`, `waypoints`, and `speed_limit_zones` collections that this editor writes to.
## Camera control
Three components for three jobs, kept separate so each effect's dependency list is small and the wrong-camera-jump bugs that plague map UIs don't appear:
- **`MapCamera`** — one-shot fit. Builds an `LngLatBounds` from `coordinates` or `positions` props and calls `map.fitBounds(...)` once. Used in replay.
- **`MapDefaultCamera`** — initial framing on first mount. Picks the selected device, the user's saved preference, or a fitBounds over visible positions. Runs at most once.
- **`MapSelectedDevice`** — reactive follow. Watches the selected device id; on selection change or position change (with `mapFollow` on), `map.easeTo(...)`.
Bonus: `MapPadding` calls `map.setPadding({ left: drawerWidth })` so MapLibre's auto-centring accounts for the persistent left drawer in desktop layout.
## Tile sources
Two flavours, identical to [[traccar-maps-architecture]]:
- **Vector styles** for providers shipping a full MapLibre style JSON (OpenFreeMap, MapTiler, etc.). Best label/road/POI styling.
- **Raster styles** synthesised ad-hoc with one raster source + one raster layer. Used for OSM, OpenTopoMap, Esri World Imagery, Google variants, Bing, custom URLs.
The synthesised raster style includes a `glyphs` URL pointing at a font CDN so even raster basemaps can render text labels for our overlays (geofence names, device labels, POIs).
**Google tiles via the official Map Tiles API** are usable through the `maplibre-google-maps` adapter, which registers a `google://` protocol handler that proxies to Google's authenticated tile endpoints. Bring-your-own-key model — Google API key in the SPA's runtime config, not baked into the image.
The dogfood-day starter set (subject to revision):
| Style | Provider | Cost | Notes |
|---|---|---|---|
| Satellite | Esri World Imagery (raster XYZ) | Free, attribution required | No key needed; default for first launch. |
| Topo | OpenTopoMap (raster XYZ) | Free, attribution required | Useful in mountain rallies where pure satellite has cloud/shadow patches. |
| Street | OSM raster | Free | Sanity baseline. |
| Custom | User-supplied URL or style JSON | — | Operator escape hatch. |
| Google Satellite (optional) | Google Map Tiles API via adapter | First 10k req/mo free, then per-tile | Enable only if operators provide a key in runtime config. |
## TRM divergences from [[traccar-maps-architecture]]
| Traccar | TRM | Reason |
|---|---|---|
| Geofence storage in WKT, WKT↔GeoJSON conversion in client | **Native PostGIS GeoJSON** via `ST_AsGeoJSON` | We have PostGIS deployed; the round-trip is dead weight. |
| Redux dispatch per WS message | **rAF coalescer + Zustand store** | Eliminates the per-message cascade that drives Traccar's perceived lag. |
| `liveRouteLength = 10` default | **`liveRouteLength = 200` default** | Rally operators want minutes of trail, not seconds. |
| Generic fleet sprite set (car / truck / plane / ship / animal) | **Racing sprite set** (rally car / quad / SSV / motorcycle / runner / hiker / default) | Operators identify by category at a glance. |
| `react-map-gl` is a candidate React wrapper | **Raw MapLibre + singleton + side-effect components** | Declarative wrappers fight the imperative `setData` pattern. |
## Cross-references
- [[traccar-maps-architecture]] — the source architecture this concept distils.
- [[react-spa]] — the entity that implements this concept.
- [[live-channel-architecture]] — the producer-side WebSocket contract this concept consumes.
- [[processor]] — produces the live position stream.
- [[directus]] — REST API for geofence CRUD; JWT issuer for WS auth.