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>
19 KiB
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 |
|
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.Mapinstance 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 callsmap.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 callsmap.addSource,map.addLayer,map.on(...)directly. They rendernullto 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 returnsnulland usesuseEffectto add sources/layers, with the cleanup function removing them. - Two-effect pattern is consistent across the codebase: one
useEffectwith[]-ish deps for setup (add source + layer), one with[data]deps for updates that just callsmap.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
styleCustomis 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
glyphsURL pointing atcdn.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=...']. Thegoogle://protocol is intercepted bymaplibre-google-maps'sgoogleProtocolhandler, registered once globally viamaplibregl.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.
- With key:
- Hybrid uses
satellite+&layerType=layerRoadmapon the keyed path. - A user-configurable
customstyle branches: ifmapUrlcontains{z}or{quadkey}it's treated as a tile template viastyleCustom; 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
readyflag plus aSet<readyListeners>lets React components subscribe to global ready state. The basemap switcher firesonBeforeSwitch → updateReadyValue(false), which causes all child map components to unmount and clean up. Aftermap.setStyle(...)the switcher waits forstyledata, pollsmap.loaded()every 33 ms, then callsinitMap()(which re-adds icon sprites viamap.addImage(...)) andupdateReadyValue(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 genericbackground.svgand adirection.svgarrow. - Pre-rasterised once at app startup (
preloadImages()insrc/index.jsx):- Each SVG is loaded to an
HTMLImageElement. - For each
category × colour(info,success,error,neutral),prepareIcon(background, icon, color)draws the background to a canvas atdevicePixelRatioscale, tints the icon with the colour using adestination-atopcanvas trick, composites it centred, and stores the rawImageDatakeyed${category}-${color}(e.g.car-success,truck-error).
- Each SVG is loaded to an
MapView.initMapthen callsmap.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 orfixTime), and adirection-…symbol layer filtered to features wheredirection === true, drawing a direction arrow rotated bycoursewith'icon-rotation-alignment': 'map'. - Plus a single
clusterssymbol layer on the main source filtered['has', 'point_count']showing abackgroundicon with the count. - Feature properties drive layer expressions:
category(selects sprite),color(success/error/neutral/infobased on status or attribute override),rotation(course),direction(controlled bymapDirectionuser pref:none/all/selected). - Updates flow through
setData: a seconduseEffectre-runs wheneverpositions,devices,selectedPosition, ormapClusterchange, and callsmap.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-featurecolor/width/opacityfrom 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 iswellknown.parse(item.area)followed byreverseCoordinates(...)because WKT islat lonwhile GeoJSON islng lat. - The reverse mapping (
geometryToArea) is used when saving edits. - Editing uses
@mapbox/mapbox-gl-drawwrapped 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 aFeatureCollectionof per-segmentLineStringfeatures, one per consecutive position pair, and colours each segment by the second point's speed viagetSpeedColor(speed, minSpeed, maxSpeed). If the device has aweb.reportColorattribute, 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 bycourseand tinted by speed colour. Clicks emitonClick(positionId, index)so a slider can scrub to that point. - Generic polyline (
MapRouteCoordinates): takes pre-computedcoordinatesand renders a singleLineStringwith optional name label. - Live trails (
MapLiveRoutes): reads fromstate.session.history— a Redux dictionary{ [deviceId]: [[lon, lat], ...] }. Updated insidesessionActions.updatePositions, capped toweb.liveRouteLengthpoints per device (default 10). Behaviour gated bymapLiveRoutesuser attribute:none/selected/ all devices. - Accuracy circles (
MapAccuracy): for each position withaccuracy > 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 passedcoordinatesorpositions, builds anLngLatBoundsand callsmap.fitBounds(bounds, { padding: min(w,h) * 0.1, duration: 0 }). Used inReplayPage.MapDefaultCamera— initial framing onMainPage. Picks (priority order) the selected device's position, the user's savedlatitude/longitude/zoompreference, or afitBoundsover all visible positions. Runs at most once.MapSelectedDevice— reactive follow. Watchesstate.devices.selectedId,selectTime, and the position of the selected device. Firesmap.easeTo(...)when the user reselects/re-clicks or whenmapFollowis 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.jsxopenswss?://<host>/api/socketonce authenticated. Ononmessage, parses the JSON envelope and dispatches one of:devicesActions.update,sessionActions.updatePositions,eventsActions.add,sessionActions.updateLogs.- Reconnect on
onclosewith a 60 s loop;onlineandvisibilitychangelisteners re-test the socket; periodicsocket.send('{}')ping. sessionActions.updatePositionsreducer per incoming position: (a)state.positions[deviceId] = positionoverwrites the latest; (b) ifmapLiveRoutesis on, appends[longitude, latitude]tostate.history[deviceId]capped toliveRouteLength; same-coord-as-last is skipped.MainPagereadspositions, runsuseFilterto computefilteredPositions, passes them into<MainMap>which composesMapView,MapOverlay,MapGeofence,MapAccuracy,MapLiveRoutes,MapPositions,MapDefaultCamera,MapSelectedDevice,PoiMapplus auxiliary controls.
Conventions to keep in mind (verbatim from the doc)
- Every
Map*component is a side-effect-only React component returningnulland usinguseEffectto add sources/layers. useId()is used to generate unique source/layer ids so the same component can be mounted multiple times safely.- All data updates flow through GeoJSON
setData— no direct DOM marker manipulation. - Style swaps reset the world; anything custom must be re-added after
setStyle. ThemapReadygate coordinates this. - Coordinates everywhere are
[lon, lat](MapLibre/GeoJSON convention).reverseCoordinatesexists specifically to bridge the WKTlat lonordering used by Traccar's geofence storage. - 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 frommaplibre-google-maps.
Notable quotes
A single
maplibregl.Mapinstance 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 callsmap.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 frommaplibre-google-maps, which is registered once inMapView.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 (50–500 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-drawlicense 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.