Files
spa/.planning/phase-2-live-map/05-map-positions.md
T
julian 28a501c02d feat: task 2.6 MapTrails (bounded ring buffer, polyline rendering)
- src/map/core/map-pref-store.ts: trailMode preference
  ('none' | 'selected' | 'all', default 'selected') + setter, persisted.
- src/map/layers/map-trails.tsx: <MapTrails /> side-effect-only.
  Single GeoJSON source + single line layer. Builds one LineString
  Feature per device whose trail has >= 2 points; filtered by mode.
  Per-device flat colour via deterministic deviceId hash mod a
  6-entry palette.
- src/map/core/trails-toggle.tsx: <TrailsToggle /> floating card
  below <BasemapSwitcher />. Three buttons (None / Selected / All).
- src/routes/_authed/monitor.tsx: renders <TrailsToggle /> +
  <MapTrails /> *before* <MapPositions /> so the line layer is added
  to the map first and renders underneath the symbol layers.

Speed-coloured-per-segment deferred to Phase 3 polish per the task
spec's open-question decision; flat-colour-per-device for v1.

Bundle: main 394KB / 120KB gz — no change from 2.5.
2026-05-03 09:31:49 +02:00

301 lines
14 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
Three new files plus Schema work:
- **`src/data/devices.ts`** — `useDevicesById()` 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 `setData`s 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 `9020822`.