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:
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user