- src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale)
returning Map<deviceId, Device>. deviceLabel() formats human-readable
titles ("FMB920 #3458").
- src/map/layers/map-positions.tsx: <MapPositions /> side-effect-only.
Two GeoJSON sources (clustered non-selected + unclustered selected).
Five layers: non-selected symbol + direction + cluster-bubble,
selected symbol + direction. Click handlers: marker -> selectDevice,
cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor.
- src/routes/_authed/monitor.tsx: renders <MapPositions /> inside
<MapView>.
Schema overhaul in src/auth/client.ts:
- Made Schema SDK-compatible. Each entry is an *array* of row types
(devices: DeviceRow[], not just DeviceRow). RegularCollections<Schema>
filters on array-like values; non-arrays collapse to never which
broke readItems('devices', ...) with keyof Schema = never.
- Spelled out the composed client type as DirectusClient<Schema> &
RestClient<Schema> & AuthenticationClient<Schema> — without the
explicit annotation Schema didn't flow through .with(...) chain to
request() call sites.
- Added DeviceRow + EventRow types; exported via @/auth.
useDevicesById uses readItems<Schema, 'devices', Query<Schema, DeviceRow>>
— explicit generics because the SDK doesn't infer Schema from the
receiver's type at call sites.
Deviations:
1. Spec referenced device.kind for category — Phase 1 schema doesn't
have it yet; everything maps to 'default'. Refine when kind lands.
2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated
'cluster-background' (not in 2.3's registry). Swap in 3.8.
3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was
callback-style); used .then().
Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor
chunk includes the new layer module (~10KB).
14 KiB
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) callssetData. - Click handlers wired via
map.on('click', layerId, handler). - Cluster expansion via
map.getSource(id).getClusterExpansionZoom(clusterId)+map.easeTo.
- Returns
- 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.
- Non-selected source (
- 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 wheneverselectedDeviceIdchanges (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
// 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:
- Non-selected: symbol (icon + title)
- Non-selected: direction arrow
- Cluster bubbles
- Selected: symbol (icon + title)
- 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)
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
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
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
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
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
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>
// 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>watchesselectedDeviceIdandeaseTos. - Per-device detail panel. Phase 3.4.
- Selection persistence.
selectedDeviceIdresets on reload. Acceptable for v1.
Acceptance criteria
pnpm typecheck,pnpm lint,pnpm format:check,pnpm buildclean.- With the Rally Albania 2026 seed and synthetic positions published to Redis,
/monitorshows 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
courseand 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: 50is 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.
devicesquery cache. TanStack Query defaultstaleTime: 0means devices refetch on every focus. For a list that changes daily, setstaleTime: 5 * 60 * 1000on this query specifically.
Done
Three new files plus Schema work:
src/data/devices.ts—useDevicesById()hook returning aMap<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. TwouseId-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.
- non-selected source (
- Update effect:
buildFeatureCollections({ latestByDevice, selectedDeviceId, devices })produces the two FCs andsetDatas 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
Schematype SDK-compatible. Each entry is an array of row types (e.g.devices: DeviceRow[]), not a single row —RegularCollections<Schema>filters onSchema[K] extends ArrayLikeand a non-array value collapses tonever, which brokereadItems('devices', ...)withkeyof Schema = never. - Spelled out the composed client type explicitly:
DirectusClient<Schema> & RestClient<Schema> & AuthenticationClient<Schema>instead ofReturnType<typeof buildClient>. Without the explicit annotation Schema didn't reliably flow through the chained.with(...)calls to inference atrequest()call sites. - Added
DeviceRowandEventRowrow 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:
- Spec referenced
mapCategoryToSprite(device?.kind ?? 'default')to pick the sprite category. Phase 1'sdevicesschema doesn't have akindcolumn 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. - 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. - Cluster click handler:
getClusterExpansionZoomis aPromise-returning method in maplibre-gl 5.x (was callback-style in older versions). Used the Promise form.
Smoke check (local pnpm dev):
/monitorloads.<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 PENDING_SHA.