Files
docs/raw/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

23 KiB
Raw Permalink Blame History

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-maps adapter (a custom google:// 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.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(). 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 like google://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 calls map.addSource, map.addLayer, map.on(...) etc. directly. They render null to 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:

  1. A module-level ready flag plus a Set<readyListeners> lets React components subscribe to the global ready state.
  2. When the user changes basemap via the switcher, the switcher fires onBeforeSwitchupdateReadyValue(false) → all child map components unmount and clean up their sources/layers.
  3. After map.setStyle(...), the switcher waits for styledata and then polls map.loaded() every 33 ms; once loaded, it calls initMap() (which lazy-loads icons via map.addImage(...)) and updateReadyValue(true).
  4. 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 transformRequest that 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 by maplibre-google-maps's googleProtocol handler, 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:

  1. Loads each SVG to an HTMLImageElement.
  2. For each category × each colour (info, success, error, neutral) it calls prepareIcon(background, icon, color):
    • Draws the background sprite to a canvas at devicePixelRatio scale.
    • Tints the icon SVG with the colour (canvas destination-atop trick) and composites it centred on the background.
    • Returns the raw ImageData.
  3. Stores all results in the mapImages registry 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/pickupcar, trolleybusbus, 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, onMarkerClick
  • selectedPosition, titleField, showStatus

Sources & layers

It creates two GeoJSON sources, identified by useId()-based unique strings:

  1. id — non-selected devices, cluster: true, clusterMaxZoom: 14, clusterRadius: 50. MapLibre's built-in clustering handles aggregation.
  2. 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 or fixTime).
  • A direction-… symbol layer filtered to features where direction === true, drawing the direction arrow rotated by the position course with '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/mouseleave swap the canvas cursor.
  • click on a marker → onMarkerClick(positionId, deviceId) (in MainMap this dispatches devicesActions.selectId(deviceId)).
  • click on a cluster → calls getClusterExpansionZoom(clusterId) and map.easeTo to zoom in.
  • click on the map background → onMapClick(lat, lng).

7. Geofences (rendering)

File: src/map/MapGeofence.js

Three layers on a single GeoJSON source:

  1. geofences-filltype: 'fill', filtered to polygons, semi-transparent fill (fill-opacity: 0.1).
  2. geofences-line — outline, with per-feature color/width/opacity driven by attributes.color, attributes.mapLineWidth, attributes.mapLineOpacity.
  3. 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/circle to approximate it as a 32-step polygon in metres.
  • Otherwise it wellknown.parse(item.area) and runs reverseCoordinates(...) because WKT is lat lon while GeoJSON is lng 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 geofences Redux state change, clears Draw and re-adds every feature converted via geofenceToFeature.
  • When a selectedGeofenceId is passed, it computes a bbox from the feature's coords and map.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 passed coordinates or positions, builds an LngLatBounds and calls map.fitBounds(bounds, { padding: min(w,h) * 0.1, duration: 0 }). Else jumps to a single lat/lon keeping zoom ≥ 10. Used in ReplayPage.
  • MapDefaultCamera — Initial framing on MainPage. Picks (in priority order) the selected device's position, the user's latitude/longitude/zoom preference, or a fitBounds over all visible positions. Runs at most once (initialized state).
  • MapSelectedDevice — Reactive follow. Watches state.devices.selectedId, selectTime, and the position of the selected device. Fires map.easeTo(...) when (a) the user reselects a device or re-clicks it, or (b) mapFollow is 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/socket once 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 the MapNotification button colour and the alarm sound).
    • sessionActions.updateLogs(data.logs).
  • Includes a fallback REST refresh + 60 s reconnect loop on onclose, plus online/visibilitychange listeners 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:

  1. state.positions[deviceId] = position — overwrites the latest known position by deviceId.
  2. If mapLiveRoutes is enabled, appends [longitude, latitude] to state.history[deviceId], capped to liveRouteLength (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.

MainPageMainMap

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.jsxMapView with MapRoutePath, MapRoutePoints, a single-position MapPositions, and MapCamera for fit-to-bounds.
  • src/other/GeofencesPage.jsxMapView with MapGeofenceEdit for 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

  1. Every Map* component is a side-effect-only React component. It returns null and uses useEffect to add sources/layers and the cleanup function to remove them. The two-effect pattern (one for setup with []-ish deps, one for setData updates) is consistent across the codebase.
  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 — there is no direct DOM marker manipulation. This keeps the WebGL pipeline efficient and lets MapLibre handle clustering/visibility.
  4. Style swaps reset the world. Anything custom on the map must be re-added after setStyle. The mapReady gate in MapView is what coordinates this for child components.
  5. Coordinates everywhere are [lon, lat] (MapLibre/GeoJSON convention). reverseCoordinates exists specifically to bridge from 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 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.