Files
spa/.planning/phase-2-live-map/05-map-positions.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

10 KiB
Raw Blame History

Task 2.5 — MapPositions (clustered + selected sources)

Phase: 2 — Live monitoring map Status: Not started Depends on: 2.1, 2.3, 2.4. Wiki refs: docs/wiki/concepts/maps-architecture.md §"Two GeoJSON sources for live positions"; docs/wiki/sources/traccar-maps-architecture.md §6.

Goal

Render every device on the map as a sprite, colour-coded by status, rotated by heading. Two GeoJSON sources: one clustered (non-selected devices), one unclustered (the selected device, always on top). Click a marker → select. Click a cluster → zoom to its extent. The map updates via setData on every store change; never via DOM marker manipulation.

After this task, the live map is functionally complete for the dogfood — operators see racers move in real time, can pick one to follow.

Deliverables

  • src/map/layers/map-positions.tsx<MapPositions /> side-effect-only component:
    • Returns null.
    • Two-effect pattern: setup effect ([] deps) adds two GeoJSON sources + 5 layers; update effect (depends on store data) calls setData.
    • Click handlers wired via map.on('click', layerId, handler).
    • Cluster expansion via map.getSource(id).getClusterExpansionZoom(clusterId) + map.easeTo.
  • Five layers per source — actually two sources × layers each:
    • Non-selected source (cluster: true, clusterMaxZoom: 14, clusterRadius: 50):
      • Symbol layer for unclustered features (icon + title).
      • Symbol layer for direction arrows (filtered to features where course != null).
      • Symbol layer for cluster bubbles (filtered to ['has', 'point_count'], shows count).
    • Selected source (no clustering):
      • Symbol layer (icon + title), always on top.
      • Direction-arrow layer.
  • Feature builder buildPositionFeature(p, opts): Feature:
    • properties.deviceId, category, color, course, title, direction (boolean controlling whether the arrow renders).
  • Selection wiring — clicks dispatch usePositionStore.getState().selectDevice(deviceId). The component re-renders both sources whenever selectedDeviceId changes (devices move from non-selected → selected source).
  • useId() for unique source/layer ids so the same component can be mounted twice safely (e.g. a future overview map).

Specification

Source configuration

// On setup:
map.addSource(nonSelectedId, {
  type: 'geojson',
  data: emptyFeatureCollection,
  cluster: true,
  clusterMaxZoom: 14,
  clusterRadius: 50,
});
map.addSource(selectedId, {
  type: 'geojson',
  data: emptyFeatureCollection,
});

clusterMaxZoom: 14 and clusterRadius: 50 are traccar-web's defaults, sensible for a country-scale rally. We can tune after seeing real data on the map.

Layer order

Order matters — later layers paint over earlier. Order:

  1. Non-selected: symbol (icon + title)
  2. Non-selected: direction arrow
  3. Cluster bubbles
  4. Selected: symbol (icon + title)
  5. Selected: direction arrow

Selected always on top. Cluster bubbles above device markers (so a clustered point looks like a bubble, not an obscured icon).

Symbol layer (non-selected, unclustered)

map.addLayer({
  id: nonSelectedSymbolId,
  source: nonSelectedId,
  type: 'symbol',
  filter: ['!', ['has', 'point_count']], // exclude clusters
  layout: {
    'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']],
    'icon-size': 1,
    'icon-allow-overlap': true,
    'text-field': ['get', 'title'],
    'text-font': ['Open Sans Regular'],
    'text-size': 11,
    'text-offset': [0, 1.4],
    'text-anchor': 'top',
    'text-allow-overlap': false,
  },
  paint: {
    'text-color': '#0E0E0C',
    'text-halo-color': '#FAFAF7',
    'text-halo-width': 1.5,
  },
});

The 'icon-image' expression resolves at GPU paint time to a sprite key like rally-car-success, registered in 2.3.

Direction arrow

map.addLayer({
  id: nonSelectedDirectionId,
  source: nonSelectedId,
  type: 'symbol',
  filter: ['all', ['!', ['has', 'point_count']], ['get', 'direction']],
  layout: {
    'icon-image': ['concat', 'direction-', ['get', 'color']],
    'icon-size': 1,
    'icon-rotation-alignment': 'map',
    'icon-rotate': ['coalesce', ['get', 'course'], 0],
    'icon-allow-overlap': true,
  },
});

icon-rotation-alignment: 'map' rotates with the map (so the arrow points the device's actual heading regardless of map bearing).

Cluster bubbles

map.addLayer({
  id: clusterId,
  source: nonSelectedId,
  type: 'symbol',
  filter: ['has', 'point_count'],
  layout: {
    'icon-image': 'cluster-background', // a dedicated sprite from 2.3
    'icon-size': 1,
    'text-field': ['get', 'point_count_abbreviated'],
    'text-font': ['Open Sans Bold'],
    'text-size': 12,
  },
  paint: {
    'text-color': '#0E0E0C',
  },
});

'cluster-background' is a separate sprite (small dot or pill) registered in 2.3 — extend the registry if it isn't there yet.

Click handlers

map.on('click', nonSelectedSymbolId, (ev) => {
  const feature = ev.features?.[0];
  if (!feature) return;
  const deviceId = feature.properties?.deviceId as string | undefined;
  if (deviceId) usePositionStore.getState().selectDevice(deviceId);
});

map.on('click', clusterId, (ev) => {
  const feature = ev.features?.[0];
  if (!feature) return;
  const clusterId = feature.properties?.cluster_id as number | undefined;
  if (clusterId == null) return;
  const source = map.getSource(nonSelectedId) as maplibregl.GeoJSONSource;
  source.getClusterExpansionZoom(clusterId, (err, zoom) => {
    if (err) return;
    map.easeTo({
      center: (feature.geometry as GeoJSON.Point).coordinates as [number, number],
      zoom: zoom ?? map.getZoom() + 1,
    });
  });
});

// Cursor feedback.
map.on('mouseenter', nonSelectedSymbolId, () => (map.getCanvas().style.cursor = 'pointer'));
map.on('mouseleave', nonSelectedSymbolId, () => (map.getCanvas().style.cursor = ''));
// Same for clusterId and selectedSymbolId.

Click handlers are added in the setup effect; cleanup function removes them.

setData update path

useEffect(() => {
  const features: Feature[] = [];
  const selectedFeatures: Feature[] = [];
  for (const [deviceId, position] of latestByDevice) {
    const feat = buildPositionFeature(position, devices.get(deviceId));
    if (deviceId === selectedDeviceId) selectedFeatures.push(feat);
    else features.push(feat);
  }
  (map.getSource(nonSelectedId) as maplibregl.GeoJSONSource | undefined)?.setData({
    type: 'FeatureCollection',
    features,
  });
  (map.getSource(selectedId) as maplibregl.GeoJSONSource | undefined)?.setData({
    type: 'FeatureCollection',
    features: selectedFeatures,
  });
}, [latestByDevice, selectedDeviceId, devices]);

devices is a TanStack Query result holding the Directus-side device records (category, vehicle, crew name) joined client-side by deviceId. 2.5 fetches it on mount. If devices is empty (initial load), default category to 'default'.

Feature shape

function buildPositionFeature(p: PositionEntry, device?: Device): Feature {
  const category = mapCategoryToSprite(device?.kind ?? 'default');
  const status = inferStatus(p); // 'success' | 'error' | 'neutral' | 'info'
  return {
    type: 'Feature',
    geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
    properties: {
      deviceId: p.deviceId,
      category,
      color: status,
      course: p.course ?? 0,
      direction: p.course != null && (p.speed ?? 0) > 1, // arrow only when moving
      title: device?.label ?? p.deviceId.slice(0, 8),
    },
  };
}

function inferStatus(p: PositionEntry): 'success' | 'error' | 'neutral' | 'info' {
  // Phase 2 heuristic — refine in Phase 3.4 with real domain rules.
  if ((p.speed ?? 0) > 1) return 'success';     // moving
  return 'neutral';                              // stopped / idle
}

Phase 2's status inference is a placeholder; Phase 3.4 (per-device detail) will refine with offline-detection, panic-button awareness, etc.

<MapPositions> lives inside <MapView>

// In src/routes/_authed/monitor.tsx
<MapView>
  <MapPositions />
  <MapTrails />            {/* 2.6 */}
  <MapDefaultCamera />     {/* 2.8 */}
  <MapSelectedDevice />    {/* 2.8 */}
</MapView>

The mapReady gate (2.1) ensures these only mount when the style is loaded.

What this task does NOT include

  • Trail rendering. 2.6 — separate component on a separate source.
  • Camera follow. 2.8 — <MapSelectedDevice> watches selectedDeviceId and easeTos.
  • Per-device detail panel. Phase 3.4.
  • Selection persistence. selectedDeviceId resets on reload. Acceptable for v1.

Acceptance criteria

  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm build clean.
  • With the Rally Albania 2026 seed and synthetic positions published to Redis, /monitor shows the seeded devices as map markers within ~1s of subscribe.
  • Clicking a marker selects the device — visually it moves to the selected source's layers (potentially pulled out of a cluster).
  • Clicking a cluster zooms the map in to expand it.
  • Marker positions update on every position arrival via setData. No DOM markers.
  • Direction arrow rotates correctly with course and stays oriented to the map (rotates with the map).
  • Stopping a device (speed=0) hides its direction arrow.
  • Style swap (basemap change in 2.2) — markers re-appear after the swap. The mapReady gate handles the unmount/remount.

Risks / open questions

  • Cluster parameters. clusterMaxZoom: 14, clusterRadius: 50 is traccar's default. Rally density is much sparser than urban GPS fleet — clusters might be too aggressive at low zoom. Tune after seeing real positions; document the changed values in this task's Done section.
  • Title text overlap. With text-allow-overlap: false, MapLibre suppresses overlapping titles. At dense clusters this hides labels; the cluster-bubble layer's count compensates. Verify visually.
  • Selection on cluster expansion. Clicking a cluster zooms in but doesn't pre-select anything. The user clicks a marker after the cluster expands. Acceptable — auto-selecting one of the cluster's devices would be arbitrary.
  • devices query cache. TanStack Query default staleTime: 0 means devices refetch on every focus. For a list that changes daily, set staleTime: 5 * 60 * 1000 on this query specifically.

Done

(Filled in when the task lands.)