f92595a62a
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>
421 lines
23 KiB
Markdown
421 lines
23 KiB
Markdown
# 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`.
|