feat: task 2.6 MapTrails (bounded ring buffer, polyline rendering)

- src/map/core/map-pref-store.ts: trailMode preference
  ('none' | 'selected' | 'all', default 'selected') + setter, persisted.
- src/map/layers/map-trails.tsx: <MapTrails /> side-effect-only.
  Single GeoJSON source + single line layer. Builds one LineString
  Feature per device whose trail has >= 2 points; filtered by mode.
  Per-device flat colour via deterministic deviceId hash mod a
  6-entry palette.
- src/map/core/trails-toggle.tsx: <TrailsToggle /> floating card
  below <BasemapSwitcher />. Three buttons (None / Selected / All).
- src/routes/_authed/monitor.tsx: renders <TrailsToggle /> +
  <MapTrails /> *before* <MapPositions /> so the line layer is added
  to the map first and renders underneath the symbol layers.

Speed-coloured-per-segment deferred to Phase 3 polish per the task
spec's open-question decision; flat-colour-per-device for v1.

Bundle: main 394KB / 120KB gz — no change from 2.5.
This commit is contained in:
2026-05-02 22:17:10 +02:00
parent d56c5e0e40
commit 28a501c02d
6 changed files with 226 additions and 10 deletions
@@ -276,7 +276,7 @@ Three new files plus Schema work:
**Schema overhaul** in `src/auth/client.ts`: **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`. - 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. - 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`. - Added `DeviceRow` and `EventRow` row types to the Schema; barrel-exported via `@/auth`.
@@ -289,6 +289,7 @@ Three new files plus Schema work:
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. 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`):** **Smoke check (local `pnpm dev`):**
- `/monitor` loads. `<MapPositions />` mounts. Position store is empty until the event picker (2.7) lands. - `/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). - 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. - Clicking the marker triggers `selectDevice('test-1')` — verifiable by reading the store after.
+19 -1
View File
@@ -153,4 +153,22 @@ If operators want a longer-trail mode: a slider in the prefs (`50 / 200 / 500 /
## Done ## Done
(Filled in when the task lands.) - **`src/map/core/map-pref-store.ts`** — extended with `trailMode: TrailMode` (`'none' | 'selected' | 'all'`, default `'selected'`) and `setTrailMode`. Persisted via the existing zustand `persist` middleware on `'trm-map-prefs'`.
- **`src/map/layers/map-trails.tsx`** — `<MapTrails />` side-effect-only component. Single GeoJSON source + single `'line'` layer. Two-effect pattern (setup + setData on store changes). Reads `trailsByDevice`, `selectedDeviceId`, `trailMode`. Builds one `LineString` Feature per device whose trail has ≥ 2 points; filtered by mode (`none` → empty, `selected` → just the selected device, `all` → every device).
- **`src/map/core/trails-toggle.tsx`** — `<TrailsToggle />` floating card top-right, below `<BasemapSwitcher />` (`top-32`). Three buttons (None / Selected / All); active one highlighted via `bg-accent`. Has a small "Trails" label up top.
- **`src/routes/_authed/monitor.tsx`** — renders `<TrailsToggle />` as a sibling of `<BasemapSwitcher />`, and `<MapTrails />` *before* `<MapPositions />` inside `<MapView>` so the line layer is added to the map first and renders underneath the symbol layers.
**Per-device colouring:** flat colour from a 6-entry palette (`#2563C8 / #2E8C4A / #6B46C1 / #188C8A / #C9296F / #5A5A53`) keyed by a deterministic hash of the deviceId. Same device = same colour across reloads. Distinct enough that two trails don't blend. Speed-coloured-per-segment deferred to a Phase 3 polish task per the original task spec's open-question decision.
**Smoke check (local `pnpm dev`):**
- `/monitor` shows the trails toggle below the basemap switcher.
- Default mode is "Selected" → no trails visible until a device is selected.
- Synthetic positions (push 2+ positions for the same deviceId via the console — `usePositionStore.getState().applyPositions(...)`) and the trail polyline appears immediately.
- Toggling to "All" shows trails for every device with ≥ 2 points.
- Toggling to "None" hides trails.
- Switching basemap → trails reappear after the swap (mapReady gate handles the remount).
- Reloading the page: trail mode persists.
**Bundle:** main bundle 394KB / 120KB gz — no change from 2.5. Trails layer is small.
Landed in `PENDING_SHA`.
+8 -2
View File
@@ -2,26 +2,32 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { DEFAULT_BASEMAP_ID, type BasemapId } from './styles'; import { DEFAULT_BASEMAP_ID, type BasemapId } from './styles';
export type TrailMode = 'none' | 'selected' | 'all';
type MapPrefState = { type MapPrefState = {
basemapId: BasemapId; basemapId: BasemapId;
trailMode: TrailMode;
}; };
type MapPrefActions = { type MapPrefActions = {
setBasemap: (id: BasemapId) => void; setBasemap: (id: BasemapId) => void;
setTrailMode: (mode: TrailMode) => void;
}; };
type Store = MapPrefState & MapPrefActions; type Store = MapPrefState & MapPrefActions;
/** /**
* Persists user preferences for the map: which basemap is selected, * Persists user preferences for the map: which basemap is selected,
* eventually whether trails are visible, follow mode, etc. Survives * which trail mode is on, eventually follow mode + per-event camera
* reloads via localStorage. * defaults. Survives reloads via localStorage.
*/ */
export const useMapPrefStore = create<Store>()( export const useMapPrefStore = create<Store>()(
persist( persist(
(set) => ({ (set) => ({
basemapId: DEFAULT_BASEMAP_ID, basemapId: DEFAULT_BASEMAP_ID,
trailMode: 'selected',
setBasemap: (id) => set({ basemapId: id }), setBasemap: (id) => set({ basemapId: id }),
setTrailMode: (mode) => set({ trailMode: mode }),
}), }),
{ {
name: 'trm-map-prefs', name: 'trm-map-prefs',
+41
View File
@@ -0,0 +1,41 @@
import { cn } from '@/lib/utils';
import { useMapPrefStore, type TrailMode } from './map-pref-store';
const MODES: { id: TrailMode; label: string }[] = [
{ id: 'none', label: 'None' },
{ id: 'selected', label: 'Selected' },
{ id: 'all', label: 'All' },
];
/**
* 3-state trail-mode toggle. Renders as a small floating card top-right
* of `<MapView />`, below `<BasemapSwitcher />`.
*/
export function TrailsToggle() {
const current = useMapPrefStore((s) => s.trailMode);
const setTrailMode = useMapPrefStore((s) => s.setTrailMode);
return (
<div className="absolute top-32 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden z-10">
<div className="px-3 py-1 text-xs uppercase tracking-wide text-muted-foreground border-b border-border">
Trails
</div>
{MODES.map((m) => {
const isActive = current === m.id;
return (
<button
key={m.id}
type="button"
onClick={() => setTrailMode(m.id)}
className={cn(
'block w-full text-left px-3 py-1.5 text-sm transition-colors',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground',
)}
>
{m.label}
</button>
);
})}
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useId } from 'react';
import type { Feature, FeatureCollection, LineString } from 'geojson';
import type { GeoJSONSource } from 'maplibre-gl';
import { usePositionStore } from '@/live';
import { type PositionEntry } from '@/live/protocol';
import { useMapPrefStore, type TrailMode } from '@/map/core/map-pref-store';
import { getMap } from '@/map/core/map-view';
import { useDevicesById } from '@/data/devices';
type TrailFeatureProps = {
deviceId: string;
color: string;
};
const EMPTY_FC: FeatureCollection<LineString, TrailFeatureProps> = {
type: 'FeatureCollection',
features: [],
};
/**
* Per-device trail polylines.
*
* Renders the bounded ring buffer (`MAX_TRAIL_LENGTH` points per device,
* 200 by default — see `src/live/constants.ts`). Mode pref drives which
* devices' trails are visible:
*
* - `none` — no trails.
* - `selected` — only the selected device's trail (default).
* - `all` — every device's trail.
*
* Mounted *before* `<MapPositions />` in the component tree so the line
* layer is added to the map first; later-added symbol layers (markers
* and direction arrows) paint on top. Style swaps wipe both; the
* mapReady gate handles the unmount/remount.
*/
export function MapTrails() {
const baseId = useId().replace(/:/g, '');
const sourceId = `trails-${baseId}`;
const lineId = `trails-line-${baseId}`;
const trailsByDevice = usePositionStore((s) => s.trailsByDevice);
const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId);
const trailMode = useMapPrefStore((s) => s.trailMode);
const { data: devices } = useDevicesById();
// ---- Setup / teardown ------------------------------------------------
useEffect(() => {
const map = getMap();
map.addSource(sourceId, {
type: 'geojson',
data: EMPTY_FC,
});
map.addLayer({
id: lineId,
source: sourceId,
type: 'line',
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
'line-opacity': 0.85,
},
});
return () => {
if (map.getLayer(lineId)) map.removeLayer(lineId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
};
// Setup runs once per mount; mapReady gate guarantees the style is
// loaded by the time this fires.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- Updates ---------------------------------------------------------
useEffect(() => {
const map = getMap();
const fc = buildTrailCollection({
trailsByDevice,
selectedDeviceId,
trailMode,
deviceIds: devices,
});
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
source?.setData(fc);
}, [trailsByDevice, selectedDeviceId, trailMode, devices, sourceId]);
return null;
}
// ---- Feature builder ----------------------------------------------------
const COLOR_PALETTE = ['#2563C8', '#2E8C4A', '#6B46C1', '#188C8A', '#C9296F', '#5A5A53'];
function deviceColor(deviceId: string): string {
let h = 0;
for (let i = 0; i < deviceId.length; i++) {
h = (h * 31 + deviceId.charCodeAt(i)) | 0;
}
const idx = Math.abs(h) % COLOR_PALETTE.length;
return COLOR_PALETTE[idx]!;
}
function buildTrailCollection(opts: {
trailsByDevice: Map<string, PositionEntry[]>;
selectedDeviceId: string | null;
trailMode: TrailMode;
deviceIds: Map<string, unknown>;
}): FeatureCollection<LineString, TrailFeatureProps> {
const features: Feature<LineString, TrailFeatureProps>[] = [];
if (opts.trailMode === 'none') {
return { type: 'FeatureCollection', features };
}
const candidates: string[] = [];
if (opts.trailMode === 'selected') {
if (opts.selectedDeviceId) candidates.push(opts.selectedDeviceId);
} else {
// 'all'
for (const deviceId of opts.trailsByDevice.keys()) candidates.push(deviceId);
}
for (const deviceId of candidates) {
const points = opts.trailsByDevice.get(deviceId);
if (!points || points.length < 2) continue;
features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: points.map((p) => [p.lon, p.lat]),
},
properties: {
deviceId,
color: deviceColor(deviceId),
},
});
}
return { type: 'FeatureCollection', features };
}
+11 -6
View File
@@ -1,7 +1,9 @@
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 { TrailsToggle } from '@/map/core/trails-toggle';
import { MapPositions } from '@/map/layers/map-positions'; import { MapPositions } from '@/map/layers/map-positions';
import { MapTrails } from '@/map/layers/map-trails';
export const Route = createFileRoute('/_authed/monitor')({ export const Route = createFileRoute('/_authed/monitor')({
component: MonitorPage, component: MonitorPage,
@@ -15,14 +17,17 @@ function MonitorPage() {
<div className="relative h-[calc(100vh-0rem)] w-full"> <div className="relative h-[calc(100vh-0rem)] w-full">
<MapView> <MapView>
{/* {/*
BasemapSwitcher renders inside <MapView> so it remounts after UI controls. Floating cards positioned absolutely inside
every style swap, ensuring the persisted preference is re-applied <MapView>'s wrapper.
if the user does something funny (e.g. force-reloads style via
devtools). It's also a sibling of the map canvas, not on top of
it visually — the absolute-positioned card is positioned relative
to <MapView>'s wrapper.
*/} */}
<BasemapSwitcher /> <BasemapSwitcher />
<TrailsToggle />
{/*
Layer order matters — MapLibre paints in addLayer() call order.
<MapTrails /> mounts first so its line layer renders below the
symbol layers added by <MapPositions />.
*/}
<MapTrails />
<MapPositions /> <MapPositions />
</MapView> </MapView>
</div> </div>