feat: task 2.5 MapPositions (clustered + selected sources)

- 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).
This commit is contained in:
2026-05-02 22:02:18 +02:00
parent 045f1cb376
commit 25dcde093a
8 changed files with 464 additions and 11 deletions
+53 -8
View File
@@ -1,4 +1,11 @@
import { authentication, createDirectus, rest } from '@directus/sdk';
import {
authentication,
createDirectus,
rest,
type AuthenticationClient,
type DirectusClient as SdkDirectusClient,
type RestClient,
} from '@directus/sdk';
import { useRuntimeConfig } from '@/config/context';
/**
@@ -16,14 +23,52 @@ export type DirectusUser = {
/**
* Typed Directus collection schema for the SDK.
*
* Will grow as the SPA starts reading TRM-specific collections in Phase 2+.
* Empty for now — Phase 1 only needs `/auth/*` and `/users/me`, which the
* SDK exposes outside the schema.
* Grows as the SPA starts reading TRM-specific collections. The shapes
* here intentionally cover only the fields the SPA actually consumes —
* a thin subset of the full Directus schema, kept minimal so the SDK's
* `readItems` calls compile without dragging in fields we don't use.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type Schema = {};
export type DeviceRow = {
id: string;
imei: string;
model: string | null;
serial_number: string | null;
notes: string | null;
date_created: string | null;
date_updated: string | null;
};
type DirectusClient = ReturnType<typeof buildClient>;
export type EventRow = {
id: string;
organization_id: string;
name: string;
slug: string;
discipline: 'rally' | 'time-trial' | 'regatta' | 'trail-run' | 'hike';
starts_at: string;
ends_at: string;
regulation_doc_url: string | null;
notes: string | null;
};
/**
* Directus SDK Schema. Each entry is an *array* of row types — that's
* what `RegularCollections<Schema>` filters on; a non-array value
* collapses to `never` and `readItems('devices', ...)` complains that
* `keyof Schema = never`.
*/
export type Schema = {
devices: DeviceRow[];
events: EventRow[];
};
/**
* Composed client type. Without the explicit annotation TS sometimes
* loses the `Schema` parameter through the chained `.with(...)` calls,
* which makes `readItems('devices', ...)` think `keyof Schema = never`.
* Spelling out the intersection here makes Schema flow through to every
* `directus.request(...)` call site.
*/
type DirectusClient = SdkDirectusClient<Schema> & RestClient<Schema> & AuthenticationClient<Schema>;
/**
* Resolve a (possibly relative) URL against the current page origin.
@@ -40,7 +85,7 @@ function toAbsoluteUrl(maybeRelative: string): string {
return new URL(maybeRelative, window.location.origin).toString();
}
function buildClient(directusUrl: string) {
function buildClient(directusUrl: string): DirectusClient {
return createDirectus<Schema>(toAbsoluteUrl(directusUrl))
.with(rest({ credentials: 'include' }))
.with(authentication('session', { credentials: 'include' }));
+1 -1
View File
@@ -1,6 +1,6 @@
export { AuthBootstrap } from './bootstrap';
export { getDirectus, initDirectusClient, useDirectus } from './client';
export type { DirectusUser, Schema } from './client';
export type { DeviceRow, DirectusUser, EventRow, Schema } from './client';
export { startCrossTabSync } from './cross-tab-sync';
export { useRequireAuth, useRequireRole } from './guard';
export { performLogout } from './logout';
+66
View File
@@ -0,0 +1,66 @@
import { readItems, type Query } from '@directus/sdk';
import { useQuery } from '@tanstack/react-query';
import { useDirectus, type DeviceRow, type Schema } from '@/auth';
export type Device = {
id: string;
imei: string;
model: string | null;
serial_number: string | null;
};
/**
* Fetch the list of devices visible to the current user. Directus's
* permission policies determine the result set (Phase 4 will refine
* with org-scoped filtering; for now all authenticated users see all
* devices).
*
* Cached for 5 minutes — devices change daily at most. Returned as a
* `Map<deviceId, Device>` for O(1) lookup by feature builders.
*/
export function useDevicesById(): {
data: Map<string, Device>;
isLoading: boolean;
isError: boolean;
} {
const directus = useDirectus();
const query = useQuery({
queryKey: ['devices', 'all'],
queryFn: async (): Promise<Device[]> => {
// Explicit Schema/collection generics — `readItems` doesn't infer
// Schema from the receiver's type at the call site, so without
// them TypeScript thinks `keyof Schema = never`.
const result = await directus.request(
readItems<Schema, 'devices', Query<Schema, DeviceRow>>('devices', {
fields: ['id', 'imei', 'model', 'serial_number'],
limit: -1,
}),
);
return result as Device[];
},
staleTime: 5 * 60 * 1000,
});
const data = new Map<string, Device>();
if (query.data) {
for (const d of query.data) {
data.set(d.id, d);
}
}
return {
data,
isLoading: query.isLoading,
isError: query.isError,
};
}
/**
* Build a human-readable label for a device. Prefers the model + last 4
* IMEI digits ("FMB920 #3458"), falls back to a deviceId prefix.
*/
export function deviceLabel(device: Device | undefined, deviceId: string): string {
if (!device) return deviceId.slice(0, 8);
const last4 = device.imei.slice(-4);
return device.model ? `${device.model} #${last4}` : `#${last4}`;
}
+306
View File
@@ -0,0 +1,306 @@
import { useEffect, useId } from 'react';
import type { Feature, FeatureCollection, Point } from 'geojson';
import type { GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl';
import { usePositionStore } from '@/live';
import { type PositionEntry } from '@/live/protocol';
import { getMap } from '@/map/core/map-view';
import { inferColor, mapCategoryToSprite } from '@/map/core/categories';
import { deviceLabel, useDevicesById, type Device } from '@/data/devices';
type FeatureProps = {
deviceId: string;
category: string;
color: 'success' | 'error' | 'neutral' | 'info';
course: number;
direction: boolean;
title: string;
};
const EMPTY_FC: FeatureCollection<Point, FeatureProps> = {
type: 'FeatureCollection',
features: [],
};
/**
* Live device positions on the map.
*
* Two GeoJSON sources: non-selected (clustered) + selected (always on
* top, never clustered). Five layers: symbol + direction arrow per
* source, plus a cluster-bubble layer on the non-selected source.
*
* Side-effect-only: returns `null`, manages MapLibre state via two
* `useEffect`s — one for setup/cleanup, one for `setData` on store changes.
*/
export function MapPositions() {
// Stable ids for sources/layers. useId returns ":r0:"-style strings;
// strip colons so they pass cleanly through MapLibre's id validators
// and any expression that concatenates them.
const baseId = useId().replace(/:/g, '');
const nonSelectedSourceId = `pos-ns-${baseId}`;
const selectedSourceId = `pos-sel-${baseId}`;
const nonSelectedSymbolId = `pos-ns-symbol-${baseId}`;
const nonSelectedDirectionId = `pos-ns-direction-${baseId}`;
const clusterId = `pos-cluster-${baseId}`;
const selectedSymbolId = `pos-sel-symbol-${baseId}`;
const selectedDirectionId = `pos-sel-direction-${baseId}`;
const latestByDevice = usePositionStore((s) => s.latestByDevice);
const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId);
const { data: devices } = useDevicesById();
// ---- Setup / teardown ------------------------------------------------
useEffect(() => {
const map = getMap();
// Sources
map.addSource(nonSelectedSourceId, {
type: 'geojson',
data: EMPTY_FC,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
});
map.addSource(selectedSourceId, {
type: 'geojson',
data: EMPTY_FC,
});
// Non-selected: device symbol + label
map.addLayer({
id: nonSelectedSymbolId,
source: nonSelectedSourceId,
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,
},
});
// Non-selected: direction arrow
map.addLayer({
id: nonSelectedDirectionId,
source: nonSelectedSourceId,
type: 'symbol',
filter: ['all', ['!', ['has', 'point_count']], ['get', 'direction']],
layout: {
'icon-image': ['concat', 'direction-', ['get', 'color']],
'icon-size': 0.8,
'icon-rotation-alignment': 'map',
'icon-rotate': ['coalesce', ['get', 'course'], 0],
'icon-offset': [0, -20], // place the arrow above the marker
'icon-allow-overlap': true,
},
});
// Cluster bubbles
map.addLayer({
id: clusterId,
source: nonSelectedSourceId,
type: 'symbol',
filter: ['has', 'point_count'],
layout: {
'icon-image': 'default-neutral',
'icon-size': 1,
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['Open Sans Bold'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#FAFAF7',
},
});
// Selected: same shape as non-selected but on the always-on-top source
map.addLayer({
id: selectedSymbolId,
source: selectedSourceId,
type: 'symbol',
layout: {
'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']],
'icon-size': 1.15, // slightly larger so the selection is visible
'icon-allow-overlap': true,
'text-field': ['get', 'title'],
'text-font': ['Open Sans Bold'],
'text-size': 12,
'text-offset': [0, 1.5],
'text-anchor': 'top',
'text-allow-overlap': true,
},
paint: {
'text-color': '#0E0E0C',
'text-halo-color': '#FAFAF7',
'text-halo-width': 2,
},
});
map.addLayer({
id: selectedDirectionId,
source: selectedSourceId,
type: 'symbol',
filter: ['get', 'direction'],
layout: {
'icon-image': ['concat', 'direction-', ['get', 'color']],
'icon-size': 0.9,
'icon-rotation-alignment': 'map',
'icon-rotate': ['coalesce', ['get', 'course'], 0],
'icon-offset': [0, -22],
'icon-allow-overlap': true,
},
});
// Click handlers ----------------------------------------------------
const onMarkerClick = (ev: MapLayerMouseEvent): void => {
const feature = ev.features?.[0];
if (!feature) return;
const props = feature.properties as FeatureProps | null;
if (props?.deviceId) {
usePositionStore.getState().selectDevice(props.deviceId);
}
};
const onClusterClick = (ev: MapLayerMouseEvent): void => {
const feature = ev.features?.[0];
if (!feature) return;
const cId = feature.properties?.cluster_id as number | undefined;
if (cId == null) return;
const source = map.getSource(nonSelectedSourceId) as GeoJSONSource | undefined;
if (!source) return;
void source.getClusterExpansionZoom(cId).then((zoom) => {
const point = (feature.geometry as Point).coordinates as [number, number];
map.easeTo({
center: point,
zoom: zoom ?? map.getZoom() + 1,
duration: 400,
});
});
};
const setPointer = (): void => {
map.getCanvas().style.cursor = 'pointer';
};
const clearPointer = (): void => {
map.getCanvas().style.cursor = '';
};
map.on('click', nonSelectedSymbolId, onMarkerClick);
map.on('click', selectedSymbolId, onMarkerClick);
map.on('click', clusterId, onClusterClick);
for (const id of [nonSelectedSymbolId, selectedSymbolId, clusterId]) {
map.on('mouseenter', id, setPointer);
map.on('mouseleave', id, clearPointer);
}
return () => {
map.off('click', nonSelectedSymbolId, onMarkerClick);
map.off('click', selectedSymbolId, onMarkerClick);
map.off('click', clusterId, onClusterClick);
for (const id of [nonSelectedSymbolId, selectedSymbolId, clusterId]) {
map.off('mouseenter', id, setPointer);
map.off('mouseleave', id, clearPointer);
}
// Tear down layers + sources in reverse order.
for (const layerId of [
selectedDirectionId,
selectedSymbolId,
clusterId,
nonSelectedDirectionId,
nonSelectedSymbolId,
]) {
if (map.getLayer(layerId)) map.removeLayer(layerId);
}
if (map.getSource(selectedSourceId)) map.removeSource(selectedSourceId);
if (map.getSource(nonSelectedSourceId)) map.removeSource(nonSelectedSourceId);
};
// Setup runs once per mount; the mapReady gate (2.1) ensures the map
// is ready when this fires. Listing dependencies would re-run setup
// on unrelated state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- Updates ---------------------------------------------------------
useEffect(() => {
const map = getMap();
const { nonSelected, selected } = buildFeatureCollections({
latestByDevice,
selectedDeviceId,
devices,
});
const ns = map.getSource(nonSelectedSourceId) as GeoJSONSource | undefined;
const sel = map.getSource(selectedSourceId) as GeoJSONSource | undefined;
ns?.setData(nonSelected);
sel?.setData(selected);
}, [latestByDevice, selectedDeviceId, devices, nonSelectedSourceId, selectedSourceId]);
return null;
}
// ---- Feature builder ----------------------------------------------------
function buildFeatureCollections(opts: {
latestByDevice: Map<string, PositionEntry>;
selectedDeviceId: string | null;
devices: Map<string, Device>;
}): {
nonSelected: FeatureCollection<Point, FeatureProps>;
selected: FeatureCollection<Point, FeatureProps>;
} {
const nonSelectedFeatures: Feature<Point, FeatureProps>[] = [];
const selectedFeatures: Feature<Point, FeatureProps>[] = [];
for (const [deviceId, position] of opts.latestByDevice) {
const isSelected = deviceId === opts.selectedDeviceId;
const device = opts.devices.get(deviceId);
const feat = buildPositionFeature(position, device, isSelected);
if (isSelected) selectedFeatures.push(feat);
else nonSelectedFeatures.push(feat);
}
return {
nonSelected: { type: 'FeatureCollection', features: nonSelectedFeatures },
selected: { type: 'FeatureCollection', features: selectedFeatures },
};
}
function buildPositionFeature(
p: PositionEntry,
device: Device | undefined,
isSelected: boolean,
): Feature<Point, FeatureProps> {
// Phase 1 schema doesn't carry `kind` on devices; map all to 'default'
// for now. When the schema gains a kind/category column (Phase 2 of
// directus or earlier), thread it through.
const category = mapCategoryToSprite(undefined);
const color = inferColor({ speed: p.speed ?? null, isSelected });
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: {
deviceId: p.deviceId,
category,
color,
course: p.course ?? 0,
// Show the direction arrow only when the device is moving (>1 m/s).
direction: p.course != null && (p.speed ?? 0) > 1,
title: deviceLabel(device, p.deviceId),
},
};
}
+2
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router';
import { BasemapSwitcher } from '@/map/core/basemap-switcher';
import { MapView } from '@/map/core/map-view';
import { MapPositions } from '@/map/layers/map-positions';
export const Route = createFileRoute('/_authed/monitor')({
component: MonitorPage,
@@ -22,6 +23,7 @@ function MonitorPage() {
to <MapView>'s wrapper.
*/}
<BasemapSwitcher />
<MapPositions />
</MapView>
</div>
);