Files
docs/wiki/sources/traccar-maps-architecture.md
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

164 lines
19 KiB
Markdown
Raw Permalink 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: Traccar Web — Maps Architecture
type: source
created: 2026-05-02
updated: 2026-05-02
sources: []
source_path: raw/TRACCAR_MAPS_ARCHITECTURE.md
source_date: 2026-05-02
source_kind: note
tags: [maps, frontend, reference, architecture, maplibre]
---
# Traccar Web — Maps Architecture
Internal architectural deep-dive into how `traccar-web` (the React front-end of the Traccar GPS tracking platform) builds its maps subsystem. Documents the rendering engine, tile-source strategies (including how Google's tiles are integrated despite Google not being a first-class MapLibre provider), GeoJSON-driven feature rendering, geofence editing, and the WebSocket → Redux → `setData` live-data pipeline.
This is the canonical reference architecture for [[react-spa]]. TRM's SPA inherits the bulk of these patterns and diverges in a small, deliberate set of places — see the divergences section below and the dedicated [[maps-architecture]] concept page.
## TL;DR
Traccar's web app does **not** use the Google Maps JavaScript API. It uses **MapLibre GL JS** (a fork of pre-1.0 Mapbox GL JS) as a single, app-lifetime singleton WebGL renderer. Every map "object" — devices, geofences, routes, accuracy circles, POIs — is a feature in a GeoJSON source rendered by MapLibre style layers. Live data flows from a single WebSocket through Redux into `useSelector`-subscribed components that call `setData` on the appropriate source. Google tiles are consumed either via the official Map Tiles API (through the `maplibre-google-maps` adapter, which registers a `google://` protocol handler) or by hitting Google's legacy public tile servers directly when no API key is configured.
The architecture is well-designed for the rendering side. The likely failure mode at scale is the **per-message Redux dispatch + per-`setData` call** pattern in the data pipeline — at high position rates this cascades through selectors and rebuilds full feature collections on every position. TRM's SPA addresses that with a `requestAnimationFrame` coalescer at the WS boundary (see divergences).
## Key claims
Each claim is self-contained — readable without prior context.
### Rendering engine
- **MapLibre GL JS is the rendering engine**, not the Google Maps SDK or Leaflet. WebGL all the way down: vector and raster sources, GPU-rasterised symbol/circle/line layers, no DOM markers.
- **A single `maplibregl.Map` instance lives for the entire app lifetime.** It's constructed at module load against a detached `<div>` held in a module-level variable. The React `<MapView>` component just appends that detached `<div>` into its own ref on mount and removes it on unmount, then calls `map.resize()`.
- This means **navigating between pages (Main / Replay / Geofences / Reports) doesn't recreate the WebGL context, doesn't re-upload icon sprites, and doesn't refetch the style.** Significant perf win.
- Every other map component in the codebase imports the same singleton: `import { map } from './core/MapView'` and calls `map.addSource`, `map.addLayer`, `map.on(...)` directly. They render `null` to React — they are imperative side effects wrapped in a component for lifecycle handling.
### `Map*` component contract
- **Every `Map*` component is a side-effect-only React component.** It returns `null` and uses `useEffect` to add sources/layers, with the cleanup function removing them.
- **Two-effect pattern is consistent across the codebase:** one `useEffect` with `[]`-ish deps for setup (add source + layer), one with `[data]` deps for updates that just calls `map.getSource(id)?.setData(...)`.
- **`useId()` is used to generate unique source/layer ids** so the same component can be mounted multiple times safely.
### Tile layers
- **Two flavours of basemap style:** vector styles (full MapLibre style JSON URLs from providers like OpenFreeMap, MapTiler, LocationIQ, TomTom, Ordnance Survey) and raster styles built ad-hoc by a `styleCustom({ tiles, minZoom, maxZoom, attribution })` helper.
- Raster `styleCustom` is used for OSM, OpenTopoMap, Carto, **all three Google variants** (Road / Satellite / Hybrid), Bing, HERE, Yandex, AutoNavi, Mapbox raster styles, and any user-supplied custom URL.
- The synthesised raster style includes a `glyphs` URL pointing at `cdn.traccar.com/map/fonts/...` so even raster-only basemaps can render text labels for overlays (geofences, devices, POIs).
- **Google tiles are accessed two ways depending on whether the user has set a `googleKey`:**
- **With key:** `tiles: ['google://roadmap/{z}/{x}/{y}?key=...']`. The `google://` protocol is intercepted by `maplibre-google-maps`'s `googleProtocol` handler, registered once globally via `maplibregl.addProtocol('google', googleProtocol)`. The handler calls Google's **official Map Tiles API** with session-token authentication. This is the legitimate, billable path.
- **Without key:** `tiles: [0,1,2,3].map(i => 'https://mt${i}.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}&s=Ga')`. Hits Google's legacy public tile servers directly. Unauthenticated, ToS-grey, availability at Google's discretion.
- Hybrid uses `satellite` + `&layerType=layerRoadmap` on the keyed path.
- A user-configurable `custom` style branches: if `mapUrl` contains `{z}` or `{quadkey}` it's treated as a tile template via `styleCustom`; otherwise it's treated as a full style JSON URL.
### Style swaps reset the world
- **Setting a new style wipes all custom sources/layers.** This is MapLibre's standard behaviour, not a Traccar quirk.
- A module-level `ready` flag plus a `Set<readyListeners>` lets React components subscribe to global ready state. The basemap switcher fires `onBeforeSwitch → updateReadyValue(false)`, which causes all child map components to unmount and clean up. After `map.setStyle(...)` the switcher waits for `styledata`, polls `map.loaded()` every 33 ms, then calls `initMap()` (which re-adds icon sprites via `map.addImage(...)`) and `updateReadyValue(true)`. Children remount and re-add their sources/layers.
- Components that should persist across style swaps (`MapScale`, `MapCurrentLocation`, `MapGeocoder`, `MapNotification`) are added/removed by their own components, outside `<MapView>`'s children list.
### Icon sprites
- **All device category icons are SVGs** (`car`, `bus`, `truck`, `bicycle`, `plane`, `ship`, `person`, `animal`, ...) plus a generic `background.svg` and a `direction.svg` arrow.
- **Pre-rasterised once at app startup** (`preloadImages()` in `src/index.jsx`):
1. Each SVG is loaded to an `HTMLImageElement`.
2. For each `category × colour` (`info`, `success`, `error`, `neutral`), `prepareIcon(background, icon, color)` draws the background to a canvas at `devicePixelRatio` scale, tints the icon with the colour using a `destination-atop` canvas trick, composites it centred, and stores the raw `ImageData` keyed `${category}-${color}` (e.g. `car-success`, `truck-error`).
- `MapView.initMap` then calls `map.addImage(key, imageData, { pixelRatio })` for every entry once a style has loaded. After that, layers can reference any sprite by name in style expressions like `'icon-image': '{category}-{color}'`.
### Live device positions — `MapPositions`
- **Two GeoJSON sources, identified by `useId()`-based unique strings:**
- `id` — non-selected devices, `cluster: true`, `clusterMaxZoom: 14`, `clusterRadius: 50`. MapLibre's built-in clustering handles aggregation.
- `selected` — the currently selected device only, never clustered, always rendered on top.
- Each source gets a symbol layer rendering `'icon-image': '{category}-{color}'` plus the title (device name or `fixTime`), and a `direction-…` symbol layer filtered to features where `direction === true`, drawing a direction arrow rotated by `course` with `'icon-rotation-alignment': 'map'`.
- Plus a single `clusters` symbol layer on the main source filtered `['has', 'point_count']` showing a `background` icon with the count.
- Feature properties drive layer expressions: `category` (selects sprite), `color` (`success`/`error`/`neutral`/`info` based on status or attribute override), `rotation` (course), `direction` (controlled by `mapDirection` user pref: `none` / `all` / `selected`).
- **Updates flow through `setData`:** a second `useEffect` re-runs whenever `positions`, `devices`, `selectedPosition`, or `mapCluster` change, and calls `map.getSource(source)?.setData(...)` with a freshly built FeatureCollection for each source. MapLibre diffs and re-renders.
### Geofences (rendering and editing)
- **Three layers on a single GeoJSON source:** `geofences-fill` (semi-transparent polygon fill, opacity 0.1), `geofences-line` (per-feature `color`/`width`/`opacity` from attributes), `geofences-title` (symbol layer rendering `{name}`).
- **Geofences arrive from the backend as WKT** in `item.area`. `geofenceToFeature(theme, item)` handles conversion: `CIRCLE(...)` strings are extracted as `(lat lon, radius)` and approximated as 32-step polygons in metres via `@turf/circle`; everything else is `wellknown.parse(item.area)` followed by `reverseCoordinates(...)` because **WKT is `lat lon` while GeoJSON is `lng lat`**.
- The reverse mapping (`geometryToArea`) is used when saving edits.
- **Editing uses `@mapbox/mapbox-gl-draw`** wrapped to look native to MapLibre (CSS class patches). Modes: polygon, line_string, trash. Listeners: `draw.create` → POST `/api/geofences`, `draw.update` → PUT, `draw.delete` → DELETE. On Redux geofence state change, Draw is cleared and re-populated with all features.
### Routes (history and live trails)
- **Replay route (`MapRoutePath`)**: builds a `FeatureCollection` of **per-segment** `LineString` features, one per consecutive position pair, and **colours each segment by the second point's speed** via `getSpeedColor(speed, minSpeed, maxSpeed)`. If the device has a `web.reportColor` attribute, that overrides speed-based colouring with a fixed colour for the whole track. Width/opacity come from user preferences.
- **Replay points (`MapRoutePoints`)**: a symbol layer rendering the literal text `▲` as the marker, rotated by `course` and tinted by speed colour. Clicks emit `onClick(positionId, index)` so a slider can scrub to that point.
- **Generic polyline (`MapRouteCoordinates`)**: takes pre-computed `coordinates` and renders a single `LineString` with optional name label.
- **Live trails (`MapLiveRoutes`)**: reads from `state.session.history` — a Redux dictionary `{ [deviceId]: [[lon, lat], ...] }`. Updated inside `sessionActions.updatePositions`, **capped to `web.liveRouteLength` points per device (default 10)**. Behaviour gated by `mapLiveRoutes` user attribute: `none` / `selected` / all devices.
- **Accuracy circles (`MapAccuracy`)**: for each position with `accuracy > 0` (in metres), `turfCircle([lon, lat], accuracy * 0.001)` polygon rendered as a translucent fill in the theme's geometry colour.
### Camera control
Three components for three jobs:
- **`MapCamera`** — one-shot fit. If passed `coordinates` or `positions`, builds an `LngLatBounds` and calls `map.fitBounds(bounds, { padding: min(w,h) * 0.1, duration: 0 })`. Used in `ReplayPage`.
- **`MapDefaultCamera`** — initial framing on `MainPage`. Picks (priority order) the selected device's position, the user's saved `latitude/longitude/zoom` preference, or a `fitBounds` over all visible positions. Runs at most once.
- **`MapSelectedDevice`** — reactive follow. Watches `state.devices.selectedId`, `selectTime`, and the position of the selected device. Fires `map.easeTo(...)` when the user reselects/re-clicks or when `mapFollow` is on and the selected device's coordinates change.
### Live data pipeline
End-to-end flow for "a device moved on the screen":
```
WebSocket /api/socket → SocketController.jsx → dispatch(...)
→ Redux store (session.positions, session.history, devices, events)
→ useSelector in MapPositions / MapLiveRoutes / MapSelectedDevice
→ map.getSource(id).setData(FeatureCollection)
→ MapLibre re-renders (WebGL)
```
- `SocketController.jsx` opens `wss?://<host>/api/socket` once authenticated. On `onmessage`, parses the JSON envelope and dispatches one of: `devicesActions.update`, `sessionActions.updatePositions`, `eventsActions.add`, `sessionActions.updateLogs`.
- Reconnect on `onclose` with a 60 s loop; `online` and `visibilitychange` listeners re-test the socket; periodic `socket.send('{}')` ping.
- `sessionActions.updatePositions` reducer per incoming position: (a) `state.positions[deviceId] = position` overwrites the latest; (b) if `mapLiveRoutes` is on, appends `[longitude, latitude]` to `state.history[deviceId]` capped to `liveRouteLength`; same-coord-as-last is skipped.
- `MainPage` reads `positions`, runs `useFilter` to compute `filteredPositions`, passes them into `<MainMap>` which composes `MapView`, `MapOverlay`, `MapGeofence`, `MapAccuracy`, `MapLiveRoutes`, `MapPositions`, `MapDefaultCamera`, `MapSelectedDevice`, `PoiMap` plus auxiliary controls.
### Conventions to keep in mind (verbatim from the doc)
1. Every `Map*` component is a side-effect-only React component returning `null` and using `useEffect` to add sources/layers.
2. `useId()` is used to generate unique source/layer ids so the same component can be mounted multiple times safely.
3. All data updates flow through GeoJSON `setData` — no direct DOM marker manipulation.
4. Style swaps reset the world; anything custom must be re-added after `setStyle`. The `mapReady` gate coordinates this.
5. Coordinates everywhere are `[lon, lat]` (MapLibre/GeoJSON convention). `reverseCoordinates` exists specifically to bridge the WKT `lat lon` ordering used by Traccar's geofence storage.
6. No Google Maps SDK is loaded in the browser. Even when Google tiles are used, the path is `tile URL → fetch → blob → MapLibre raster source`. The only Google-specific code is the protocol adapter from `maplibre-google-maps`.
## Notable quotes
> A **single** `maplibregl.Map` instance lives for the entire app lifetime, attached to a **detached `<div>`** held in a module-level variable. The React `<MapView>` component just mounts that detached `<div>` into its own ref (`appendChild`) on mount and removes it on unmount, then calls `map.resize()`. (§2)
> All data updates flow through GeoJSON `setData` — there is no direct DOM marker manipulation. This keeps the WebGL pipeline efficient and lets MapLibre handle clustering/visibility. (§14)
> No Google Maps SDK is loaded in the browser. Even when Google tiles are used, the code path is `tile URL → fetch → blob → MapLibre raster source`. The only Google-specific code is the protocol adapter from `maplibre-google-maps`, which is registered once in `MapView.jsx`. (§14)
## TRM divergences
The TRM SPA inherits the bulk of this architecture and diverges in a small, deliberate set of places. Each divergence is anchored to a specific reason, not "I prefer X."
| Traccar pattern | TRM pattern | Reason |
|---|---|---|
| Geofence storage in WKT (`item.area` is a WKT string); client converts WKT → GeoJSON on every read and GeoJSON → WKT on every save via `wellknown` + `reverseCoordinates`. | **Native PostGIS GeoJSON.** The API serves `ST_AsGeoJSON(geometry)` directly; client receives valid GeoJSON, no conversion. | Traccar predates PostGIS being a first-class option in many environments; we have it deployed and the round-trip is dead weight. |
| `CIRCLE(lat lon, radius)` WKT extension approximated client-side as a 32-step polygon via `@turf/circle`. | **Real `geometry(POLYGON, 4326)` columns**, with circles either stored as polygons up front or computed server-side via `ST_Buffer` if needed. | Same reason as above; native geometry types remove the approximation step. |
| Redux dispatch on every WebSocket message arrival. At 200 racers × 1Hz that's 200 dispatches/sec, each cascading through `useSelector`, `useFilter`, and a freshly-built FeatureCollection in `setData`. | **`requestAnimationFrame`-coalescing buffer at the WS boundary.** WS messages push into a per-device map; an rAF loop fires once per frame (~16ms), reads the buffer, and triggers a single state update. | Traccar's lag at high update rates is almost certainly here, not in the GPU pipeline. ~30 lines of code, eliminates the cascade. |
| Redux for everything (positions, history, devices, events, session, UI prefs). | **Zustand for high-frequency live state** (positions, trails); **TanStack Query for Directus REST**; Redux not used. | Zustand stores can be subscribed to with selectors that don't re-run components unnecessarily; meaningful render-cost reduction at the rates we expect. |
| `liveRouteLength` default 10 — barely a trail. | **Default 200 points per device**, configurable. | Rally operators want a few minutes of trail visible to read what a racer is doing; 10 points at 1Hz is 10 seconds. |
| Icon sprite set covers general fleet (car, bus, truck, plane, ship, animal). | **Racing-specific sprite set** (rally car, quad / ATV, SSV / UTV, motorcycle, runner, hiker) plus the generic `default`. | Race operators identify by category at a glance; "truck" and "plane" sprites would be misleading. |
| `react-map-gl` could be a candidate React wrapper for MapLibre. | **Raw MapLibre via the singleton + side-effect components pattern** (Traccar's choice). No `react-map-gl`. | The declarative wrapper fights the imperative `setData` pattern that's the whole point of the architecture. The Traccar approach is cleaner and gives full control. |
## Open questions surfaced by this ingest
- **Clustering parameters.** Traccar uses `clusterMaxZoom: 14`, `clusterRadius: 50`. Rally racing density (50500 vehicles spread across a country-scale stage) may want different values. Defer until we have a real map open with real positions.
- **Basemap switcher scope for v1.** Traccar offers 30+ basemap options; for the dogfood we don't need that. Decide a starter set: probably one satellite (Esri or Google via adapter), one topo (OpenTopoMap), one street (OSM), and the "custom URL" escape hatch.
- **Sprite set finalisation.** Rally / quad / SSV / motorcycle / runner / hiker covers the dogfood disciplines. Need actual SVG assets — borrow from open icon sets or commission.
- **Live trail per-segment colouring.** Traccar colours replay segments by speed. For live trails it just renders a flat colour. Adopting the per-segment-by-speed pattern for live too would be a small upgrade — race operators glance at colour and immediately see who's pushing vs. cruising.
- **`@mapbox/mapbox-gl-draw` license check.** It's open source but worth confirming the licence is compatible with our deployment (Mozilla Public License 2.0, last we checked).
- **Geocoder.** Traccar uses Nominatim via `MaplibreGeocoder`. For Albania-specific use we may want a different gazetteer, or skip search entirely for v1.
## Cross-references
- [[react-spa]] — the entity that consumes this reference architecture.
- [[maps-architecture]] — concept page distilling the patterns from this source plus TRM's refinements.
- [[live-channel-architecture]] — TRM's WS contract on the producer (Processor) side; this source documents the consumer side.
- [[processor]] — produces the live position stream this architecture consumes.
- [[directus]] — issues the JWT used for WS auth.