--- 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 `` 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 `
` held in a module-level variable. The `` React component just appends that detached `
` 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. `` 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(); // 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.