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:
@@ -381,6 +381,7 @@ Eight files under `src/live/`:
|
|||||||
3. `onPosition` returns an unsubscribe function (Set-add + delete). Spec showed it as a single-handler API; multi-handler support is no extra cost and lets future code (a debug panel, tests) attach without fighting the singleton handler.
|
3. `onPosition` returns an unsubscribe function (Set-add + delete). Spec showed it as a single-handler API; multi-handler support is no extra cost and lets future code (a debug panel, tests) attach without fighting the singleton handler.
|
||||||
|
|
||||||
**Smoke check (local `pnpm dev`):**
|
**Smoke check (local `pnpm dev`):**
|
||||||
|
|
||||||
- App boots; no WS connection until login.
|
- App boots; no WS connection until login.
|
||||||
- After login: `useConnectionStore.getState()` shows `status: 'connecting'` then either `'connected'` (if Phase 1.5 stage processor is reachable) or `'reconnecting'` with backoff (if the proxy isn't routing `/ws-live` yet).
|
- After login: `useConnectionStore.getState()` shows `status: 'connecting'` then either `'connected'` (if Phase 1.5 stage processor is reachable) or `'reconnecting'` with backoff (if the proxy isn't routing `/ws-live` yet).
|
||||||
- `usePositionStore.getState()` shows the empty initial state — `activeEventId: null`, empty Maps.
|
- `usePositionStore.getState()` shows the empty initial state — `activeEventId: null`, empty Maps.
|
||||||
|
|||||||
@@ -263,4 +263,37 @@ The `mapReady` gate (2.1) ensures these only mount when the style is loaded.
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
(Filled in when the task lands.)
|
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 `PENDING_SHA`.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ When Phase 2 is done:
|
|||||||
| 2.2 | [Tile-source switcher](./02-tile-source-switcher.md) | 🟩 |
|
| 2.2 | [Tile-source switcher](./02-tile-source-switcher.md) | 🟩 |
|
||||||
| 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | 🟩 |
|
| 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | 🟩 |
|
||||||
| 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | 🟩 |
|
| 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | 🟩 |
|
||||||
| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | ⬜ |
|
| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 |
|
||||||
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ |
|
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ |
|
||||||
| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ |
|
| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ |
|
||||||
| 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ |
|
| 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ |
|
||||||
|
|||||||
+53
-8
@@ -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';
|
import { useRuntimeConfig } from '@/config/context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,14 +23,52 @@ export type DirectusUser = {
|
|||||||
/**
|
/**
|
||||||
* Typed Directus collection schema for the SDK.
|
* Typed Directus collection schema for the SDK.
|
||||||
*
|
*
|
||||||
* Will grow as the SPA starts reading TRM-specific collections in Phase 2+.
|
* Grows as the SPA starts reading TRM-specific collections. The shapes
|
||||||
* Empty for now — Phase 1 only needs `/auth/*` and `/users/me`, which the
|
* here intentionally cover only the fields the SPA actually consumes —
|
||||||
* SDK exposes outside the schema.
|
* 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 DeviceRow = {
|
||||||
export type Schema = {};
|
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.
|
* 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();
|
return new URL(maybeRelative, window.location.origin).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClient(directusUrl: string) {
|
function buildClient(directusUrl: string): DirectusClient {
|
||||||
return createDirectus<Schema>(toAbsoluteUrl(directusUrl))
|
return createDirectus<Schema>(toAbsoluteUrl(directusUrl))
|
||||||
.with(rest({ credentials: 'include' }))
|
.with(rest({ credentials: 'include' }))
|
||||||
.with(authentication('session', { credentials: 'include' }));
|
.with(authentication('session', { credentials: 'include' }));
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
export { AuthBootstrap } from './bootstrap';
|
export { AuthBootstrap } from './bootstrap';
|
||||||
export { getDirectus, initDirectusClient, useDirectus } from './client';
|
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 { startCrossTabSync } from './cross-tab-sync';
|
||||||
export { useRequireAuth, useRequireRole } from './guard';
|
export { useRequireAuth, useRequireRole } from './guard';
|
||||||
export { performLogout } from './logout';
|
export { performLogout } from './logout';
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { BasemapSwitcher } from '@/map/core/basemap-switcher';
|
import { BasemapSwitcher } from '@/map/core/basemap-switcher';
|
||||||
import { MapView } from '@/map/core/map-view';
|
import { MapView } from '@/map/core/map-view';
|
||||||
|
import { MapPositions } from '@/map/layers/map-positions';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/monitor')({
|
export const Route = createFileRoute('/_authed/monitor')({
|
||||||
component: MonitorPage,
|
component: MonitorPage,
|
||||||
@@ -22,6 +23,7 @@ function MonitorPage() {
|
|||||||
to <MapView>'s wrapper.
|
to <MapView>'s wrapper.
|
||||||
*/}
|
*/}
|
||||||
<BasemapSwitcher />
|
<BasemapSwitcher />
|
||||||
|
<MapPositions />
|
||||||
</MapView>
|
</MapView>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user