# 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 (Filled in when the task lands.)