# 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`** — `` 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.
### `` lives inside ``
```tsx
// In src/routes/_authed/monitor.tsx
{/* 2.6 */}
{/* 2.8 */}
{/* 2.8 */}
```
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 — `` 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` 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`** — `` 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 `` as a child of ``.
**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` 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 & RestClient & AuthenticationClient` instead of `ReturnType`. 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>('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. `` 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`.