Files
julian 564b7881bd feat: task 2.7 event picker (subscription driver)
- src/data/events.ts: useUserEvents() TanStack Query (5-min stale,
  sort -starts_at). EventSummary type is a Pick of EventRow.
- src/live/active-event.ts: useActiveEventOrchestration() returns the
  swap fn — unsubscribe previous + clearForEvent + subscribe new +
  applySnapshot on success + persist to localStorage. Out-of-order
  safety via per-call version counter. Plus readSavedActiveEventId().
- src/ui/components/event-picker.tsx: <EventPicker> dropdown.
  useState + click-outside; rows show name + date + discipline.
- src/live/index.ts: re-exports active-event helpers.
- src/routes/_authed/monitor.tsx: auto-select effect (one-shot via
  initializedRef, gated on events loaded + WS connected); renders
  <EventPicker> wired to setActiveEvent.

Deviations:
1. Vanilla div + useState dropdown instead of shadcn Popover —
   no new shadcn primitive add; easy to swap later for keyboard nav.
2. Auto-select gated on connectionStatus === 'connected' so the
   subscribe call gets the snapshot path (not 'not-connected').
3. Logout-clears-saved-event-id deferred to a small Phase 1.8
   follow-up; documented in task risks.

Bundle: 395KB / 120KB gz (~1KB up from 2.6).
2026-05-03 09:32:37 +02:00

9.7 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

  • src/map/core/map-pref-store.ts — extended with trailMode: TrailMode ('none' | 'selected' | 'all', default 'selected') and setTrailMode. Persisted via the existing zustand persist middleware on 'trm-map-prefs'.
  • src/map/layers/map-trails.tsx<MapTrails /> side-effect-only component. Single GeoJSON source + single 'line' layer. Two-effect pattern (setup + setData on store changes). Reads trailsByDevice, selectedDeviceId, trailMode. Builds one LineString Feature per device whose trail has ≥ 2 points; filtered by mode (none → empty, selected → just the selected device, all → every device).
  • src/map/core/trails-toggle.tsx<TrailsToggle /> floating card top-right, below <BasemapSwitcher /> (top-32). Three buttons (None / Selected / All); active one highlighted via bg-accent. Has a small "Trails" label up top.
  • src/routes/_authed/monitor.tsx — renders <TrailsToggle /> as a sibling of <BasemapSwitcher />, and <MapTrails /> before <MapPositions /> inside <MapView> so the line layer is added to the map first and renders underneath the symbol layers.

Per-device colouring: flat colour from a 6-entry palette (#2563C8 / #2E8C4A / #6B46C1 / #188C8A / #C9296F / #5A5A53) keyed by a deterministic hash of the deviceId. Same device = same colour across reloads. Distinct enough that two trails don't blend. Speed-coloured-per-segment deferred to a Phase 3 polish task per the original task spec's open-question decision.

Smoke check (local pnpm dev):

  • /monitor shows the trails toggle below the basemap switcher.
  • Default mode is "Selected" → no trails visible until a device is selected.
  • Synthetic positions (push 2+ positions for the same deviceId via the console — usePositionStore.getState().applyPositions(...)) and the trail polyline appears immediately.
  • Toggling to "All" shows trails for every device with ≥ 2 points.
  • Toggling to "None" hides trails.
  • Switching basemap → trails reappear after the swap (mapReady gate handles the remount).
  • Reloading the page: trail mode persists.

Bundle: main bundle 394KB / 120KB gz — no change from 2.5. Trails layer is small.

Landed in 510dfdf.