87a738313e
- 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.
267 lines
10 KiB
Markdown
267 lines
10 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
|
||
|
||
(Filled in when the task lands.)
|