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>
23 KiB
Maps Architecture — traccar-web
This document describes how the maps subsystem is built in this project: the underlying engine, how tiles are loaded (Google included), how vector geometries are rendered, and how live tracking data flows from the backend WebSocket onto the map.
TL;DR — The app does not use the Google Maps JavaScript API. It uses MapLibre GL JS as the rendering engine, and Google's tile servers are consumed either as plain raster XYZ tiles or via the
maplibre-google-mapsadapter (a customgoogle://protocol that proxies Google's official Map Tiles API). All map "objects" (devices, geofences, route lines, accuracy circles, POIs) are GeoJSON sources rendered by MapLibre style layers.
1. Stack & dependencies
From package.json:
| Package | Role |
|---|---|
maplibre-gl |
Core WebGL rendering engine (vector + raster, sources, layers, controls). |
maplibre-google-maps |
Adapter that registers a google:// protocol handler so MapLibre can fetch tiles from Google's official Map Tiles API. |
@mapbox/mapbox-gl-draw |
Drawing controls (polygon, line, trash) — used for editing geofences. |
@mapbox/mapbox-gl-rtl-text |
RTL text shaping plugin (loaded only when the UI direction is RTL). |
@maplibre/maplibre-gl-geocoder |
Search box control (wired to OpenStreetMap Nominatim). |
@turf/circle |
Builds polygon approximations of circles for CIRCLE(...) geofences and accuracy circles. |
wellknown |
WKT parse/stringify (geofence storage format used by Traccar backend). |
@tmcw/togeojson |
Converts KML POI overlays into GeoJSON. |
Application bootstrap (src/index.jsx) calls preloadImages() once at startup so all device-icon sprites (with all four colour variants) are pre-rasterised before the first map renders.
2. The single global map instance
File: src/map/core/MapView.jsx
This is the most important architectural decision in the maps code:
const element = document.createElement('div');
element.style.width = '100%';
element.style.height = '100%';
maplibregl.addProtocol('google', googleProtocol);
export const map = new maplibregl.Map({
container: element,
attributionControl: false,
});
Key points:
- 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(). This lets the user navigate between Main/Replay/Geofences/Reports pages without paying the cost of recreating the WebGL context, re-uploading icon sprites, or refetching the style. maplibregl.addProtocol('google', googleProtocol)is called once globally — this is what makes URLs likegoogle://roadmap/{z}/{x}/{y}?key=...work as a tile source.- Every other map component in the codebase imports the same singleton:
import { map } from './core/MapView';and callsmap.addSource,map.addLayer,map.on(...)etc. directly. They rendernullto React — they are imperative side effects wrapped as components so React's lifecycle handles add/cleanup.
Ready gating
<MapView> only renders its children after the active style is fully loaded:
{mapReady && children}
The mechanism:
- A module-level
readyflag plus aSet<readyListeners>lets React components subscribe to the global ready state. - When the user changes basemap via the switcher, the switcher fires
onBeforeSwitch→updateReadyValue(false)→ all child map components unmount and clean up their sources/layers. - After
map.setStyle(...), the switcher waits forstyledataand then pollsmap.loaded()every 33 ms; once loaded, it callsinitMap()(which lazy-loads icons viamap.addImage(...)) andupdateReadyValue(true). - Children remount and re-add their sources/layers on top of the new style.
This is the canonical MapLibre pattern for "style swap": setting a style wipes all custom sources/layers, so the consumer has to re-add them.
Controls wired in MapView
AttributionControl(bottom-right / bottom-left in RTL)NavigationControl(top-right / top-left in RTL)SwitcherControl(custom — basemap picker)
Other controls (MapScale, MapCurrentLocation, MapGeocoder, MapNotification) are added/removed by their own components, outside <MapView>'s children list, because they should persist across style swaps (they hook the same singleton).
3. Tile layers — how the map "looks"
File: src/map/core/useMapStyles.js
This hook returns an array of style descriptors that the SwitcherControl exposes to the user. There are two flavours:
a) Vector styles (full MapLibre style JSON URLs)
For providers that ship a vector-tile style.json:
- OpenFreeMap (
https://tiles.openfreemap.org/styles/liberty) - LocationIQ Streets / Dark
- MapTiler Basic / Hybrid
- TomTom Basic
- Ordnance Survey (with a
transformRequestthat appends&srs=3857)
These give MapLibre full label/road/POI styling.
b) Raster styles (built ad-hoc by styleCustom)
styleCustom({ tiles, minZoom, maxZoom, attribution }) synthesizes a minimal MapLibre style with one raster source + one raster layer:
{
version: 8,
sources: { custom: { type: 'raster', tiles, tileSize: 256, ... } },
glyphs: 'https://cdn.traccar.com/map/fonts/{fontstack}/{range}.pbf',
layers: [{ id: 'custom', type: 'raster', source: 'custom' }],
}
It's used for: OSM, OpenTopoMap, Carto, all three Google variants (when no Google API key set), Bing (Road / Aerial / Hybrid via {quadkey}), HERE (3 variants), Yandex, AutoNavi, Mapbox raster styles, and any user-supplied custom URL.
The included glyphs URL means even raster-only basemaps can still render text labels for our overlays (geofences, devices, POIs).
c) Google specifically
Google appears in three entries — googleRoad, googleSatellite, googleHybrid. Each one branches on whether the googleKey user attribute is set:
tiles: googleKey
? [`google://roadmap/{z}/{x}/{y}?key=${googleKey}`]
: [0, 1, 2, 3].map((i) =>
`https://mt${i}.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}&s=Ga`),
- With a key: the
google://URL is intercepted bymaplibre-google-maps'sgoogleProtocolhandler, which calls Google's official Map Tiles API (server, session-token auth, billable) and returns the tile to MapLibre. - Without a key: the legacy public Google tile servers (
mt0..mt3.google.com) are used directly. This is the unauthenticated/scraped mode and its long-term availability is at Google's discretion.
Hybrid uses satellite + &layerType=layerRoadmap on the keyed path.
useMapStyles also exposes a custom style: if state.session.server.mapUrl contains {z} or {quadkey} it's treated as a tile template via styleCustom; otherwise it's treated as a full style JSON URL.
4. Optional raster overlays
File: src/map/overlay/useMapOverlays.js + src/map/overlay/MapOverlay.js
These are extra raster layers stacked on top of the basemap (traffic, weather, sea/rail, etc.). Each entry is just a MapLibre raster source descriptor:
| Overlay | Source |
|---|---|
| Google Traffic | google://satellite/{z}/{x}/{y}?key=...&layerType=layerTraffic&overlay=true |
| OpenSeaMap, OpenRailwayMap | OSM-derived tile servers |
| OpenWeather (clouds / precipitation / pressure / wind / temperature) | tile.openweathermap.org/.../{z}/{x}/{y}.png?appid=... |
| TomTom Flow / Incidents | TomTom traffic API |
| HERE Flow | HERE traffic flow tiles |
| Custom | state.session.server.overlayUrl |
<MapOverlay> reads the selectedMapOverlay user attribute, finds the active descriptor, then map.addSource(id, source) + map.addLayer({ id, type: 'raster', source: id }). On unmount or change it tears down both.
5. Icon sprites
File: src/map/core/preloadImages.js
All device category icons are SVGs in src/resources/images/icon/ (car, bus, truck, bicycle, plane, ship, person, animal, ...). Plus a generic background.svg and a direction.svg arrow.
preloadImages() runs at app startup and:
- Loads each SVG to an
HTMLImageElement. - For each category × each colour (
info,success,error,neutral) it callsprepareIcon(background, icon, color):- Draws the background sprite to a canvas at
devicePixelRatioscale. - Tints the icon SVG with the colour (canvas
destination-atoptrick) and composites it centred on the background. - Returns the raw
ImageData.
- Draws the background sprite to a canvas at
- Stores all results in the
mapImagesregistry 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}'.
mapIconKey(category) normalises a Traccar device category to one of the available sprite names (offroad/pickup → car, trolleybus → bus, unknown → default).
6. Rendering live device positions
File: src/map/MapPositions.js
This is the central live-tracking renderer. Props:
positions— array of position objects{ id, deviceId, latitude, longitude, course, fixTime, attributes, ... }onMapClick,onMarkerClickselectedPosition,titleField,showStatus
Sources & layers
It creates 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 on top).
For each of the two sources it adds:
- A symbol layer rendering
'icon-image': '{category}-{color}'plus the title (device name orfixTime). - A
direction-…symbol layer filtered to features wheredirection === true, drawing thedirectionarrow rotated by the positioncoursewith'icon-rotation-alignment': 'map'.
Plus a single clusters symbol layer on the main source that filters ['has', 'point_count'] and shows a background icon with the count.
Feature construction
createFeature(devices, position, selectedPositionId) returns properties used by the layer expressions:
{
id, deviceId,
name, fixTime,
category: mapIconKey(device.category), // selects sprite
color: showStatus
? position.attributes.color || getStatusColor(device.status) // 'success'|'error'|'neutral'|'info'
: 'neutral',
rotation: position.course,
direction: showDirection, // controlled by 'mapDirection' preference
}
showDirection modes: none (never), all (every position with a course), selected (default — only the currently selected position).
Updates
A second useEffect re-runs whenever positions, devices, selectedPosition, or mapCluster change, and just calls map.getSource(source)?.setData(...) with a freshly built FeatureCollection for each source. MapLibre diffs and re-renders.
Interaction
mouseenter/mouseleaveswap the canvas cursor.clickon a marker →onMarkerClick(positionId, deviceId)(inMainMapthis dispatchesdevicesActions.selectId(deviceId)).clickon a cluster → callsgetClusterExpansionZoom(clusterId)andmap.easeToto zoom in.clickon the map background →onMapClick(lat, lng).
7. Geofences (rendering)
File: src/map/MapGeofence.js
Three layers on a single GeoJSON source:
geofences-fill—type: 'fill', filtered to polygons, semi-transparent fill (fill-opacity: 0.1).geofences-line— outline, with per-featurecolor/width/opacitydriven byattributes.color,attributes.mapLineWidth,attributes.mapLineOpacity.geofences-title— symbol layer rendering{name}.
WKT → GeoJSON conversion
Geofences arrive from the backend (/api/geofences) as WKT in item.area. geofenceToFeature(theme, item) (in src/map/core/mapUtil.js) handles this:
- If the area starts with
CIRCLE, it extracts(lat lon, radius)and uses@turf/circleto approximate it as a 32-step polygon in metres. - Otherwise it
wellknown.parse(item.area)and runsreverseCoordinates(...)because WKT islat lonwhile GeoJSON islng lat.
The reverse mapping (geometryToArea) is used when saving edits.
Editing — src/map/draw/MapGeofenceEdit.js
Wraps @mapbox/mapbox-gl-draw. It:
- Adds a Draw control with polygon, line_string, trash modes (and patches the CSS class names so it looks native to MapLibre).
- Listens to
draw.create→ POST/api/geofences,draw.update→ PUT,draw.delete→ DELETE. - On
geofencesRedux state change, clears Draw and re-adds every feature converted viageofenceToFeature. - When a
selectedGeofenceIdis passed, it computes a bbox from the feature's coords andmap.fitBounds(...)to it.
8. Routes (history & live trails)
Replay route (MapRoutePath)
Used by ReplayPage and report pages. 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):
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: [[lon1,lat1],[lon2,lat2]] },
properties: { color: reportColor || getSpeedColor(...), width, opacity },
});
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 mapLineWidth/mapLineOpacity user preferences.
Replay points (MapRoutePoints)
A symbol layer that renders the literal text "▲" as the marker, rotated by course and tinted by speed colour. Clicking emits onClick(positionId, index) so the slider can scrub to that point. Also conditionally adds a SpeedLegendControl (a small horizontal turbo-colormap gradient with min/max speed labels in the user's speed unit).
Generic polyline (MapRouteCoordinates)
A simpler version that takes pre-computed coordinates (e.g. from a route geometry shipped by the API) and renders a single LineString with optional name label.
Live trails (MapLiveRoutes)
Renders the trailing path of currently-tracked devices in real time. It reads from state.session.history — a Redux-managed dictionary { [deviceId]: [[lon,lat], ...] }. The history is updated inside sessionActions.updatePositions (see §11) and capped to web.liveRouteLength points per device. Behaviour is gated by the mapLiveRoutes user attribute: none, selected (only the active device), or all devices.
Accuracy circles (MapAccuracy)
For each position with accuracy > 0 (in metres), it builds a turfCircle([lon, lat], accuracy * 0.001) (km) polygon and renders a translucent fill in the theme's geometry colour.
9. Generic markers — MapMarkers
A reusable component for showing simple POI-like markers (used by report pages, event pages, etc.). Each marker { latitude, longitude, image, title } becomes a Point feature; the symbol layer reads 'icon-image': '{image}' so any preloaded sprite name works (commonly start-success, finish-error, or default-neutral).
The showTitles prop toggles whether the layer renders the title text under the icon.
10. Camera control
Three components handle different camera scenarios:
MapCamera— One-shot fit. If passedcoordinatesorpositions, builds anLngLatBoundsand callsmap.fitBounds(bounds, { padding: min(w,h) * 0.1, duration: 0 }). Else jumps to a singlelat/lonkeeping zoom ≥ 10. Used inReplayPage.MapDefaultCamera— Initial framing onMainPage. Picks (in priority order) the selected device's position, the user'slatitude/longitude/zoompreference, or a fitBounds over all visible positions. Runs at most once (initializedstate).MapSelectedDevice— Reactive follow. Watchesstate.devices.selectedId,selectTime, and the position of the selected device. Firesmap.easeTo(...)when (a) the user reselects a device or re-clicks it, or (b)mapFollowis enabled and the selected device's coordinates change. The vertical offset (-popupMapOffset / 2) makes room for the StatusCard popup.
MapPadding separately calls map.setPadding({ left: drawerWidth }) so MapLibre's auto-centering accounts for the persistent left drawer in desktop layout.
11. Live data pipeline (where positions come from)
This is the end-to-end flow for "a device moved on the screen":
Traccar backend Browser
│
│ WebSocket /api/socket
▼
SocketController.jsx ──► dispatch(devicesActions.update / sessionActions.updatePositions / eventsActions.add)
│
▼
Redux store (slices)
│
▼
useSelector in MapPositions / MapLiveRoutes / MapSelectedDevice / ...
│
▼
map.getSource(id).setData(GeoJSON FeatureCollection)
│
▼
MapLibre re-renders (WebGL)
SocketController.jsx
- Opens
wss?://<host>/api/socketonce the user is authenticated. - On
onmessage, parses the JSON envelope and dispatches one of:devicesActions.update(data.devices)— device meta (status/lastUpdate/...).sessionActions.updatePositions(data.positions)— the live position stream.eventsActions.add(data.events)(also drives theMapNotificationbutton colour and the alarm sound).sessionActions.updateLogs(data.logs).
- Includes a fallback REST refresh + 60 s reconnect loop on
onclose, plusonline/visibilitychangelisteners that re-test the socket and reconnect if needed. On socket loss it also keeps a ping (socket.send('{}')) to test the connection.
sessionActions.updatePositions (in src/store/session.js)
The reducer does two things per incoming position:
state.positions[deviceId] = position— overwrites the latest known position by deviceId.- If
mapLiveRoutesis enabled, appends[longitude, latitude]tostate.history[deviceId], capped toliveRouteLength(default 10) points. If the new coordinate matches the last one, it's skipped.
Thus everything downstream is just useSelector on state.session.positions / state.session.history.
MainPage → MainMap
MainPage reads positions, runs useFilter to compute filteredPositions based on user search/filter UI, then passes them into <MainMap>. MainMap (src/main/MainMap.jsx) composes the live map:
<MapView>
<MapOverlay />
<MapGeofence />
<MapAccuracy positions={filteredPositions} />
<MapLiveRoutes deviceIds={filteredPositions.map(p => p.deviceId)} />
<MapPositions positions={filteredPositions} ... showStatus />
<MapDefaultCamera filteredPositions={filteredPositions} />
<MapSelectedDevice />
<PoiMap />
</MapView>
<MapScale />
<MapCurrentLocation />
<MapGeocoder />
{!disableEvents && <MapNotification ... />}
{desktop && <MapPadding ... />}
When filteredPositions changes (driven by socket → redux), MapPositions's effect re-runs and calls setData on the two GeoJSON sources — that's the actual visible update.
12. Other map consumers
The same singleton + composition pattern is reused across:
src/other/ReplayPage.jsx—MapViewwithMapRoutePath,MapRoutePoints, a single-positionMapPositions, andMapCamerafor fit-to-bounds.src/other/GeofencesPage.jsx—MapViewwithMapGeofenceEditfor CRUD on geofences.src/other/EmulatorPage.jsx,src/other/EventPage.jsx— point-and-click + event-context maps.src/reports/PositionsReportPage.jsx,TripReportPage.jsx,StopReportPage.jsx,EventReportPage.jsx,CombinedReportPage.jsx— replay-style maps for report visualisation.src/settings/UserPage.jsx,src/settings/ServerPage.jsx— coordinate pickers.
Because the engine is a singleton, switching pages incurs only a React reconcile + add/remove of sources & layers — no WebGL teardown.
13. Auxiliary controls
| Control | File | Purpose |
|---|---|---|
SwitcherControl |
src/map/switcher/switcher.js |
Custom basemap picker — fully imperative DOM; calls map.setStyle(style.style, { diff: false }) and triggers the before/onSwitch/after lifecycle so children can rebuild. |
MapScale |
src/map/MapScale.js |
Wraps maplibregl.ScaleControl; switches metric/imperial/nautical from the distanceUnit preference. |
MapCurrentLocation |
src/map/MapCurrentLocation.js |
Wraps maplibregl.GeolocateControl (enableHighAccuracy, no continuous tracking). |
MapGeocoder |
src/map/geocoder/MapGeocoder.js |
MaplibreGeocoder configured with a custom forwardGeocode that calls Nominatim and reshapes the response into the geocoder's expected feature format. |
MapNotification |
src/map/notification/MapNotification.js |
Small custom toggle button styled as a MapLibre control; reflects an enabled boolean and emits clicks. |
SpeedLegendControl |
src/map/legend/MapSpeedLegend.js |
Inline gradient legend added by MapRoutePoints when showSpeedControl is true. |
PoiMap |
src/map/main/PoiMap.js |
Loads a user-configured KML via fetch + DOMParser, converts with @tmcw/togeojson, and renders 3 layers (point/line/title). |
14. Conventions to keep in mind
- Every
Map*component is a side-effect-only React component. It returnsnulland usesuseEffectto add sources/layers and the cleanup function to remove them. The two-effect pattern (one for setup with[]-ish deps, one forsetDataupdates) is consistent across the codebase. 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— there is no direct DOM marker manipulation. This keeps the WebGL pipeline efficient and lets MapLibre handle clustering/visibility. - Style swaps reset the world. Anything custom on the map must be re-added after
setStyle. ThemapReadygate inMapViewis what coordinates this for child components. - Coordinates everywhere are
[lon, lat](MapLibre/GeoJSON convention).reverseCoordinatesexists specifically to bridge from 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 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.