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`:
|
||||
|
||||
- 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.
|
||||
- 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. `<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.
|
||||
|
||||
@@ -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`** — `<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 { 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<Store>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
basemapId: DEFAULT_BASEMAP_ID,
|
||||
trailMode: 'selected',
|
||||
setBasemap: (id) => set({ basemapId: id }),
|
||||
setTrailMode: (mode) => set({ trailMode: mode }),
|
||||
}),
|
||||
{
|
||||
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 { 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() {
|
||||
<div className="relative h-[calc(100vh-0rem)] w-full">
|
||||
<MapView>
|
||||
{/*
|
||||
BasemapSwitcher renders inside <MapView> 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 <MapView>'s wrapper.
|
||||
UI controls. Floating cards positioned absolutely inside
|
||||
<MapView>'s wrapper.
|
||||
*/}
|
||||
<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 />
|
||||
</MapView>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user