` 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
` 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 ``'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?:///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 `` 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 ``** held in a module-level variable. The React `
` component just mounts that detached `` 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 (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-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.