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

267 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```ts
// 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)
```ts
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
```ts
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
```ts
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
```ts
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
```ts
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
```ts
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>`
```tsx
// 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 `easeTo`s.
- **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.)