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>
12 KiB
title, type, created, updated, sources, tags
| title | type | created | updated | sources | tags | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Maps architecture | concept | 2026-05-02 | 2026-05-02 |
|
|
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-
addImagecost 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:
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:
- A module-level
readyflag plus a listenerSetlets components subscribe to global ready state. - Basemap switcher fires
onBeforeSwitch → updateReadyValue(false). All children unmount and clean up their sources/layers. - After
map.setStyle(...)resolves (styledataevent +map.loaded()polling),initMap()re-adds icon sprites, thenupdateReadyValue(true)flips the gate. <MapView>re-renders itschildren; everyMap*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:
'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:
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
useSelectorcascades. - 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 anLngLatBoundsfromcoordinatesorpositionsprops and callsmap.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 (withmapFollowon),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.