Files
docs/wiki/sources/traccar-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

19 KiB
Raw Blame History

title, type, created, updated, sources, source_path, source_date, source_kind, tags
title type created updated sources source_path source_date source_kind tags
Traccar Web — Maps Architecture source 2026-05-02 2026-05-02
raw/TRACCAR_MAPS_ARCHITECTURE.md 2026-05-02 note
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.