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

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.)