Files
spa/.planning/phase-2-live-map/06-map-trails.md
T
julian 87a738313e feat: task 2.1 MapView singleton + mapReady gate
- 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.
2026-05-03 09:28:38 +02:00

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 trailsByDevice and selectedDeviceId from the position store.
    • Honours a user preference: none / selected / all for which devices' trails render.
  • src/live/position-store.ts updated to expose trailMode preference ('none' | 'selected' | 'all', default 'selected') and a setter. Persisted via Zustand persist middleware 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 build clean.
  • With the dogfood seed and synthetic positions, /monitor shows 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 setData warnings (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 faulty filtering 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.)