diff --git a/.planning/phase-2-live-map/05-map-positions.md b/.planning/phase-2-live-map/05-map-positions.md index 972568e..3e344c7 100644 --- a/.planning/phase-2-live-map/05-map-positions.md +++ b/.planning/phase-2-live-map/05-map-positions.md @@ -276,7 +276,7 @@ Three new files plus Schema work: **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` 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` 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 & RestClient & AuthenticationClient` instead of `ReturnType`. 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`. @@ -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. **Smoke check (local `pnpm dev`):** + - `/monitor` loads. `` 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. diff --git a/.planning/phase-2-live-map/06-map-trails.md b/.planning/phase-2-live-map/06-map-trails.md index 01490b6..2708da2 100644 --- a/.planning/phase-2-live-map/06-map-trails.md +++ b/.planning/phase-2-live-map/06-map-trails.md @@ -153,4 +153,22 @@ If operators want a longer-trail mode: a slider in the prefs (`50 / 200 / 500 / ## 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`** — `` 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`** — `` floating card top-right, below `` (`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 `` as a sibling of ``, and `` *before* `` inside `` 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`. diff --git a/src/map/core/map-pref-store.ts b/src/map/core/map-pref-store.ts index ac32da1..6396d4a 100644 --- a/src/map/core/map-pref-store.ts +++ b/src/map/core/map-pref-store.ts @@ -2,26 +2,32 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { DEFAULT_BASEMAP_ID, type BasemapId } from './styles'; +export type TrailMode = 'none' | 'selected' | 'all'; + type MapPrefState = { basemapId: BasemapId; + trailMode: TrailMode; }; type MapPrefActions = { setBasemap: (id: BasemapId) => void; + setTrailMode: (mode: TrailMode) => void; }; type Store = MapPrefState & MapPrefActions; /** * Persists user preferences for the map: which basemap is selected, - * eventually whether trails are visible, follow mode, etc. Survives - * reloads via localStorage. + * which trail mode is on, eventually follow mode + per-event camera + * defaults. Survives reloads via localStorage. */ export const useMapPrefStore = create()( persist( (set) => ({ basemapId: DEFAULT_BASEMAP_ID, + trailMode: 'selected', setBasemap: (id) => set({ basemapId: id }), + setTrailMode: (mode) => set({ trailMode: mode }), }), { name: 'trm-map-prefs', diff --git a/src/map/core/trails-toggle.tsx b/src/map/core/trails-toggle.tsx new file mode 100644 index 0000000..bc491e2 --- /dev/null +++ b/src/map/core/trails-toggle.tsx @@ -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 ``, below ``. + */ +export function TrailsToggle() { + const current = useMapPrefStore((s) => s.trailMode); + const setTrailMode = useMapPrefStore((s) => s.setTrailMode); + + return ( +
+
+ Trails +
+ {MODES.map((m) => { + const isActive = current === m.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/map/layers/map-trails.tsx b/src/map/layers/map-trails.tsx new file mode 100644 index 0000000..ede2367 --- /dev/null +++ b/src/map/layers/map-trails.tsx @@ -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 = { + 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* `` 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; + selectedDeviceId: string | null; + trailMode: TrailMode; + deviceIds: Map; +}): FeatureCollection { + const features: Feature[] = []; + + 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 }; +} diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx index 2d9f4f0..69dd394 100644 --- a/src/routes/_authed/monitor.tsx +++ b/src/routes/_authed/monitor.tsx @@ -1,7 +1,9 @@ import { createFileRoute } from '@tanstack/react-router'; import { BasemapSwitcher } from '@/map/core/basemap-switcher'; import { MapView } from '@/map/core/map-view'; +import { TrailsToggle } from '@/map/core/trails-toggle'; import { MapPositions } from '@/map/layers/map-positions'; +import { MapTrails } from '@/map/layers/map-trails'; export const Route = createFileRoute('/_authed/monitor')({ component: MonitorPage, @@ -15,14 +17,17 @@ function MonitorPage() {
{/* - BasemapSwitcher renders inside so it remounts after - every style swap, ensuring the persisted preference is re-applied - 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 's wrapper. + UI controls. Floating cards positioned absolutely inside + 's wrapper. */} + + {/* + Layer order matters — MapLibre paints in addLayer() call order. + mounts first so its line layer renders below the + symbol layers added by . + */} +