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: <MapDefaultCamera /> 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: <MapSelectedDevice />
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: <MapCamera coordinates> 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: <FollowToggle /> 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.
This commit is contained in:
@@ -188,8 +188,8 @@ Three new files plus orchestration in the monitor route:
|
|||||||
3. `subscribe('event:<new>')`.
|
3. `subscribe('event:<new>')`.
|
||||||
4. On success → `applySnapshot(eventId, snapshot)` + persist to `localStorage`.
|
4. On success → `applySnapshot(eventId, snapshot)` + persist to `localStorage`.
|
||||||
5. On failure → `console.warn` and leave previous state alone.
|
5. On failure → `console.warn` and leave previous state alone.
|
||||||
Out-of-order safety via per-call version counter; `null` clears the active event.
|
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.
|
Plus `readSavedActiveEventId()` for callers that want to know what the persisted choice is.
|
||||||
- **`src/ui/components/event-picker.tsx`** — `<EventPicker activeEventId, onChange>`. 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/ui/components/event-picker.tsx`** — `<EventPicker activeEventId, onChange>`. 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/live/index.ts`** — re-exports `useActiveEventOrchestration` + `readSavedActiveEventId`.
|
||||||
- **`src/routes/_authed/monitor.tsx`** — orchestration:
|
- **`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.
|
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`):**
|
**Smoke check (local `pnpm dev`):**
|
||||||
|
|
||||||
- `/monitor` shows the event picker top-left. Clicking it opens the dropdown with the seeded events.
|
- `/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.
|
- 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.
|
- Switching events: previous event's markers disappear, new event's markers appear, no leftover state.
|
||||||
|
|||||||
@@ -191,4 +191,29 @@ Click toggles `mapFollow`. Selecting a device with follow off still pans to it o
|
|||||||
|
|
||||||
## Done
|
## 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`** — `<MapDefaultCamera />`. 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`** — `<MapSelectedDevice />`. 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`** — `<MapCamera coordinates>` 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`** — `<FollowToggle />` 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 `<FollowToggle />`, `<MapDefaultCamera />`, and `<MapSelectedDevice />` (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 `<MapSelectedDevice>` 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.
|
||||||
|
- `<MapDefaultCamera>` 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`.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ When Phase 2 is done:
|
|||||||
| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 |
|
| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 |
|
||||||
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | 🟩 |
|
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | 🟩 |
|
||||||
| 2.7 | [Event picker (subscription driver)](./07-event-picker.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) | ⬜ |
|
| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ |
|
||||||
|
|
||||||
## Files modified
|
## Files modified
|
||||||
|
|||||||
@@ -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<string | null>(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { MapDefaultCamera } from './default-camera';
|
||||||
|
export { MapSelectedDevice } from './selected-device';
|
||||||
|
export { MapCamera } from './map-camera';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string | null>(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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* `<MapSelectedDevice>` component handles that).
|
||||||
|
*/
|
||||||
|
export function FollowToggle() {
|
||||||
|
const mapFollow = useMapPrefStore((s) => s.mapFollow);
|
||||||
|
const setMapFollow = useMapPrefStore((s) => s.setMapFollow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-56 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden z-10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMapFollow(!mapFollow)}
|
||||||
|
title={mapFollow ? 'Following selected device' : 'Click to follow selected device'}
|
||||||
|
aria-pressed={mapFollow}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-1.5 text-sm transition-colors',
|
||||||
|
mapFollow ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mapFollow ? (
|
||||||
|
<Locate className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<LocateOff className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span>Follow</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,11 +7,14 @@ export type TrailMode = 'none' | 'selected' | 'all';
|
|||||||
type MapPrefState = {
|
type MapPrefState = {
|
||||||
basemapId: BasemapId;
|
basemapId: BasemapId;
|
||||||
trailMode: TrailMode;
|
trailMode: TrailMode;
|
||||||
|
/** When true, the camera pans to follow the selected device's position updates. */
|
||||||
|
mapFollow: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapPrefActions = {
|
type MapPrefActions = {
|
||||||
setBasemap: (id: BasemapId) => void;
|
setBasemap: (id: BasemapId) => void;
|
||||||
setTrailMode: (mode: TrailMode) => void;
|
setTrailMode: (mode: TrailMode) => void;
|
||||||
|
setMapFollow: (follow: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Store = MapPrefState & MapPrefActions;
|
type Store = MapPrefState & MapPrefActions;
|
||||||
@@ -26,8 +29,10 @@ export const useMapPrefStore = create<Store>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
basemapId: DEFAULT_BASEMAP_ID,
|
basemapId: DEFAULT_BASEMAP_ID,
|
||||||
trailMode: 'selected',
|
trailMode: 'selected',
|
||||||
|
mapFollow: true,
|
||||||
setBasemap: (id) => set({ basemapId: id }),
|
setBasemap: (id) => set({ basemapId: id }),
|
||||||
setTrailMode: (mode) => set({ trailMode: mode }),
|
setTrailMode: (mode) => set({ trailMode: mode }),
|
||||||
|
setMapFollow: (follow) => set({ mapFollow: follow }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'trm-map-prefs',
|
name: 'trm-map-prefs',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
usePositionStore,
|
usePositionStore,
|
||||||
} from '@/live';
|
} from '@/live';
|
||||||
import { BasemapSwitcher } from '@/map/core/basemap-switcher';
|
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 { MapView } from '@/map/core/map-view';
|
||||||
import { TrailsToggle } from '@/map/core/trails-toggle';
|
import { TrailsToggle } from '@/map/core/trails-toggle';
|
||||||
import { MapPositions } from '@/map/layers/map-positions';
|
import { MapPositions } from '@/map/layers/map-positions';
|
||||||
@@ -58,6 +60,7 @@ function MonitorPage() {
|
|||||||
/>
|
/>
|
||||||
<BasemapSwitcher />
|
<BasemapSwitcher />
|
||||||
<TrailsToggle />
|
<TrailsToggle />
|
||||||
|
<FollowToggle />
|
||||||
{/*
|
{/*
|
||||||
Layer order matters — MapLibre paints in addLayer() call order.
|
Layer order matters — MapLibre paints in addLayer() call order.
|
||||||
<MapTrails /> mounts first so its line layer renders below the
|
<MapTrails /> mounts first so its line layer renders below the
|
||||||
@@ -65,6 +68,13 @@ function MonitorPage() {
|
|||||||
*/}
|
*/}
|
||||||
<MapTrails />
|
<MapTrails />
|
||||||
<MapPositions />
|
<MapPositions />
|
||||||
|
{/*
|
||||||
|
Camera components are side-effect-only (return null). They sit
|
||||||
|
at the end so they react to whatever <MapPositions /> just
|
||||||
|
surfaced (selected device, latest positions).
|
||||||
|
*/}
|
||||||
|
<MapDefaultCamera />
|
||||||
|
<MapSelectedDevice />
|
||||||
</MapView>
|
</MapView>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user