From 17439de34f4bc5fa4d800682de44ea5d1cb46511 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 23:41:02 +0200 Subject: [PATCH] feat: task 2.8 camera control trio + follow toggle - src/map/core/map-pref-store.ts: mapFollow boolean (default true) + setter. Persisted via trm-map-prefs. - src/map/core/camera/default-camera.tsx: fits the snapshot's bounds once per activeEventId change. Uses a fittedFor ref; resets to null when activeEventId becomes null. Single-point events centre+zoom 12; multi-point fitBounds with padding capped at min(canvas*0.1, 80). - src/map/core/camera/selected-device.tsx: pans+zooms on selection change, smooth pans on subsequent position updates with mapFollow on. Separate effect listens for user-gesture movestart (originalEvent truthy) and flips mapFollow off. - src/map/core/camera/map-camera.tsx: imperative one-shot. Phase 2 doesn't use it; primitive for replay (Phase 4) and future "fit to all" UI. - src/map/core/follow-toggle.tsx: icon-button using Lucide Locate/LocateOff. Sits below the trails toggle. - src/routes/_authed/monitor.tsx: renders FollowToggle, MapDefaultCamera, MapSelectedDevice. Deviations: - Manual-pan listener uses an inline { originalEvent?: unknown } type instead of MapLibre's MapMouseEvent|MapTouchEvent union (typing friction across version bumps). - MapDefaultCamera clears fittedFor on activeEventId === null so re-selecting the same event later re-fits. Bundle: main 395KB / 120KB gz, no measurable change. --- .planning/phase-2-live-map/07-event-picker.md | 5 +- .planning/phase-2-live-map/08-camera-trio.md | 27 ++++++- .planning/phase-2-live-map/README.md | 2 +- src/map/core/camera/default-camera.tsx | 47 ++++++++++++ src/map/core/camera/index.ts | 3 + src/map/core/camera/map-camera.tsx | 44 +++++++++++ src/map/core/camera/selected-device.tsx | 74 +++++++++++++++++++ src/map/core/follow-toggle.tsx | 39 ++++++++++ src/map/core/map-pref-store.ts | 5 ++ src/routes/_authed/monitor.tsx | 10 +++ 10 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 src/map/core/camera/default-camera.tsx create mode 100644 src/map/core/camera/index.ts create mode 100644 src/map/core/camera/map-camera.tsx create mode 100644 src/map/core/camera/selected-device.tsx create mode 100644 src/map/core/follow-toggle.tsx diff --git a/.planning/phase-2-live-map/07-event-picker.md b/.planning/phase-2-live-map/07-event-picker.md index 4d88d79..bf04ee2 100644 --- a/.planning/phase-2-live-map/07-event-picker.md +++ b/.planning/phase-2-live-map/07-event-picker.md @@ -188,8 +188,8 @@ Three new files plus orchestration in the monitor route: 3. `subscribe('event:')`. 4. On success → `applySnapshot(eventId, snapshot)` + persist to `localStorage`. 5. On failure → `console.warn` and leave previous state alone. - Out-of-order safety via per-call version counter; `null` clears the active event. - Plus `readSavedActiveEventId()` for callers that want to know what the persisted choice is. + Out-of-order safety via per-call version counter; `null` clears the active event. + Plus `readSavedActiveEventId()` for callers that want to know what the persisted choice is. - **`src/ui/components/event-picker.tsx`** — ``. Click-to-open dropdown with click-outside-to-close. Shows the events list sorted most-recent-first; each row displays name + date + discipline. Disabled state when loading / errored / empty. - **`src/live/index.ts`** — re-exports `useActiveEventOrchestration` + `readSavedActiveEventId`. - **`src/routes/_authed/monitor.tsx`** — orchestration: @@ -204,6 +204,7 @@ Three new files plus orchestration in the monitor route: 3. Logout-clears-saved-event-id: documented as a Phase 1 follow-up in this task's risks section. Not implemented here — `performLogout` (Phase 1.8) doesn't currently clear localStorage. A small follow-up patch will pull `localStorage.removeItem('trm-active-event-id')` into the logout flow. **Smoke check (local `pnpm dev`):** + - `/monitor` shows the event picker top-left. Clicking it opens the dropdown with the seeded events. - On a fresh page load (with stage Directus reachable + a connected WS): the dogfood event auto-selects within ~1s; the snapshot of seeded positions appears as map markers. - Switching events: previous event's markers disappear, new event's markers appear, no leftover state. diff --git a/.planning/phase-2-live-map/08-camera-trio.md b/.planning/phase-2-live-map/08-camera-trio.md index 3571c60..697651e 100644 --- a/.planning/phase-2-live-map/08-camera-trio.md +++ b/.planning/phase-2-live-map/08-camera-trio.md @@ -191,4 +191,29 @@ Click toggles `mapFollow`. Selecting a device with follow off still pans to it o ## Done -(Filled in when the task lands.) +- **`src/map/core/map-pref-store.ts`** — added `mapFollow: boolean` (default `true`) + `setMapFollow`. Persisted via the existing `trm-map-prefs` zustand+persist store. +- **`src/map/core/camera/default-camera.tsx`** — ``. Watches `activeEventId` + `latestByDevice`. Uses a `fittedFor` ref to ensure the camera only fits *once* per event-id change. Single-device events centre + zoom to 12; multi-device events `fitBounds` with padding capped at `min(canvas * 0.1, 80)`. +- **`src/map/core/camera/selected-device.tsx`** — ``. Two effects: + - Selection / position effect: on selection change, always pan + zoom to ≥ 14. On subsequent position updates with `mapFollow` on, smooth pan only (no zoom change). + - Manual-pan listener: hooks `map.on('movestart', ...)`. Distinguishes user gestures from programmatic `easeTo`s via `originalEvent`-truthy check; flips `mapFollow` to false when the user grabs the map. +- **`src/map/core/camera/map-camera.tsx`** — `` props-driven one-shot. Re-runs on `coordinates` reference change. Phase 2 doesn't use it; reserved for replay (Phase 4) and "fit to all" UI (Phase 3+). +- **`src/map/core/camera/index.ts`** — barrel. +- **`src/map/core/follow-toggle.tsx`** — `` icon-button using Lucide's `Locate` / `LocateOff`. Sits at `top-56 right-3` below the trails toggle. `aria-pressed` reflects the state for keyboard users. +- **`src/routes/_authed/monitor.tsx`** — renders ``, ``, and `` (the camera components after the layer components so they react to whatever positions just got rendered). + +**Deviations from spec:** + +- Spec sketched the manual-pan listener inside `` as a separate effect with a typed `MapMouseEvent | MapTouchEvent` union. MapLibre's `'movestart'` handler typing was awkward across version bumps; defined a tiny inline `{ originalEvent?: unknown }` type and casted there. Functionally equivalent; less type-import friction. +- `` clears `fittedFor.current = null` when `activeEventId` becomes null (e.g. on logout / event clear). Without that, switching back to the same event later wouldn't re-fit. Spec didn't call this out; added it as a small correctness fix. + +**Smoke check (`pnpm dev`):** +- Pick the seeded Rally Albania event → camera fits all snapshot positions on first appearance. +- Click a device → camera pans + zooms in. +- With Follow on, push a synthetic position for the selected device → camera smoothly pans to follow it. +- Pan the map manually → Follow toggle visibly flips off; camera stops following. +- Click Follow back on → camera resumes following on the next position update. +- Switch events → camera fits the new event's snapshot once. + +**Bundle:** main 395KB / 120KB gz — no measurable change (camera components are tiny). + +Landed in `PENDING_SHA`. diff --git a/.planning/phase-2-live-map/README.md b/.planning/phase-2-live-map/README.md index c7dd77f..292d918 100644 --- a/.planning/phase-2-live-map/README.md +++ b/.planning/phase-2-live-map/README.md @@ -55,7 +55,7 @@ When Phase 2 is done: | 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 | | 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.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) | 🟩 | | 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ | ## Files modified diff --git a/src/map/core/camera/default-camera.tsx b/src/map/core/camera/default-camera.tsx new file mode 100644 index 0000000..ea87c48 --- /dev/null +++ b/src/map/core/camera/default-camera.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react'; +import { LngLatBounds } from 'maplibre-gl'; +import { usePositionStore } from '@/live'; +import { getMap } from '../map-view'; + +/** + * Initial framing on event-load. + * + * Fits the map to the bounds of every device in the active event's + * snapshot — runs at most once per `activeEventId` change. After the + * initial fit the user owns the camera; later position updates don't + * snap back. + * + * Side-effect-only: returns null. + */ +export function MapDefaultCamera() { + const activeEventId = usePositionStore((s) => s.activeEventId); + const latestByDevice = usePositionStore((s) => s.latestByDevice); + const fittedFor = useRef(null); + + useEffect(() => { + if (!activeEventId) { + fittedFor.current = null; + return; + } + if (fittedFor.current === activeEventId) return; + if (latestByDevice.size === 0) return; // wait for snapshot + + const map = getMap(); + const positions = Array.from(latestByDevice.values()); + + if (positions.length === 1) { + const p = positions[0]!; + map.easeTo({ center: [p.lon, p.lat], zoom: 12, duration: 0 }); + } else { + const bounds = new LngLatBounds(); + for (const p of positions) bounds.extend([p.lon, p.lat]); + const canvas = map.getCanvas(); + const padding = Math.min(canvas.clientWidth, canvas.clientHeight) * 0.1; + map.fitBounds(bounds, { padding: Math.min(padding, 80), duration: 0 }); + } + + fittedFor.current = activeEventId; + }, [activeEventId, latestByDevice]); + + return null; +} diff --git a/src/map/core/camera/index.ts b/src/map/core/camera/index.ts new file mode 100644 index 0000000..2b730ea --- /dev/null +++ b/src/map/core/camera/index.ts @@ -0,0 +1,3 @@ +export { MapDefaultCamera } from './default-camera'; +export { MapSelectedDevice } from './selected-device'; +export { MapCamera } from './map-camera'; diff --git a/src/map/core/camera/map-camera.tsx b/src/map/core/camera/map-camera.tsx new file mode 100644 index 0000000..240a1ed --- /dev/null +++ b/src/map/core/camera/map-camera.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { LngLatBounds } from 'maplibre-gl'; +import { getMap } from '../map-view'; + +/** + * Imperative one-shot camera fit. Pass `coordinates` (or a single point) + * and the camera animates to fit them. Used by future features: + * + * - "Fit all" button — coordinates = every visible device. + * - Replay scrubbing (Phase 4) — coordinates = the replay frame's window. + * + * Re-runs when `coordinates` changes by reference. Pass a stable array if + * you don't want repeated animations. + */ +export function MapCamera({ + coordinates, + padding = 60, + zoom, + duration = 600, +}: { + coordinates: [number, number][]; + padding?: number; + /** Fixed zoom override for single-point centring. Ignored for multi-point fits. */ + zoom?: number; + duration?: number; +}) { + useEffect(() => { + if (coordinates.length === 0) return; + const map = getMap(); + if (coordinates.length === 1) { + map.easeTo({ + center: coordinates[0]!, + zoom: zoom ?? Math.max(map.getZoom(), 14), + duration, + }); + return; + } + const bounds = new LngLatBounds(); + for (const c of coordinates) bounds.extend(c); + map.fitBounds(bounds, { padding, duration }); + }, [coordinates, padding, zoom, duration]); + + return null; +} diff --git a/src/map/core/camera/selected-device.tsx b/src/map/core/camera/selected-device.tsx new file mode 100644 index 0000000..cd603a6 --- /dev/null +++ b/src/map/core/camera/selected-device.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from 'react'; +import { usePositionStore } from '@/live'; +import { useMapPrefStore } from '../map-pref-store'; +import { getMap } from '../map-view'; + +/** + * Reactive follow for the selected device. + * + * Two distinct behaviours, dispatched by what changed: + * + * 1. **Selection change** — always pan + zoom in (clamped to zoom ≥ 14). + * The user just clicked the device, they want to see it. + * 2. **Position update with `mapFollow` on** — smooth pan only, no zoom + * change. Updates run at the rAF-coalesced rate of the position store. + * + * Plus: manual map-pan auto-disables `mapFollow` so the user isn't + * fighting the camera. We distinguish user gestures from programmatic + * `easeTo`s via `MapMouseEvent.originalEvent` (truthy on user gestures, + * absent on our own animations). + */ +export function MapSelectedDevice() { + const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId); + const latest = usePositionStore((s) => s.latestByDevice); + const mapFollow = useMapPrefStore((s) => s.mapFollow); + const lastSelection = useRef(null); + + useEffect(() => { + if (!selectedDeviceId) { + lastSelection.current = null; + return; + } + const position = latest.get(selectedDeviceId); + if (!position) return; + + const map = getMap(); + + if (lastSelection.current !== selectedDeviceId) { + lastSelection.current = selectedDeviceId; + map.easeTo({ + center: [position.lon, position.lat], + zoom: Math.max(map.getZoom(), 14), + duration: 600, + }); + return; + } + + if (mapFollow) { + map.easeTo({ + center: [position.lon, position.lat], + duration: 300, + }); + } + }, [selectedDeviceId, latest, mapFollow]); + + // Manual-pan detection: any move with an `originalEvent` is a user + // gesture and should disable follow. Programmatic `easeTo`s have no + // originalEvent, so they don't trigger this path. + useEffect(() => { + const map = getMap(); + type MoveEv = { originalEvent?: unknown }; + const onMoveStart = (ev: MoveEv): void => { + if (ev.originalEvent != null) { + const { mapFollow: follow, setMapFollow } = useMapPrefStore.getState(); + if (follow) setMapFollow(false); + } + }; + map.on('movestart', onMoveStart); + return () => { + map.off('movestart', onMoveStart); + }; + }, []); + + return null; +} diff --git a/src/map/core/follow-toggle.tsx b/src/map/core/follow-toggle.tsx new file mode 100644 index 0000000..9f1e6e5 --- /dev/null +++ b/src/map/core/follow-toggle.tsx @@ -0,0 +1,39 @@ +import { Locate, LocateOff } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useMapPrefStore } from './map-pref-store'; + +/** + * Small icon-button toggling `mapFollow`. Sits in the chrome below the + * trails toggle. + * + * Behaviour: when on, the camera pans to follow the selected device's + * position updates. When off, the camera stays where the user put it. + * Manually panning the map auto-disables follow (the + * `` component handles that). + */ +export function FollowToggle() { + const mapFollow = useMapPrefStore((s) => s.mapFollow); + const setMapFollow = useMapPrefStore((s) => s.setMapFollow); + + return ( +
+ +
+ ); +} diff --git a/src/map/core/map-pref-store.ts b/src/map/core/map-pref-store.ts index 6396d4a..2fbec79 100644 --- a/src/map/core/map-pref-store.ts +++ b/src/map/core/map-pref-store.ts @@ -7,11 +7,14 @@ export type TrailMode = 'none' | 'selected' | 'all'; type MapPrefState = { basemapId: BasemapId; trailMode: TrailMode; + /** When true, the camera pans to follow the selected device's position updates. */ + mapFollow: boolean; }; type MapPrefActions = { setBasemap: (id: BasemapId) => void; setTrailMode: (mode: TrailMode) => void; + setMapFollow: (follow: boolean) => void; }; type Store = MapPrefState & MapPrefActions; @@ -26,8 +29,10 @@ export const useMapPrefStore = create()( (set) => ({ basemapId: DEFAULT_BASEMAP_ID, trailMode: 'selected', + mapFollow: true, setBasemap: (id) => set({ basemapId: id }), setTrailMode: (mode) => set({ trailMode: mode }), + setMapFollow: (follow) => set({ mapFollow: follow }), }), { name: 'trm-map-prefs', diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx index 13628a9..87775b9 100644 --- a/src/routes/_authed/monitor.tsx +++ b/src/routes/_authed/monitor.tsx @@ -8,6 +8,8 @@ import { usePositionStore, } from '@/live'; import { BasemapSwitcher } from '@/map/core/basemap-switcher'; +import { MapDefaultCamera, MapSelectedDevice } from '@/map/core/camera'; +import { FollowToggle } from '@/map/core/follow-toggle'; import { MapView } from '@/map/core/map-view'; import { TrailsToggle } from '@/map/core/trails-toggle'; import { MapPositions } from '@/map/layers/map-positions'; @@ -58,6 +60,7 @@ function MonitorPage() { /> + {/* Layer order matters — MapLibre paints in addLayer() call order. mounts first so its line layer renders below the @@ -65,6 +68,13 @@ function MonitorPage() { */} + {/* + Camera components are side-effect-only (return null). They sit + at the end so they react to whatever just + surfaced (selected device, latest positions). + */} + + );