Files
spa/.planning/phase-2-live-map/05-map-positions.md
T
julian 25dcde093a feat: task 2.5 MapPositions (clustered + selected sources)
- src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale)
  returning Map<deviceId, Device>. deviceLabel() formats human-readable
  titles ("FMB920 #3458").
- src/map/layers/map-positions.tsx: <MapPositions /> side-effect-only.
  Two GeoJSON sources (clustered non-selected + unclustered selected).
  Five layers: non-selected symbol + direction + cluster-bubble,
  selected symbol + direction. Click handlers: marker -> selectDevice,
  cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor.
- src/routes/_authed/monitor.tsx: renders <MapPositions /> inside
  <MapView>.

Schema overhaul in src/auth/client.ts:
- Made Schema SDK-compatible. Each entry is an *array* of row types
  (devices: DeviceRow[], not just DeviceRow). RegularCollections<Schema>
  filters on array-like values; non-arrays collapse to never which
  broke readItems('devices', ...) with keyof Schema = never.
- Spelled out the composed client type as DirectusClient<Schema> &
  RestClient<Schema> & AuthenticationClient<Schema> — without the
  explicit annotation Schema didn't flow through .with(...) chain to
  request() call sites.
- Added DeviceRow + EventRow types; exported via @/auth.

useDevicesById uses readItems<Schema, 'devices', Query<Schema, DeviceRow>>
— explicit generics because the SDK doesn't infer Schema from the
receiver's type at call sites.

Deviations:
1. Spec referenced device.kind for category — Phase 1 schema doesn't
   have it yet; everything maps to 'default'. Refine when kind lands.
2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated
   'cluster-background' (not in 2.3's registry). Swap in 3.8.
3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was
   callback-style); used .then().

Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor
chunk includes the new layer module (~10KB).
2026-05-03 09:31:09 +02:00

14 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

Three new files plus Schema work:

  • src/data/devices.tsuseDevicesById() hook returning a Map<deviceId, Device> plus loading/error flags. TanStack Query with 5-min stale-time. deviceLabel(device, deviceId) produces a human-readable title (e.g. "FMB920 #3458" from model + last-4 IMEI).
  • src/map/layers/map-positions.tsx<MapPositions /> side-effect-only component. Two useId-derived (colon-stripped) source ids + five layer ids. Setup effect adds:
    • non-selected source (cluster: true, clusterMaxZoom: 14, clusterRadius: 50) with symbol + direction-arrow + cluster-bubble layers.
    • selected source (no clustering, always on top) with symbol + direction-arrow layers.
    • Click handlers for marker click → selectDevice(deviceId), cluster click → getClusterExpansionZoom + easeTo. Hover handlers swap the cursor.
    • Cleanup removes all layers and sources in reverse order.
  • Update effect: buildFeatureCollections({ latestByDevice, selectedDeviceId, devices }) produces the two FCs and setDatas both sources. Selected goes onto its own source so the always-on-top symbol layers handle the z-ordering without manual layer reordering.
  • src/routes/_authed/monitor.tsx — renders <MapPositions /> as a child of <MapView>.

Schema overhaul in src/auth/client.ts:

  • Made Schema type SDK-compatible. Each entry is an array of row types (e.g. devices: DeviceRow[]), not a single row — RegularCollections<Schema> filters on Schema[K] extends ArrayLike and a non-array value collapses to never, which broke readItems('devices', ...) with keyof Schema = never.
  • Spelled out the composed client type explicitly: DirectusClient<Schema> & RestClient<Schema> & AuthenticationClient<Schema> instead of ReturnType<typeof buildClient>. Without the explicit annotation Schema didn't reliably flow through the chained .with(...) calls to inference at request() call sites.
  • Added DeviceRow and EventRow row types to the Schema; barrel-exported via @/auth.

useDevicesById calls readItems<Schema, 'devices', Query<Schema, DeviceRow>>('devices', ...) — the SDK doesn't infer Schema from the receiver's type, so explicit type parameters are required.

Deviations from spec:

  1. Spec referenced mapCategoryToSprite(device?.kind ?? 'default') to pick the sprite category. Phase 1's devices schema doesn't have a kind column yet, so the feature builder maps everything to 'default' for v1. When the schema gains a kind/category (Phase 2 of directus or earlier), thread it through here.
  2. Cluster-bubble icon: spec sketched 'cluster-background' as a separate sprite. The 2.3 sprite registry didn't include it; reused 'default-neutral' as the bubble plate (it's a circle on a black background, which reads fine as a cluster). When 3.8 lands, swap to a dedicated cluster sprite.
  3. Cluster click handler: getClusterExpansionZoom is a Promise-returning method in maplibre-gl 5.x (was callback-style in older versions). Used the Promise form.

Smoke check (local pnpm dev):

  • /monitor loads. <MapPositions /> mounts. Position store is empty until the event picker (2.7) lands.
  • In the browser console: usePositionStore.getState().applyPositions([{deviceId: 'test-1', lat: 41.327, lon: 19.819, ts: Date.now()}]) produces a marker on the map at the seeded coordinates within ~16ms (one rAF flush).
  • Clicking the marker triggers selectDevice('test-1') — verifiable by reading the store after.
  • Switching basemap (2.2) → mapReady gate fires → markers reappear after the swap.

Bundle: /monitor route chunk is now ~10KB raw (the rest of MapLibre is in its own chunk). Main bundle 394KB / 120KB gzipped — small bump from 2.4's 393KB. No measurable impact.

Landed in PENDING_SHA.