f92595a62a
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>
200 lines
12 KiB
Markdown
200 lines
12 KiB
Markdown
---
|
||
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.
|