Files
spa/.planning/phase-2-live-map/06-map-trails.md
T
julian 05543529e4 docs(planning): file Phase 2 task specs (live monitoring map)
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec /
Acceptance / Risks / Done). README updated with full sequencing diagram,
files-modified outline, tech stack additions, design rules, and phase
acceptance.

| #   | Task                                                                  |
| --- | --------------------------------------------------------------------- |
| 2.1 | MapView singleton + mapReady gate                                     |
| 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google)     |
| 2.3 | Sprite preload — 7 racing categories x 4 colour variants              |
| 2.4 | WS client + rAF coalescer + Zustand position store + connection store |
| 2.5 | MapPositions — clustered + selected sources                           |
| 2.6 | MapTrails — bounded ring buffer, polyline rendering                   |
| 2.7 | Event picker — TanStack Query + WS subscription orchestration         |
| 2.8 | Camera control trio — default-fit / selected-follow / one-shot        |
| 2.9 | Connection status + per-device last-seen indicators                   |

Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data
pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently.
2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5.

Each task documents its deliverables down to file paths + interface
shapes, includes concrete code sketches in the Specification, lists
explicit out-of-scope items, and surfaces risks for the implementer
to think about. An agent (or future me) can pick up any single task
and ship it without re-deriving the design from the wiki.

Resolved Phase 2 design decisions baked into the task files:
- Trails: flat-colour-per-device for v1, defer speed-coloured segments
  to a Phase 3 polish task.
- Cluster params: 14/50 (traccar default); tune after seeing real data.
- Event picker placement: top-left dropdown.
- Multi-event: out — single-select, one event at a time.
- Stale-position visual: fade icon opacity; defer warning badges.
2026-05-03 09:28:16 +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.)