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>
This commit is contained in:
@@ -8,13 +8,14 @@ Content catalog for the TRM wiki. Maintained by the LLM on every ingest. See [[C
|
|||||||
- [[rally-albania-regulations-2025]] — Federation rulebook for Rally Albania 2025; canonical real-world reference for classes, start-order rules, penalty taxonomy, tracking requirements.
|
- [[rally-albania-regulations-2025]] — Federation rulebook for Rally Albania 2025; canonical real-world reference for classes, start-order rules, penalty taxonomy, tracking requirements.
|
||||||
- [[teltonika-data-sending-protocols]] — Official Teltonika canonical wiki; full codec inventory including Codec 15 and SMS protocols, UDP transport, ACK/nACK details.
|
- [[teltonika-data-sending-protocols]] — Official Teltonika canonical wiki; full codec inventory including Codec 15 and SMS protocols, UDP transport, ACK/nACK details.
|
||||||
- [[teltonika-ingestion-architecture]] — Internal Teltonika protocol adapter design; Phase 1 (8/8E/16) and Phase 2 (12/13/14) roadmap.
|
- [[teltonika-ingestion-architecture]] — Internal Teltonika protocol adapter design; Phase 1 (8/8E/16) and Phase 2 (12/13/14) roadmap.
|
||||||
|
- [[traccar-maps-architecture]] — Deep dive into traccar-web's MapLibre + GeoJSON + WebSocket maps subsystem. The reference architecture our SPA inherits, with deliberate divergences.
|
||||||
|
|
||||||
## Entities
|
## Entities
|
||||||
|
|
||||||
- [[directus]] — Business plane: schema owner, REST/GraphQL/WSS, admin UI, permissions, Flows.
|
- [[directus]] — Business plane: schema owner, REST/GraphQL/WSS, admin UI, permissions, Flows.
|
||||||
- [[postgres-timescaledb]] — Durable storage: positions hypertable + business schema. The system's only single point of failure.
|
- [[postgres-timescaledb]] — Durable storage: positions hypertable + business schema. The system's only single point of failure.
|
||||||
- [[processor]] — Domain-logic service consuming Redis Streams; per-device hot state in memory; sole writer for telemetry tables.
|
- [[processor]] — Domain-logic service consuming Redis Streams; per-device hot state in memory; sole writer for telemetry tables.
|
||||||
- [[react-spa]] — End-user UI; talks exclusively to Directus; role-based views in a single bundle.
|
- [[react-spa]] — End-user UI; talks to Directus (REST + business-plane WS) and Processor (live-position WS); role-based views in a single bundle.
|
||||||
- [[redis-streams]] — Durable in-flight queue between Ingestion and Processor; Phase 2 transport for outbound commands.
|
- [[redis-streams]] — Durable in-flight queue between Ingestion and Processor; Phase 2 transport for outbound commands.
|
||||||
- [[tcp-ingestion]] — Per-vendor TCP listener service; parses binary protocols and emits normalized records.
|
- [[tcp-ingestion]] — Per-vendor TCP listener service; parses binary protocols and emits normalized records.
|
||||||
- [[teltonika]] — GPS hardware vendor; Codec 8/8E/16 telemetry today, Codec 12/14 commands deferred (13/15 one-way, 15 out of scope).
|
- [[teltonika]] — GPS hardware vendor; Codec 8/8E/16 telemetry today, Codec 12/14 commands deferred (13/15 one-way, 15 out of scope).
|
||||||
@@ -26,6 +27,7 @@ Content catalog for the TRM wiki. Maintained by the LLM on every ingest. See [[C
|
|||||||
- [[failure-domains]] — Independent component failure behavior; database is the only SPOF.
|
- [[failure-domains]] — Independent component failure behavior; database is the only SPOF.
|
||||||
- [[io-element-bag]] — The pass-through principle for model-specific telemetry inside AVL records.
|
- [[io-element-bag]] — The pass-through principle for model-specific telemetry inside AVL records.
|
||||||
- [[live-channel-architecture]] — Dual-WebSocket design for live UX: Processor's endpoint for telemetry firehose, Directus's for business-plane updates.
|
- [[live-channel-architecture]] — Dual-WebSocket design for live UX: Processor's endpoint for telemetry firehose, Directus's for business-plane updates.
|
||||||
|
- [[maps-architecture]] — Singleton MapLibre + side-effect React components + GeoJSON setData pipeline. The pattern the SPA uses, with rAF coalescing as the throughput discipline.
|
||||||
- [[phase-2-commands]] — Deferred design for server-to-device commands via Teltonika codecs 12/14.
|
- [[phase-2-commands]] — Deferred design for server-to-device commands via Teltonika codecs 12/14.
|
||||||
- [[plane-separation]] — Three-plane architecture (telemetry / business / presentation) split by data velocity and failure domain.
|
- [[plane-separation]] — Three-plane architecture (telemetry / business / presentation) split by data velocity and failure domain.
|
||||||
- [[position-record]] — Boundary contract between vendor adapters and the rest of the system.
|
- [[position-record]] — Boundary contract between vendor adapters and the rest of the system.
|
||||||
@@ -34,3 +36,4 @@ Content catalog for the TRM wiki. Maintained by the LLM on every ingest. See [[C
|
|||||||
## Synthesis
|
## Synthesis
|
||||||
|
|
||||||
- [[directus-schema-draft]] — Working draft of the business-plane schema: orgs, users, teams, vehicles, devices, events, entries with crew/devices. Open for revision.
|
- [[directus-schema-draft]] — Working draft of the business-plane schema: orgs, users, teams, vehicles, devices, events, entries with crew/devices. Open for revision.
|
||||||
|
- [[processor-ws-contract]] — Wire-level spec for the live-position WebSocket: auth handshake, subscribe/snapshot/streaming/unsubscribe protocol, reconnect, multi-instance, versioning. Implementation-agnostic; flags the wiki/planning drift on which service hosts it.
|
||||||
|
|||||||
@@ -108,3 +108,66 @@ Index updated: new source row. No new entity/concept pages created — the doc s
|
|||||||
- New "Network exposure" subsection inside Deployment: directus is internal-only on stage / prod (`expose: 8055` not `ports:`). A reverse proxy (Traefik / Caddy / nginx) on the host or attached to `trm_default` terminates TLS and forwards the public domain to `http://directus:8055`. The asymmetry with [[tcp-ingestion]] (which must host-publish for GPS devices) is named, and the dev compose's deliberate divergence is noted.
|
- New "Network exposure" subsection inside Deployment: directus is internal-only on stage / prod (`expose: 8055` not `ports:`). A reverse proxy (Traefik / Caddy / nginx) on the host or attached to `trm_default` terminates TLS and forwards the public domain to `http://directus:8055`. The asymmetry with [[tcp-ingestion]] (which must host-publish for GPS devices) is named, and the dev compose's deliberate divergence is noted.
|
||||||
|
|
||||||
Three CI iterations on the directus repo's first push exposed three distinct production-breaking bugs (port collision; bootstrap-before-apply ordering + silent ERROR exit; ghost-collection apply conflict). The dry-run gate caught all of them before the image touched stage. The "ghost-collection" stripping is now automated in `scripts/schema-snapshot.sh` so future captures don't regress.
|
Three CI iterations on the directus repo's first push exposed three distinct production-breaking bugs (port collision; bootstrap-before-apply ordering + silent ERROR exit; ghost-collection apply conflict). The dry-run gate caught all of them before the image touched stage. The "ghost-collection" stripping is now automated in `scripts/schema-snapshot.sh` so future captures don't regress.
|
||||||
|
|
||||||
|
## [2026-05-02] note | Stage deploy verified + Rally Albania 2026 seed landed
|
||||||
|
|
||||||
|
Stage Directus is live at `api.stage.new.trmtracking.org` and matches the local snapshot. Verification done via the `directus-stage` MCP server:
|
||||||
|
|
||||||
|
- All 12 user collections present (`organizations`, `organization_users`, `organization_vehicles`, `organization_devices`, `vehicles`, `devices`, `events`, `classes`, `entries`, `entry_crew`, `entry_devices` + custom fields on `directus_users`).
|
||||||
|
- Field shapes, types, notes, and relations identical to local. `migrations_applied` + `positions` (db-init) and `schema_migrations` (processor migration runner) tables also present, as expected.
|
||||||
|
- Composite UNIQUE constraints landed — probed `(event_id, code)` on `classes` with a duplicate `M-1` insert, got `RECORD_NOT_UNIQUE`. Confirms `db-init-post/001` + `002` ran on stage (the post-schema phase introduced during task 1.8 CI iterations).
|
||||||
|
|
||||||
|
Rally Albania 2026 dogfood seed (task 1.9) replayed against stage: 1 org (`msc-albania`), 1 event (`rally-albania-2026`, 2026-06-06 → 2026-06-13), 18 classes (M-1..M-8, Q-1..Q-3, C-1/C-2/C-A/C-3, S-1..S-3), 1 vehicle (Toyota Land Cruiser 70), 3 devices (FMB920 chassis + FMB920 dash backup + FMB003 panic). Junction rows (`organization_vehicles` ×1, `organization_devices` ×3) wired. UUIDs differ from the local seed; record of stage UUIDs lives in `trm/directus/.planning/phase-1-slice-1-schema/09-rally-albania-2026-seed.md` Done section if needed.
|
||||||
|
|
||||||
|
End-to-end registration walkthrough (`organization_users` + `entries` + `entry_crew` + `entry_devices`) deferred to manual operator pass through the admin UI — the MCP `items` tool blocks writes to core collections like `directus_users`, so the user-attaches-to-entry flow can't be MCP-driven. That manual walkthrough is the actual dogfood acceptance gate for slice-1 schema.
|
||||||
|
|
||||||
|
Drift flagged: field notes on `events.slug`, `classes.code`, and `entries.race_number` still reference "db-init/005" — those constraints moved to `db-init-post/` during the CI fix. Cosmetic only, no behavior impact; worth a snapshot-side cleanup pass next time someone touches the schema.
|
||||||
|
|
||||||
|
## [2026-05-02] ingest | TRACCAR_MAPS_ARCHITECTURE.md
|
||||||
|
|
||||||
|
Ingested the deep architectural reference for traccar-web's maps subsystem after recognising during SPA-planning discussion that Traccar already fields the exact stack we're converging on (MapLibre GL JS + GeoJSON sources + WebSocket fan-out). Created [[traccar-maps-architecture]] (source page, with TRM divergences enumerated) and [[maps-architecture]] (concept page distilling the inherited patterns: singleton map, side-effect-only `Map*` components, two-effect setup/setData split, two-source clustered+selected design, style-swap `mapReady` gate, sprite preload, rAF coalescer at the WS boundary, geofence editing via `@mapbox/mapbox-gl-draw`, three-way camera control split).
|
||||||
|
|
||||||
|
Updated [[react-spa]] heavily: appended the new source; corrected the "talks exclusively to Directus" claim that conflicted with [[live-channel-architecture]] (the SPA connects to two endpoints — Directus for business plane, Processor for telemetry firehose); locked in the stack (raw MapLibre over `react-map-gl`, Zustand over Redux, `maplibre-google-maps` adapter as optional Google-tiles path); added an Auth section documenting the same-domain cookie + reverse-proxy pattern; rewrote Real-time rendering to point at [[maps-architecture]] and headline the rAF coalescer + per-device bounded ring buffers. One sentence + cross-reference added to [[live-channel-architecture]] flagging consumer-side throughput discipline.
|
||||||
|
|
||||||
|
Headline takeaway: Traccar's frontend architecture is mostly correct — the lag the user experienced isn't the rendering layer (which is WebGL `setData` and fast) but throughput discipline (per-message Redux dispatch cascading through selectors and rebuilding feature collections at every position arrival). TRM inherits the architecture and adds an rAF coalescer at the WS boundary plus Zustand to neutralise the failure mode. Tile-source decision unblocked: Google Maps via the official Map Tiles API is legitimate through the `maplibre-google-maps` protocol adapter (bring-your-own-key, runtime-config-gated). Dogfood-day starter set: Esri World Imagery (satellite, free) + OpenTopoMap (free) + OSM raster, with Google Satellite as an optional add when an operator provides a key.
|
||||||
|
|
||||||
|
## [2026-05-02] synthesis | Processor WebSocket contract + wiki/planning drift surfaced
|
||||||
|
|
||||||
|
Wrote [[processor-ws-contract]] as the wire-level spec for the live-position WebSocket: endpoint shape, cookie-based auth handshake, subscribe/snapshot/streaming/unsubscribe protocol, reconnect semantics, multi-instance fan-out behaviour, connection limits, versioning rules. Both the SPA and the producing service will build against this page; changes require coordinated updates on both sides.
|
||||||
|
|
||||||
|
Surfaced a real wiki/planning drift while researching: [[processor]] entity page lists "Broadcast live positions" as a top-level responsibility and [[live-channel-architecture]] specifies the design, but the processor's actual planning roadmap (`trm/processor/.planning/`) has no task for it. Phase 1 (done) is throughput-only; Phase 2 is geofence/IO/timing; Phase 3 is hardening; Phase 4 only mentions a "WebSocket gateway" as an uncommitted fallback service. The drift happened because [[live-channel-architecture]] was synthesised on 2026-05-01, after Phase 1's plan had locked — the wiki absorbed the corrected design, the processor's planning didn't reconcile.
|
||||||
|
|
||||||
|
Recommendation pending user decision: add a new processor phase ("Phase 1.5 — Live broadcast") that implements [[processor-ws-contract]] inside the processor service. Alternatives are Option B (separate `trm/live-gateway` service, aligning with the old Phase 4 framing — adds a deploy unit and contradicts the wiki) and Option C (defer the live map for the dogfood — thins the SPA's value-add over Directus admin). The synthesis page is implementation-agnostic so the contract is locked regardless of which option lands.
|
||||||
|
|
||||||
|
## [2026-05-02] note | Phase 1.5 planning landed (Option A chosen)
|
||||||
|
|
||||||
|
Promoted the Processor's WebSocket broadcast endpoint to a real planning artefact. Created `trm/processor/.planning/phase-1-5-live-broadcast/` with a phase README and six task files: 1.5.1 WS server scaffold + heartbeat, 1.5.2 cookie auth handshake, 1.5.3 subscription registry & per-event authorization, 1.5.4 broadcast consumer group & fan-out, 1.5.5 snapshot-on-subscribe, 1.5.6 integration test. Each follows the existing Phase 1 task-file shape (Goal / Deliverables / Specification / Acceptance / Risks / Done) so an implementer can pick one up self-contained.
|
||||||
|
|
||||||
|
Updated `trm/processor/.planning/ROADMAP.md` with a Phase 1.5 section between Phase 1 and Phase 2, including the per-task table. Pruned the stale "WebSocket gateway for live updates" candidate from Phase 4's README and reframed it as the documented [[live-channel-architecture]] escape hatch — to be promoted to a numbered phase only when measurements justify lifting the WS endpoint out of the Processor process. Updated [[processor-ws-contract]]'s Implementation status section to reflect "planned as Phase 1.5" instead of "designed but not scheduled."
|
||||||
|
|
||||||
|
Wiki / planning drift surfaced earlier today is now closed: the wiki's [[processor]] / [[live-channel-architecture]] / [[processor-ws-contract]] design and the processor's planning roadmap agree on what gets built, where, and how it's sequenced. Implementation can start on 1.5.1 whenever; SPA work can proceed against [[processor-ws-contract]] in parallel as long as it doesn't ship to stage before Phase 1.5 lands.
|
||||||
|
|
||||||
|
## [2026-05-02] note | Auth-mode wiki realignment (cookie → session)
|
||||||
|
|
||||||
|
SPA implementation surfaced that Directus SDK's `'cookie'` auth mode doesn't survive a hard reload cleanly — the in-memory access token is gone, and `/users/me` 401s before autoRefresh can establish a new one. Switched the SPA to `'session'` mode (`authentication('session', { credentials: 'include' })`), where the session itself lives in the httpOnly cookie and the browser sends it on every request including the WebSocket upgrade. Reload survives without any client-side state.
|
||||||
|
|
||||||
|
Updated [[react-spa]] §"Auth pattern" to describe session mode (single httpOnly session cookie, no separate access token, no `/auth/refresh` dance). Added a "Mode choice context" note explaining why session mode is the right default for an SPA that needs reload-survives behaviour.
|
||||||
|
|
||||||
|
Updated [[processor-ws-contract]] §"Auth handshake" to drop the explicit "(mode: cookie)" annotation and emphasise that the producer is **cookie-name-agnostic** — it forwards the entire `Cookie` header to `/users/me` and lets Directus identify the session. The producer's implementation was already cookie-name-agnostic in practice (the 1.5.2 implementation forwards the whole header), so no processor-side code change is needed; the wiki just now matches the implementation. Reframed "Cookie refresh while connected" open question as "Session expiry while connected" with the cleaner session-mode semantics.
|
||||||
|
|
||||||
|
Processor Phase 1.5 is fully shipped (`c07ea0e` 1.5.4, `f4b50ca` 1.5.5, `2f2cf5c` 1.5.6) — six tasks, 178/178 unit tests, 6 integration scenarios. The cookie-mode language in the processor's planning task files (1.5.2 in particular) is left as-is — it's the historical spec the implementation landed against; the implementation itself is mode-agnostic.
|
||||||
|
|
||||||
|
## [2026-05-02] note | trm/spa planning landed
|
||||||
|
|
||||||
|
User created `trm/spa` repo on Gitea and seeded a minimal Vite 8 + React 19 + TypeScript 6 scaffold (App.tsx returns "SPA"). Wrote the full planning structure mirroring the conventions established by `trm/processor` and `trm/directus`.
|
||||||
|
|
||||||
|
Created in `trm/spa/.planning/`:
|
||||||
|
- `ROADMAP.md` — navigation hub with status legend, architectural anchors, eight non-negotiable design rules (singleton MapLibre, side-effect-only `Map*` components, rAF coalescer, same-origin-everything, in-memory access token, role-aware UI, runtime config, native PostGIS GeoJSON), four phases.
|
||||||
|
- `phase-1-foundation/` — README + 9 task files: 1.2 stack rounding-out (Tailwind + shadcn/ui + TanStack Router/Query + Zustand + @directus/sdk + zod + react-hook-form + Prettier), 1.3 Vite dev proxy + path aliases + tsconfig hardening, 1.4 runtime config endpoint, 1.5 Directus auth client (cookie mode + refresh + Zustand auth store), 1.6 login page, 1.7 routing skeleton (TanStack Router file-based + role-aware guards), 1.8 logout flow (with cross-tab sync), 1.9 Gitea CI + Dockerfile + nginx static serve, 1.10 compose service block in `trm/deploy`.
|
||||||
|
- `phase-2-live-map/README.md` — sketched task table for the live-monitoring map; depends on processor Phase 1.5 landing. Nine tasks: MapLibre singleton, tile-source switcher, sprite preload, WS client + rAF coalescer + Zustand store, MapPositions, MapTrails, event picker, camera control trio, connection-status indicators.
|
||||||
|
- `phase-3-dogfood-readiness/README.md` — error boundaries, connection-state UI, mobile-responsive baseline, per-device detail panel, empty/loading-state polish, Vitest setup, production logging, visual brand pass.
|
||||||
|
- `phase-4-future/README.md` — geometry editor (depends on directus Phase 2), replay mode, heatmaps / deck.gl, i18n (Albanian), dark mode, Playwright E2E, leaderboard, spectator-facing public map, notifications, operator chat. None committed.
|
||||||
|
|
||||||
|
Each task file follows the existing Goal / Deliverables / Specification / Acceptance / Risks / Done shape so an implementer agent can pick one up self-contained. Phase 1 sequencing: 1.2 → 1.3 → 1.4 → 1.5 → (1.6 ‖ 1.7) → 1.8, with 1.9+1.10 (deploy plumbing) developable in parallel after 1.3 lands.
|
||||||
|
|
||||||
|
End state of Phase 1: a deployable empty shell — auth + protected routes + login/logout + CI + compose deploy block. End state of Phase 2: the dogfood-day deliverable. End state of Phase 3: actually fielded for race operators on race day, not just a tech demo.
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# 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`.
|
||||||
@@ -116,7 +116,7 @@ The escape hatch is well-defined: lift the WebSocket endpoint code out of the Pr
|
|||||||
|
|
||||||
- [[processor]] grows a public-facing WebSocket endpoint in addition to its existing Redis consumer and Postgres writer.
|
- [[processor]] grows a public-facing WebSocket endpoint in addition to its existing Redis consumer and Postgres writer.
|
||||||
- [[directus]] keeps its built-in WebSocket subscriptions for tables it writes to. Its real-time delivery section no longer claims to broadcast direct writes from [[processor]] — that's a documented mistake corrected in this revision.
|
- [[directus]] keeps its built-in WebSocket subscriptions for tables it writes to. Its real-time delivery section no longer claims to broadcast direct writes from [[processor]] — that's a documented mistake corrected in this revision.
|
||||||
- [[react-spa]] connects to two WebSocket endpoints: Directus for admin/business updates, Processor for live position firehose. Same JWT-based auth on both.
|
- [[react-spa]] connects to two WebSocket endpoints: Directus for admin/business updates, Processor for live position firehose. Same JWT-based auth on both. Consumer-side throughput discipline (rAF coalescing of incoming positions before reducer dispatch) is documented in [[maps-architecture]] — without it the per-message dispatch pattern observed in [[traccar-maps-architecture]] cascades through selectors and `setData` at every position arrival.
|
||||||
- The deploy stack publishes the Processor's WebSocket port (with TLS termination at a reverse proxy in front).
|
- The deploy stack publishes the Processor's WebSocket port (with TLS termination at a reverse proxy in front).
|
||||||
|
|
||||||
## Why not a single WebSocket endpoint
|
## Why not a single WebSocket endpoint
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
---
|
||||||
|
title: Maps architecture
|
||||||
|
type: concept
|
||||||
|
created: 2026-05-02
|
||||||
|
updated: 2026-05-02
|
||||||
|
sources: [traccar-maps-architecture, gps-tracking-architecture]
|
||||||
|
tags: [frontend, maps, maplibre, architecture, decision]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Maps architecture
|
||||||
|
|
||||||
|
The pattern the [[react-spa]] uses to render real-time positions, trails, geofences, and replay tracks. Inherited (with deliberate refinements) from [[traccar-maps-architecture]], which already fields the same shape — MapLibre GL JS + GeoJSON sources + WebSocket-fed updates — at production scale. Reading this page tells you how the SPA's maps subsystem is structured; reading the source tells you the original implementation in detail.
|
||||||
|
|
||||||
|
## Why this shape
|
||||||
|
|
||||||
|
DOM-rendered markers (Leaflet's default, or React-Leaflet's `<Marker>` components) thrash layout and repaint on every map move. ~1000 markers is enough to choke a browser. WebGL-rendered markers handle 100k+ at 60fps — the GPU is purpose-built for it. **The decision tree is short:** any map that updates frequently or shows more than a few hundred markers must be WebGL.
|
||||||
|
|
||||||
|
MapLibre GL JS is the open-source WebGL map renderer. Vector tiles, raster tiles, GeoJSON sources, symbol/circle/line/fill style layers — all GPU-rendered. Free, no API key required for OSM-derived tiles. Standard tooling.
|
||||||
|
|
||||||
|
The architecture that follows is what makes MapLibre tractable inside React without losing the imperative escape hatches you need for streaming-data performance.
|
||||||
|
|
||||||
|
## Singleton map
|
||||||
|
|
||||||
|
A **single `maplibregl.Map` instance lives for the entire app lifetime**, attached to a detached `<div>` held in a module-level variable. The `<MapView>` React component just appends that detached `<div>` into its own ref on mount and removes it on unmount; it then calls `map.resize()`.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- **Page navigation doesn't recreate the WebGL context.** Live → replay → geofence editor → reports — all share the same instance.
|
||||||
|
- **Icon sprites stay registered across navigations** — no re-`addImage` cost per page change.
|
||||||
|
- **State outside React's tree.** Sources, layers, camera position, event listeners are MapLibre's, not React's. React's reconciler doesn't compete.
|
||||||
|
|
||||||
|
Trade-off: if multiple tabs of the SPA need *different* maps (e.g. side-by-side comparison), the singleton breaks. For TRM's operator-facing flow this is a non-concern. Revisit only if a use case demands it.
|
||||||
|
|
||||||
|
## Side-effect-only `Map*` components
|
||||||
|
|
||||||
|
**Every component that participates in the map (`MapPositions`, `MapGeofences`, `MapTrails`, `MapAccuracy`, etc.) returns `null`.** It uses `useEffect` for setup (add source + layer) with cleanup (remove source + layer), and a separate `useEffect` for updates that calls `map.getSource(id)?.setData(...)`.
|
||||||
|
|
||||||
|
The two-effect pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function MapPositions({ positions }: Props) {
|
||||||
|
const sourceId = useId();
|
||||||
|
const layerId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.addSource(sourceId, { type: 'geojson', data: emptyFC, cluster: true });
|
||||||
|
map.addLayer({ id: layerId, type: 'symbol', source: sourceId, layout: {...} });
|
||||||
|
return () => {
|
||||||
|
map.removeLayer(layerId);
|
||||||
|
map.removeSource(sourceId);
|
||||||
|
};
|
||||||
|
}, []); // setup: runs once
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.getSource(sourceId)?.setData(toFeatureCollection(positions));
|
||||||
|
}, [positions]); // update: runs on every change
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useId()` for source/layer ids lets the same component mount multiple times safely. The empty render keeps React's reconciler out of the rendering hot path.
|
||||||
|
|
||||||
|
## Two GeoJSON sources for live positions
|
||||||
|
|
||||||
|
Live device rendering uses **two GeoJSON sources**, not one:
|
||||||
|
|
||||||
|
- **Non-selected source** with `cluster: true, clusterMaxZoom: 14, clusterRadius: 50`. MapLibre's built-in clustering aggregates dense regions automatically.
|
||||||
|
- **Selected source**, unclustered, always rendered on top.
|
||||||
|
|
||||||
|
This keeps the UX clean without manual z-ordering: clicking a device pulls it out of the clustered layer and into the always-on-top selected layer. Layers reference the appropriate source.
|
||||||
|
|
||||||
|
## Style swaps reset the world
|
||||||
|
|
||||||
|
Calling `map.setStyle(...)` wipes every custom source and layer the SPA has added. This is MapLibre's native behaviour, not a quirk to work around.
|
||||||
|
|
||||||
|
Coordination via a `mapReady` gate:
|
||||||
|
|
||||||
|
1. A module-level `ready` flag plus a listener `Set` lets components subscribe to global ready state.
|
||||||
|
2. Basemap switcher fires `onBeforeSwitch → updateReadyValue(false)`. All children unmount and clean up their sources/layers.
|
||||||
|
3. After `map.setStyle(...)` resolves (`styledata` event + `map.loaded()` polling), `initMap()` re-adds icon sprites, then `updateReadyValue(true)` flips the gate.
|
||||||
|
4. `<MapView>` re-renders its `children`; every `Map*` component remounts and re-adds its source + layer.
|
||||||
|
|
||||||
|
The gate is the contract: **don't add anything to the map until `mapReady` is true after a style swap.** Violating it causes phantom-source errors that look like Heisenbugs.
|
||||||
|
|
||||||
|
## Icon sprites
|
||||||
|
|
||||||
|
Device-category icons (rally car / quad / SSV / motorcycle / runner / hiker for TRM, plus a generic `default`) are SVGs **pre-rasterised once at app startup** at `devicePixelRatio` scale, with all colour variants composited up-front, stored in a module-level registry, and `addImage`'d after every style swap.
|
||||||
|
|
||||||
|
Layers reference sprites by name in style expressions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']]
|
||||||
|
```
|
||||||
|
|
||||||
|
So a feature with `properties.category === 'rally-car'` and `properties.color === 'success'` resolves to the `rally-car-success` sprite at GPU paint time. Zero per-frame work in JavaScript.
|
||||||
|
|
||||||
|
## WebSocket → map data flow (with rAF coalescer)
|
||||||
|
|
||||||
|
The end-to-end live data flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
Processor WS → coalesce buffer → Zustand store → useStore selectors → setData → GPU
|
||||||
|
```
|
||||||
|
|
||||||
|
**The coalesce buffer is the discipline this architecture lives or dies on.** [[traccar-maps-architecture]] dispatches one Redux action per WS message; at 200 racers × 1Hz this fires 200 dispatches/sec, each cascading through `useSelector` consumers, `useFilter` recomputes over the full position array, and a freshly-built FeatureCollection passed to `setData`. That cascade is the most likely source of the lag operators see in production Traccar deployments.
|
||||||
|
|
||||||
|
TRM's pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const buffer = new Map<string, Position>(); // deviceId → latest position
|
||||||
|
let rafScheduled = false;
|
||||||
|
|
||||||
|
socket.onmessage = (msg) => {
|
||||||
|
const pos = JSON.parse(msg.data);
|
||||||
|
buffer.set(pos.deviceId, pos);
|
||||||
|
if (!rafScheduled) {
|
||||||
|
rafScheduled = true;
|
||||||
|
requestAnimationFrame(flush);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
rafScheduled = false;
|
||||||
|
const snapshot = Array.from(buffer.values());
|
||||||
|
buffer.clear();
|
||||||
|
positionStore.getState().applyPositions(snapshot);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Properties of this shape:
|
||||||
|
|
||||||
|
- **At most one state update per frame** (~60Hz cap). Dozens of incoming messages per frame collapse to one snapshot.
|
||||||
|
- **Per-device coalescing.** If the same device reports five times in 16ms, only the latest position is kept.
|
||||||
|
- **No back-pressure on the WS.** Buffer is bounded by device count, not message rate.
|
||||||
|
- **Trail history isn't lossy.** This buffer holds *latest visible positions for the map*, not the per-device trail. The trail is a separate per-device ring buffer in the store, written from `applyPositions`.
|
||||||
|
|
||||||
|
Throttling-by-user-choice (slider for "1Hz / 5Hz / 30Hz redraw rate") is a future feature layered on top of this — change the rAF callback to a `setInterval` keyed on user preference. The rAF coalescer is the always-on hygiene.
|
||||||
|
|
||||||
|
## State management
|
||||||
|
|
||||||
|
- **Zustand** for high-frequency live state: positions store, trails store, selection. Granular subscribers, no provider boilerplate, no `useSelector` cascades.
|
||||||
|
- **TanStack Query** for Directus REST: events, classes, entries, vehicles, devices. Caching, invalidation, refetch on focus.
|
||||||
|
- **No Redux.** The Traccar Redux pattern works correctly but the dispatch + selector machinery is overhead at our update rates and doesn't earn its weight given Zustand's existence.
|
||||||
|
|
||||||
|
## Geofence editing
|
||||||
|
|
||||||
|
`@mapbox/mapbox-gl-draw` wrapped to look native to MapLibre (CSS class patches). Modes: polygon, line_string, trash. On `draw.create` / `draw.update` / `draw.delete`, the SPA POSTs / PUTs / DELETEs to Directus's REST API. Directus stores PostGIS geometry directly; the SPA reads back GeoJSON via `ST_AsGeoJSON(geometry)` — **no WKT round-trip** unlike [[traccar-maps-architecture]] which uses WKT in storage.
|
||||||
|
|
||||||
|
Phase 2 of [[directus]] introduces the `geofences`, `waypoints`, and `speed_limit_zones` collections that this editor writes to.
|
||||||
|
|
||||||
|
## Camera control
|
||||||
|
|
||||||
|
Three components for three jobs, kept separate so each effect's dependency list is small and the wrong-camera-jump bugs that plague map UIs don't appear:
|
||||||
|
|
||||||
|
- **`MapCamera`** — one-shot fit. Builds an `LngLatBounds` from `coordinates` or `positions` props and calls `map.fitBounds(...)` once. Used in replay.
|
||||||
|
- **`MapDefaultCamera`** — initial framing on first mount. Picks the selected device, the user's saved preference, or a fitBounds over visible positions. Runs at most once.
|
||||||
|
- **`MapSelectedDevice`** — reactive follow. Watches the selected device id; on selection change or position change (with `mapFollow` on), `map.easeTo(...)`.
|
||||||
|
|
||||||
|
Bonus: `MapPadding` calls `map.setPadding({ left: drawerWidth })` so MapLibre's auto-centring accounts for the persistent left drawer in desktop layout.
|
||||||
|
|
||||||
|
## Tile sources
|
||||||
|
|
||||||
|
Two flavours, identical to [[traccar-maps-architecture]]:
|
||||||
|
|
||||||
|
- **Vector styles** for providers shipping a full MapLibre style JSON (OpenFreeMap, MapTiler, etc.). Best label/road/POI styling.
|
||||||
|
- **Raster styles** synthesised ad-hoc with one raster source + one raster layer. Used for OSM, OpenTopoMap, Esri World Imagery, Google variants, Bing, custom URLs.
|
||||||
|
|
||||||
|
The synthesised raster style includes a `glyphs` URL pointing at a font CDN so even raster basemaps can render text labels for our overlays (geofence names, device labels, POIs).
|
||||||
|
|
||||||
|
**Google tiles via the official Map Tiles API** are usable through the `maplibre-google-maps` adapter, which registers a `google://` protocol handler that proxies to Google's authenticated tile endpoints. Bring-your-own-key model — Google API key in the SPA's runtime config, not baked into the image.
|
||||||
|
|
||||||
|
The dogfood-day starter set (subject to revision):
|
||||||
|
|
||||||
|
| Style | Provider | Cost | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Satellite | Esri World Imagery (raster XYZ) | Free, attribution required | No key needed; default for first launch. |
|
||||||
|
| Topo | OpenTopoMap (raster XYZ) | Free, attribution required | Useful in mountain rallies where pure satellite has cloud/shadow patches. |
|
||||||
|
| Street | OSM raster | Free | Sanity baseline. |
|
||||||
|
| Custom | User-supplied URL or style JSON | — | Operator escape hatch. |
|
||||||
|
| Google Satellite (optional) | Google Map Tiles API via adapter | First 10k req/mo free, then per-tile | Enable only if operators provide a key in runtime config. |
|
||||||
|
|
||||||
|
## TRM divergences from [[traccar-maps-architecture]]
|
||||||
|
|
||||||
|
| Traccar | TRM | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| Geofence storage in WKT, WKT↔GeoJSON conversion in client | **Native PostGIS GeoJSON** via `ST_AsGeoJSON` | We have PostGIS deployed; the round-trip is dead weight. |
|
||||||
|
| Redux dispatch per WS message | **rAF coalescer + Zustand store** | Eliminates the per-message cascade that drives Traccar's perceived lag. |
|
||||||
|
| `liveRouteLength = 10` default | **`liveRouteLength = 200` default** | Rally operators want minutes of trail, not seconds. |
|
||||||
|
| Generic fleet sprite set (car / truck / plane / ship / animal) | **Racing sprite set** (rally car / quad / SSV / motorcycle / runner / hiker / default) | Operators identify by category at a glance. |
|
||||||
|
| `react-map-gl` is a candidate React wrapper | **Raw MapLibre + singleton + side-effect components** | Declarative wrappers fight the imperative `setData` pattern. |
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- [[traccar-maps-architecture]] — the source architecture this concept distils.
|
||||||
|
- [[react-spa]] — the entity that implements this concept.
|
||||||
|
- [[live-channel-architecture]] — the producer-side WebSocket contract this concept consumes.
|
||||||
|
- [[processor]] — produces the live position stream.
|
||||||
|
- [[directus]] — REST API for geofence CRUD; JWT issuer for WS auth.
|
||||||
+38
-19
@@ -2,8 +2,8 @@
|
|||||||
title: React SPA
|
title: React SPA
|
||||||
type: entity
|
type: entity
|
||||||
created: 2026-04-30
|
created: 2026-04-30
|
||||||
updated: 2026-04-30
|
updated: 2026-05-02
|
||||||
sources: [gps-tracking-architecture]
|
sources: [gps-tracking-architecture, traccar-maps-architecture]
|
||||||
tags: [service, presentation-plane, frontend]
|
tags: [service, presentation-plane, frontend]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,31 +26,50 @@ One application serves multiple user types via role-based routing and conditiona
|
|||||||
|
|
||||||
## Data access pattern
|
## Data access pattern
|
||||||
|
|
||||||
The SPA talks **exclusively** to Directus:
|
The SPA talks to **two endpoints**, one per plane (see [[live-channel-architecture]]):
|
||||||
|
|
||||||
- REST/GraphQL via `@directus/sdk`.
|
- **[[directus]]** — REST/GraphQL via `@directus/sdk`, plus Directus's WebSocket for business-plane events. Auth, schema, all CRUD on entries / vehicles / devices / geofences / etc.
|
||||||
- WebSocket subscriptions via the same SDK.
|
- **[[processor]]** — its own WebSocket endpoint, exclusively for the live position firehose. Authenticated by the same Directus-issued credential the SPA already holds; authorization delegated to Directus once at subscribe time.
|
||||||
- JWT auth managed by the SDK; refresh handled transparently.
|
|
||||||
|
|
||||||
**Never** talks to the [[processor]], [[tcp-ingestion]], [[redis-streams]], or [[postgres-timescaledb]] directly. This boundary lets the back-end evolve internally and keeps the security model coherent — every request goes through Directus's permission system.
|
**Never** talks to [[tcp-ingestion]], [[redis-streams]], or [[postgres-timescaledb]] directly. The two-endpoint split exists because Directus's WebSocket subscriptions only fire for writes through its own `ItemsService` — Processor's direct-to-DB position writes are invisible to it. See [[live-channel-architecture]] for why this is the architecturally honest answer rather than a workaround.
|
||||||
|
|
||||||
## Recommended stack
|
## Stack
|
||||||
|
|
||||||
- **Vite + React + TypeScript**
|
- **Vite + React + TypeScript** — SPA build, no SSR.
|
||||||
- **TanStack Router** — better TS support than React Router; optional file-based routing
|
- **TanStack Router** — file-based, type-safe routes.
|
||||||
- **TanStack Query** — server state, caching, invalidation, optimistic updates
|
- **TanStack Query** — Directus REST: caching, invalidation, refetch on focus.
|
||||||
- **@directus/sdk** — typed access + real-time
|
- **`@directus/sdk`** — typed access for REST + Directus's WebSocket.
|
||||||
- **MapLibre GL + react-map-gl** — open-source WebGL maps, no token needed
|
- **MapLibre GL JS** — WebGL map renderer. Used **raw**, not via `react-map-gl` — the declarative wrapper fights the imperative `setData` pattern that's the whole point of the architecture (see [[maps-architecture]]).
|
||||||
- **shadcn/ui + Tailwind** — UI primitives
|
- **`maplibre-google-maps`** *(optional, runtime-config-gated)* — protocol adapter that lets MapLibre consume Google's official Map Tiles API when an operator-provided API key is present.
|
||||||
- **Zustand** — client-only state (filters, UI prefs)
|
- **`@mapbox/mapbox-gl-draw`** — geofence editor (polygon / line / trash modes) wrapped to look native to MapLibre.
|
||||||
- **react-hook-form + Zod** — forms and validation
|
- **Zustand** — high-frequency live state (positions, trails, selection). Granular subscribers; chosen over Redux specifically because Redux's dispatch + selector cascade is the most likely cause of the lag observed in production [[traccar-maps-architecture]] deployments.
|
||||||
|
- **shadcn/ui + Tailwind** — UI primitives.
|
||||||
|
- **react-hook-form + Zod** — forms and validation.
|
||||||
|
|
||||||
Covers the spectrum from form-heavy admin screens to real-time map dashboards without architectural changes between them.
|
Covers form-heavy admin screens and real-time map dashboards without architectural changes between them.
|
||||||
|
|
||||||
|
## Auth pattern
|
||||||
|
|
||||||
|
Same-domain session cookie via reverse proxy. One origin serves the SPA, Directus, and Processor's WebSocket endpoint — Vite's `server.proxy` in dev, Traefik (or whatever fronts the deploy stack) in stage/prod.
|
||||||
|
|
||||||
|
- Login uses the Directus SDK in **session mode** (`authentication('session', { credentials: 'include' })`). Directus issues an `httpOnly`/`Secure`/`SameSite=Lax` session cookie; the cookie itself carries the session — there is no separate in-memory access token to manage and no `/auth/refresh` dance.
|
||||||
|
- Reload survives cleanly: the browser still has the cookie, `/users/me` returns the user without any client-side state.
|
||||||
|
- No JavaScript ever reads or writes the cookie (it's `httpOnly`), so XSS cannot exfiltrate it.
|
||||||
|
- WebSocket handshake: same-origin means the browser sends the session cookie automatically with the upgrade request. Processor reads it on the upgrade, validates against Directus's `/users/me`, and uses the resulting user identity for subscription authorization. See [[live-channel-architecture]] and [[processor-ws-contract]].
|
||||||
|
|
||||||
|
This requires the proxy to serve everything under one origin (path-based or single subdomain) — separate subdomains break cookie flow.
|
||||||
|
|
||||||
|
**Mode choice context.** Directus's SDK also supports `'cookie'` mode (refresh cookie + in-memory access token). It works while the SDK is alive in memory but doesn't survive a hard reload cleanly because there's no access token to retry `/users/me` against, and the refresh-then-read sequence is order-sensitive. `'session'` mode collapses that to one credential — the session cookie — and is the right default for an SPA that wants reload-survives behaviour.
|
||||||
|
|
||||||
## Real-time rendering
|
## Real-time rendering
|
||||||
|
|
||||||
- **Live maps with many markers**: React reconciler is not the bottleneck — drawing happens in WebGL via MapLibre, which manages features outside React's tree. The React layer manages subscriptions and feeds the map updates.
|
The full pattern lives at [[maps-architecture]]; the headlines:
|
||||||
- **High-frequency tabular updates** (live leaderboards, event feeds): split components so high-update areas re-render in isolation; use TanStack Query for live data; memoize at component boundaries that receive frequent updates.
|
|
||||||
|
- **MapLibre is a singleton** held in a module-level variable, attached to a detached `<div>` that React refs mount/unmount per page. WebGL context survives navigation.
|
||||||
|
- **Two GeoJSON sources** for live positions: clustered non-selected, unclustered always-on-top selected. Updates flow through `setData`, not DOM marker manipulation.
|
||||||
|
- **rAF coalescer at the WS boundary.** Incoming position messages buffer per-device; one `requestAnimationFrame` tick flushes the latest snapshot to the Zustand store. Without this, per-message dispatches cascade through selectors and `setData` at every position arrival — the failure mode [[traccar-maps-architecture]] exhibits.
|
||||||
|
- **Per-device bounded ring buffers** for trail history. Default 200 points per device, configurable. The throttle controls visual cadence; trails are never lossy.
|
||||||
|
- **High-frequency tabular updates** (live leaderboards, event feeds) — same Zustand store, separate component subtrees so the map's re-renders don't ripple into the leaderboard and vice versa.
|
||||||
|
|
||||||
## Failure mode
|
## Failure mode
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
title: Traccar Web — Maps Architecture
|
||||||
|
type: source
|
||||||
|
created: 2026-05-02
|
||||||
|
updated: 2026-05-02
|
||||||
|
sources: []
|
||||||
|
source_path: raw/TRACCAR_MAPS_ARCHITECTURE.md
|
||||||
|
source_date: 2026-05-02
|
||||||
|
source_kind: note
|
||||||
|
tags: [maps, frontend, reference, architecture, maplibre]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Traccar Web — Maps Architecture
|
||||||
|
|
||||||
|
Internal architectural deep-dive into how `traccar-web` (the React front-end of the Traccar GPS tracking platform) builds its maps subsystem. Documents the rendering engine, tile-source strategies (including how Google's tiles are integrated despite Google not being a first-class MapLibre provider), GeoJSON-driven feature rendering, geofence editing, and the WebSocket → Redux → `setData` live-data pipeline.
|
||||||
|
|
||||||
|
This is the canonical reference architecture for [[react-spa]]. TRM's SPA inherits the bulk of these patterns and diverges in a small, deliberate set of places — see the divergences section below and the dedicated [[maps-architecture]] concept page.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Traccar's web app does **not** use the Google Maps JavaScript API. It uses **MapLibre GL JS** (a fork of pre-1.0 Mapbox GL JS) as a single, app-lifetime singleton WebGL renderer. Every map "object" — devices, geofences, routes, accuracy circles, POIs — is a feature in a GeoJSON source rendered by MapLibre style layers. Live data flows from a single WebSocket through Redux into `useSelector`-subscribed components that call `setData` on the appropriate source. Google tiles are consumed either via the official Map Tiles API (through the `maplibre-google-maps` adapter, which registers a `google://` protocol handler) or by hitting Google's legacy public tile servers directly when no API key is configured.
|
||||||
|
|
||||||
|
The architecture is well-designed for the rendering side. The likely failure mode at scale is the **per-message Redux dispatch + per-`setData` call** pattern in the data pipeline — at high position rates this cascades through selectors and rebuilds full feature collections on every position. TRM's SPA addresses that with a `requestAnimationFrame` coalescer at the WS boundary (see divergences).
|
||||||
|
|
||||||
|
## Key claims
|
||||||
|
|
||||||
|
Each claim is self-contained — readable without prior context.
|
||||||
|
|
||||||
|
### Rendering engine
|
||||||
|
|
||||||
|
- **MapLibre GL JS is the rendering engine**, not the Google Maps SDK or Leaflet. WebGL all the way down: vector and raster sources, GPU-rasterised symbol/circle/line layers, no DOM markers.
|
||||||
|
- **A single `maplibregl.Map` instance lives for the entire app lifetime.** It's constructed at module load against a detached `<div>` held in a module-level variable. The React `<MapView>` component just appends that detached `<div>` 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<readyListeners>` 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 `<MapView>`'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?://<host>/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 `<MainMap>` 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 `<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()`. (§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.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
title: Processor WebSocket contract
|
||||||
|
type: synthesis
|
||||||
|
created: 2026-05-02
|
||||||
|
updated: 2026-05-02
|
||||||
|
sources: [gps-tracking-architecture, traccar-maps-architecture]
|
||||||
|
tags: [websocket, protocol, contract, telemetry-plane, decision]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Processor WebSocket contract
|
||||||
|
|
||||||
|
The wire-level specification of the WebSocket endpoint that fans live position updates from [[processor]] (or its eventual replacement gateway — see Implementation status) to [[react-spa]] clients. Both sides build against this contract; changes require a coordinated update on both sides.
|
||||||
|
|
||||||
|
This page is the protocol spec. The architectural rationale lives in [[live-channel-architecture]]; the consumer-side rendering pattern in [[maps-architecture]]; the inheritance from a working production reference in [[traccar-maps-architecture]].
|
||||||
|
|
||||||
|
## Implementation status
|
||||||
|
|
||||||
|
**Planned as `processor` Phase 1.5 — Live broadcast.** Six tasks in `trm/processor/.planning/phase-1-5-live-broadcast/`: WS server scaffold + heartbeat, cookie auth handshake, subscription registry & per-event authorization, broadcast consumer group & fan-out, snapshot-on-subscribe, integration test. Status ⬜ Not started; sequenced as 1.5.1 → 1.5.2 → 1.5.3 → (1.5.4 ‖ 1.5.5) → 1.5.6.
|
||||||
|
|
||||||
|
The endpoint is hosted *inside* the Processor process (as [[processor]] and [[live-channel-architecture]] specify). Lifting it into a separate `live-gateway` service is the documented escape hatch in [[live-channel-architecture]] §"Scale considerations" if sustained > 10k WS messages/sec demands it — not the starting point.
|
||||||
|
|
||||||
|
This contract is implementation-agnostic in the sense that the wire format wouldn't change if we ever did lift the endpoint out — only the host process would. SPA work can build against the contract independently of the Processor task sequence as long as it doesn't ship to stage before Phase 1.5 lands.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
wss://<one-public-origin>/processor/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Served behind the same reverse proxy that fronts [[directus]] and the [[react-spa]] static bundle. **Single origin is non-negotiable** — same-origin is what allows the auth cookie to flow with the WebSocket upgrade request (see Auth handshake below).
|
||||||
|
|
||||||
|
The path `/processor/ws` is illustrative; final path determined by the proxy routing rules. Whatever it is, the SPA reaches it as a relative URL, never a cross-origin URL.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
- **Protocol:** WebSocket (RFC 6455) over TLS at the edge. Internal hop from the proxy to the producer is plain WS on the `trm_default` Compose network.
|
||||||
|
- **Subprotocol:** none required. Future versions may add a `Sec-WebSocket-Protocol` of `trm.live.v1` if we need to negotiate versions; for now the path is the version.
|
||||||
|
- **Frame format:** text frames, JSON-encoded. No binary frames. (If we ever need to ship raw position bytes for a high-frequency optimisation, that's a v2 concern.)
|
||||||
|
- **Heartbeat:** the producer sends a ping every 30 s; the consumer responds. Consumer-side liveness is enforced by `setInterval` checking time-since-last-message > 60s ⇒ reconnect.
|
||||||
|
|
||||||
|
## Auth handshake
|
||||||
|
|
||||||
|
Cookie-based, same-origin, validated against [[directus]] once at connection time. The SPA uses the Directus SDK in session mode (see [[react-spa]] §"Auth pattern"); the producer is cookie-name-agnostic and just forwards whatever cookie header the upgrade carries.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Browser opens WebSocket to wss://<origin>/processor/ws.
|
||||||
|
Same-origin → browser automatically attaches the httpOnly session cookie
|
||||||
|
issued by Directus's /auth/login (session mode).
|
||||||
|
|
||||||
|
2. Producer reads the entire Cookie header from the upgrade request.
|
||||||
|
GET /users/me to Directus, forwarding the header verbatim.
|
||||||
|
200 → user identity (id, role, etc.) is bound to the connection.
|
||||||
|
401/403 → close the WebSocket with code 4401 (unauthorized).
|
||||||
|
|
||||||
|
3. Connection is now authenticated. The producer holds (connectionId → user)
|
||||||
|
in memory. No further per-message auth.
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- **Cookie validation cache.** `/users/me` round-trip per connection is fine at pilot scale (≤500 viewers). At higher scale, cache the validation result for the connection's lifetime; on logout / session expiry the SPA reconnects, which re-validates.
|
||||||
|
- **No JWT in URL.** Don't pass tokens in query strings — they end up in proxy logs. Cookie is the only credential.
|
||||||
|
- **Why cookie not Authorization header.** Browsers don't let you set Authorization on a WebSocket upgrade. Cookies flow automatically. Same-origin is what makes this work.
|
||||||
|
- **Cookie-name-agnostic.** The producer never parses individual cookies; it forwards the whole header to `/users/me` and lets Directus identify the session. This keeps the producer working unchanged if Directus's cookie name or auth-mode default ever changes.
|
||||||
|
|
||||||
|
## Subscription model
|
||||||
|
|
||||||
|
After authentication, the SPA subscribes to event-scoped topics. One connection can hold multiple subscriptions; per-event authorization is checked once at subscribe time.
|
||||||
|
|
||||||
|
### Topic format
|
||||||
|
|
||||||
|
```
|
||||||
|
event:<eventId>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<eventId>` is the UUID of an `events` row. Authorization: the user must have a record in `organization_users` for the event's organization (any role). Phase 4 of [[directus]] (permissions) will tighten this; for now membership is enough.
|
||||||
|
|
||||||
|
Future topic shapes (not in v1):
|
||||||
|
|
||||||
|
- `device:<deviceId>` — single-device follow.
|
||||||
|
- `entry:<entryId>` — follow a specific competitor across stages.
|
||||||
|
- `org:<orgId>` — broad org-wide watch (admin-only).
|
||||||
|
|
||||||
|
The protocol is forward-compatible: any string-typed topic is valid; producer rejects unknown shapes with `error/unknown-topic`.
|
||||||
|
|
||||||
|
### Subscribe
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Client → Server
|
||||||
|
{
|
||||||
|
"type": "subscribe",
|
||||||
|
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||||
|
"id": "client-correlation-id-1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`id` is optional; if present, the server echoes it on the response so the client can correlate.
|
||||||
|
|
||||||
|
### Server response — subscribed
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Server → Client
|
||||||
|
{
|
||||||
|
"type": "subscribed",
|
||||||
|
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||||
|
"id": "client-correlation-id-1",
|
||||||
|
"snapshot": [
|
||||||
|
{ "deviceId": "cbed320e...", "lat": 41.327, "lon": 19.819, "ts": 1714654800000, "speed": 42.3, "course": 187, "accuracy": 5.0, "attributes": {} },
|
||||||
|
{ "deviceId": "f6114c7e...", "lat": 41.328, "lon": 19.820, "ts": 1714654799000, "speed": 38.1, "course": 184, "accuracy": 4.5, "attributes": {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The snapshot is the **latest known position per device** registered to the event (via `entry_devices` → `entries` → `events`). Without it, the SPA opens to a black map until devices report — feels broken.
|
||||||
|
|
||||||
|
### Server response — error
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Server → Client
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||||
|
"id": "client-correlation-id-1",
|
||||||
|
"code": "forbidden",
|
||||||
|
"message": "User does not belong to the event's organization."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error codes (initial set; extensible):
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `forbidden` | User authenticated but not authorized for this topic. |
|
||||||
|
| `not-found` | Topic refers to a non-existent entity (event id has no row). |
|
||||||
|
| `unknown-topic` | Topic format not recognised. |
|
||||||
|
| `rate-limited` | Subscribe rate exceeded (Phase 3 hardening; reserved). |
|
||||||
|
|
||||||
|
### Streaming updates
|
||||||
|
|
||||||
|
After `subscribed`, the server pushes one message per position-of-interest:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Server → Client
|
||||||
|
{
|
||||||
|
"type": "position",
|
||||||
|
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||||
|
"deviceId": "cbed320e-1e94-488a-93c3-41060fcb06bc",
|
||||||
|
"lat": 41.32791,
|
||||||
|
"lon": 19.81947,
|
||||||
|
"ts": 1714654801000,
|
||||||
|
"speed": 42.5,
|
||||||
|
"course": 188,
|
||||||
|
"accuracy": 5.0,
|
||||||
|
"attributes": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field semantics:
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | `"position"` | yes | Discriminator. |
|
||||||
|
| `topic` | string | yes | Echoes the subscription. Allows multiplexing on one connection. |
|
||||||
|
| `deviceId` | uuid | yes | The `devices.id` (not the IMEI). SPA looks up device → entry → vehicle/crew via TanStack Query against [[directus]]. |
|
||||||
|
| `lat` / `lon` | number (degrees, WGS84) | yes | GPS coordinates. **Coordinate order in JSON is `lat`/`lon`** (not `[lon,lat]` GeoJSON ordering — that conversion happens in the SPA). |
|
||||||
|
| `ts` | number (epoch milliseconds, UTC) | yes | Authoritative timestamp from the device's GPS fix. **Always use this, never `Date.now()` on the client.** |
|
||||||
|
| `speed` | number (km/h) | optional | Omitted if device reports speed=0 with invalid GPS fix (per [[teltonika]] convention). |
|
||||||
|
| `course` | number (degrees, 0=N, clockwise) | optional | Heading. Omitted if unknown. |
|
||||||
|
| `accuracy` | number (metres) | optional | Position accuracy radius for the [[react-spa]]'s accuracy-circle layer. |
|
||||||
|
| `attributes` | object | optional, default `{}` | The decoded IO bag. Phase 1 ships the raw IO map; Phase 2 of [[processor]] adds named attributes per [[io-element-bag]]. SPA must tolerate empty / unknown shapes. |
|
||||||
|
|
||||||
|
The producer should **omit fields rather than send `null`** for absent values. Reduces JSON size and removes ambiguity (null = "we don't know" vs missing = "device didn't report").
|
||||||
|
|
||||||
|
### Unsubscribe
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Client → Server
|
||||||
|
{
|
||||||
|
"type": "unsubscribe",
|
||||||
|
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||||
|
"id": "client-correlation-id-2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Server → Client
|
||||||
|
{
|
||||||
|
"type": "unsubscribed",
|
||||||
|
"topic": "event:ada60b3d-b29f-4017-b702-cd6b700f9f6c",
|
||||||
|
"id": "client-correlation-id-2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The connection stays open with whatever other subscriptions are active. Closing the WebSocket is the cleanup-everything path.
|
||||||
|
|
||||||
|
## Reconnect semantics
|
||||||
|
|
||||||
|
The client reconnects on close (other than code 4401). Backoff: 1s, 2s, 4s, 8s, 16s, then 30s steady. Cap at 30s.
|
||||||
|
|
||||||
|
On reconnect, the client **must re-subscribe to all previously-active topics**. The server treats reconnect as a fresh connection; subscription state lives in memory only.
|
||||||
|
|
||||||
|
The server should accept reconnects from the same user without rate-limiting at pilot scale. Phase 3 may add a per-user concurrent-connection cap.
|
||||||
|
|
||||||
|
## Multi-instance behaviour
|
||||||
|
|
||||||
|
When [[processor]] (or the gateway service) runs more than one replica:
|
||||||
|
|
||||||
|
- Each instance reads the [[redis-streams]] telemetry stream on **two consumer groups**:
|
||||||
|
- `processor` — the durable-write group (work-split: only one instance handles each record for the DB write).
|
||||||
|
- `live-broadcast-{instance_id}` — a per-instance fan-out group (every instance reads every record for fan-out).
|
||||||
|
- Connected clients are bound to one instance via the load balancer; that instance fans out to its own clients only. No cross-instance broadcasting needed.
|
||||||
|
- The reconnect is what handles instance failure — client reconnects, gets re-load-balanced to a healthy instance, re-subscribes.
|
||||||
|
|
||||||
|
This design is documented in [[live-channel-architecture]] §"Multi-instance Processor".
|
||||||
|
|
||||||
|
## Connection limits and back-pressure
|
||||||
|
|
||||||
|
Pilot-scale targets (subject to revision after first dogfood):
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|---|---|
|
||||||
|
| Concurrent connections per instance | 100 |
|
||||||
|
| Subscriptions per connection | 4 (one event + room for future per-device follow) |
|
||||||
|
| Position messages per second per connection | ≤ 500 (race start with 500 devices reporting at 1Hz) |
|
||||||
|
| End-to-end latency (Redis stream → client) | p95 < 500ms |
|
||||||
|
| Reconnect storm tolerance | 200 reconnects/sec for 5 seconds (race start surge) |
|
||||||
|
|
||||||
|
If a slow consumer can't drain its queue, the server **drops oldest position messages** for that connection (per-device; latest position is always preserved). Position data is always-fresh — backlog isn't valuable. Only `subscribed`/`unsubscribed`/`error` control messages are guaranteed delivery.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
This is `v1`. Breaking changes (renaming fields, changing semantics) require:
|
||||||
|
|
||||||
|
1. New endpoint path (`/processor/ws/v2`).
|
||||||
|
2. Update this synthesis page to document both versions.
|
||||||
|
3. Deprecation window: v1 stays online for ≥ one full event cycle after v2 lands.
|
||||||
|
|
||||||
|
Non-breaking additions (new optional fields, new message types, new error codes) ship in v1 without ceremony — both sides should ignore unknown fields and unknown `type` values.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Session expiry while connected.** Directus session cookies have a finite lifetime. The WebSocket connection's already-validated identity is unaffected for as long as the connection stays open — the producer authorised once at upgrade and doesn't re-check. If the session expires server-side, the SPA's next REST call (or its periodic `/users/me` ping, if added) will fail with 401, the SPA will redirect to login, and on re-login the SPA reconnects the WebSocket — which re-validates. Pilot answer: producer never re-validates mid-connection. Phase 3 hardening can revisit if real-world session durations make this feel wrong.
|
||||||
|
- **Device-to-event resolution snapshot freshness.** The snapshot includes "every device registered to the event"; that registration set may change while a client is subscribed. Initial answer: subscription holds the registration set captured at subscribe time; new entries added mid-event don't appear until the client reconnects. Acceptable for pilot.
|
||||||
|
- **Faulty-flag visibility.** When an operator flips a position's `faulty=true` flag in [[directus]], should the live channel emit a correction? Current answer: no — faulty flagging is post-hoc operator review, not a live concern. Live map shows whatever was streamed at the time. The recompute pipeline ([[processor]] faulty position handling) corrects derived data, not the live history.
|
||||||
|
- **Replay-mode endpoint.** Out of v1 scope. A future `event:<id>:replay` topic could stream historical positions at a chosen speed. Defer.
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- [[live-channel-architecture]] — architectural rationale and dual-channel design.
|
||||||
|
- [[processor]] — the entity nominally hosting this endpoint (subject to the Implementation status note above).
|
||||||
|
- [[react-spa]] — the consumer.
|
||||||
|
- [[maps-architecture]] — consumer-side throughput discipline (rAF coalescer) that this contract is consumed through.
|
||||||
|
- [[traccar-maps-architecture]] — the working production reference whose WS contract shape this draws from (with refinements for our needs).
|
||||||
|
- [[directus]] — auth source (cookie validator) and the data source for event/device/org metadata the SPA looks up alongside the live stream.
|
||||||
Reference in New Issue
Block a user