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

421 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```js
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:
```js
{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 `onBeforeSwitch``updateReadyValue(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:
```js
{
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:
```js
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`/`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`, `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:
```js
{
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-fill``type: '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)`:
```js
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`.
### `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:
```jsx
<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``MapView` with `MapRoutePath`, `MapRoutePoints`, a single-position `MapPositions`, and `MapCamera` for fit-to-bounds.
- `src/other/GeofencesPage.jsx``MapView` 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`.