- pnpm add maplibre-gl + -D @types/geojson. - src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2 replaces with the basemap-switcher descriptor table). - src/map/core/map-view.tsx: module-level Map singleton lazily created on first <MapView> mount, attached to a class="trm-map-host" detached <div> that React refs append/remove on mount/unmount. Style-data lifecycle flips mapReady false on every styledata event, polls loaded() at 33ms intervals, flips ready true once the style is loaded — the canonical MapLibre style-swap dance. - Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap() throws if called pre-mount; the explicit failure mode beats a null-able top-level export. - src/routes/_authed/monitor.tsx: new /monitor route, full-viewport <MapView /> for 2.1 (no children — subsequent tasks plug in here). - src/routes/_authed/index.tsx: home-page card now links to /monitor. - eslint.config.js: override for src/map/** + src/live/** disables react-refresh/only-export-components. Same pattern as the existing overrides for shadcn primitives and route files. Deviation: spec sketched a top-level `map` constant export; implemented as `getMap(): MapLibreMap` (a function) so the singleton stays lazy until <MapView> mounts. Top-level constant would either force eager init (breaks SSR/tests) or be nullable (footgun). The function form throws a clear error if called pre-mount. Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS). Other routes unaffected. Vite chunk-size warning is harmless.
7.5 KiB
Task 2.6 — MapTrails (bounded ring buffer, polyline rendering)
Phase: 2 — Live monitoring map
Status: ⬜ Not started
Depends on: 2.1, 2.4.
Wiki refs: docs/wiki/concepts/maps-architecture.md §"Routes (history and live trails)"; docs/wiki/sources/traccar-maps-architecture.md §8.
Goal
Render the trailing path of every (or only the selected) device as a polyline. Reads from the per-device ring buffer in the position store (2.4 capped at 200 points). Visible as a thin line behind the moving marker — operators see "where this racer has been in the last few minutes."
After this task lands, the live map shows motion history, not just current position. That's what makes "live tracking" feel live.
Deliverables
src/map/layers/map-trails.tsx—<MapTrails />side-effect-only component:- Returns
null. - Two-effect pattern: setup adds one GeoJSON source + one line layer; update calls
setData. - Reads
trailsByDeviceandselectedDeviceIdfrom the position store. - Honours a user preference:
none/selected/allfor which devices' trails render.
- Returns
src/live/position-store.tsupdated to exposetrailModepreference ('none' | 'selected' | 'all', default'selected') and a setter. Persisted via Zustandpersistmiddleware on the prefs slice (or a separate prefs store — same shape as the map-pref store from 2.2).- A toggle UI control in the basemap-switcher card (or a sibling control): "Trails: none / selected / all". 3-state toggle.
- Decision: speed-coloured per-segment. Two flavours possible (see Specification below). For v1 of this task, start with flat colour per device (the simpler path). Add a feature-flagged speed-coloured variant if there's appetite during the build; defer to a Phase 3 polish task otherwise.
Specification
Single source, single layer
// Setup effect:
map.addSource(trailsId, {
type: 'geojson',
data: emptyFC,
});
map.addLayer(
{
id: trailsLineId,
source: trailsId,
type: 'line',
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
'line-opacity': 0.85,
},
},
// Insert BEFORE map-positions' icon layers so trails sit under markers.
// The id of the first non-selected symbol layer from 2.5 is the anchor.
firstSymbolLayerId,
);
The third arg to addLayer puts the line layer below the position symbols; trails draw under markers without obscuring them.
Feature builder — flat colour per device
function buildTrailFeature(
deviceId: string,
points: PositionEntry[],
device?: Device,
): Feature | null {
if (points.length < 2) return null;
return {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: points.map((p) => [p.lon, p.lat]),
},
properties: {
deviceId,
// Flat colour per device's status (or category-driven palette).
color: deviceColour(device),
},
};
}
function deviceColour(device?: Device): string {
// Defer to a per-class palette later. For v1, a small rotating set keyed by deviceId hash:
const palette = ['#2563C8', '#2E8C4A', '#6B46C1', '#188C8A', '#C9296F', '#5A5A53'];
const h = hashString(device?.id ?? 'default');
return palette[h % palette.length];
}
Per-device colour means operators can visually track who's whose trail without reading labels.
Update effect
useEffect(() => {
const features: Feature[] = [];
if (trailMode === 'none') {
// empty FC
} else if (trailMode === 'selected' && selectedDeviceId) {
const points = trailsByDevice.get(selectedDeviceId) ?? [];
const f = buildTrailFeature(selectedDeviceId, points, devices.get(selectedDeviceId));
if (f) features.push(f);
} else if (trailMode === 'all') {
for (const [deviceId, points] of trailsByDevice) {
const f = buildTrailFeature(deviceId, points, devices.get(deviceId));
if (f) features.push(f);
}
}
(map.getSource(trailsId) as maplibregl.GeoJSONSource | undefined)?.setData({
type: 'FeatureCollection',
features,
});
}, [trailMode, selectedDeviceId, trailsByDevice, devices]);
Speed-coloured per-segment (deferred decision)
Traccar's replay mode renders one line segment per consecutive position pair, coloured by the second point's speed. For live trails this would mean N-1 features per device per update — heavier but visually richer. Operators glance at colour to read "fast / slow / stopped."
If we decide to ship this in v1: replace LineString with multiple two-point LineStrings and colour per segment. The setData payload grows; the line layer's 'line-color' becomes ['get', 'segmentColor'] instead of the device's flat colour.
For 2.6 v1: flat colour, deferred speed-colouring to a Phase 3 polish task. Decision rationale documented in the open question below.
Trail length
MAX_TRAIL_LENGTH = 200 (from 2.4's constants). At 1Hz position rate, that's 200 seconds (~3.3 minutes) of trail. At 0.2Hz (default Teltonika rate), it's ~17 minutes. Both are reasonable.
If operators want a longer-trail mode: a slider in the prefs (50 / 200 / 500 / 1000). Out of scope for 2.6 — defer to a Phase 3 polish task.
What this task does NOT include
- Speed-coloured segments. Decided as flat for v1; revisit in Phase 3 polish.
- Trail-length user control. Hardcoded to 200 via 2.4's constant.
- Trail persistence across page reloads. Trails reset on every reload (live state, not durable).
- Snapshotting old trails. No "show me the last 24h of this device" — that's replay (Phase 4).
Acceptance criteria
pnpm typecheck,pnpm lint,pnpm format:check,pnpm buildclean.- With the dogfood seed and synthetic positions,
/monitorshows a polyline trailing each device as it moves. - Switching the trail-mode toggle (none / selected / all) immediately changes which trails render. None → empty layer. Selected → only the selected device. All → every device.
- Trail follows the device's most recent N positions; old points fall off the front as new ones append (verify trail is always exactly the configured length once steady-state).
- Trails sit underneath markers (verifiable visually — markers paint over the polyline).
- Style swap → trails reappear after the basemap change.
- No
setDatawarnings (e.g. coordinate-array mismatch).
Risks / open questions
- Speed-coloured segments — ship in v1? Decided "no, defer". Reasons: (a) flat colour is simpler and visually cleaner; (b) operators don't actually need a real-time speed colourmap when they have the moving marker's status colour; (c) the per-segment feature explosion at high update rates is a minor perf concern. Revisit if early dogfood feedback says the trail isn't informative enough.
- Visual clutter at
trailMode: 'all'. With 50+ devices in a tight rally area, all trails on screen is noisy. Operators will probably default to'selected'. The default is'selected'. Acceptable. - Trail accuracy. With
faultyfiltering on the snapshot but NOT on streaming positions (per processor-ws-contract §"Faulty-flag visibility"), trails can include positions that an operator later flags faulty. Live trail = observed-as-streamed; correction is post-hoc, not retro-active. Document. - Trail-mode toggle placement. Adjacent to the basemap-switcher (2.2) is the natural home — same floating-card pattern. UI-wise, three small radio buttons. Operator-facing label: "Trails: none / selected / all" or "Trail mode: …".
Done
(Filled in when the task lands.)