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:
2026-05-02 23:41:02 +02:00
parent cc4cd8ddbf
commit 17439de34f
10 changed files with 252 additions and 4 deletions
+26 -1
View File
@@ -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`** — `<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`.