28a501c02d
- 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.
301 lines
14 KiB
Markdown
301 lines
14 KiB
Markdown
# 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`.
|