Files
spa/.planning/phase-2-live-map/08-camera-trio.md
T
julian ed88f1767d feat: task 2.9 connection status + per-device staleness fade — Phase 2 done
- src/live/last-seen.ts: formatLastSeen(ts, now) — "now" / "Ns ago" /
  "Nm ago" / "HH:MM".
- src/live/use-staleness.ts: useStalenessTick(intervalMs=1000) hook
  re-renders subscribers every interval with the current epoch ms.
- src/ui/components/connection-chip.tsx: <ConnectionChip /> bottom-left,
  three states (connected / connecting+reconnecting / disconnected),
  aria-live polite + role status.
- src/map/layers/map-positions.tsx: every FeatureProps now carries
  staleSec; both symbol layers interpolate icon-opacity (and text-opacity
  on the non-selected layer) on staleSec — 0-60s full opacity,
  5min faded, 30min very faded. Update effect rebuilds features at the
  staleness tick rate (1Hz).
- src/routes/_authed/monitor.tsx: renders <ConnectionChip />.
- src/live/index.ts: re-exports formatLastSeen + useStalenessTick.

Strategy A (faded marker via interpolation) chosen over Strategy B
(separate warning-badge layer) per the task's open-question
resolution; simpler and easier to extend later.

Deviations:
1. stalenessTick is its own hook in src/live/use-staleness.ts rather
   than a property on the position store — keeps the store clean of
   UI-driven re-render concerns; the hook is reusable.
2. <DeviceLastSeen deviceId> standalone component skipped — the SPA
   doesn't have a sidebar yet (Phase 3.4); the per-marker fade IS the
   indicator for v1.

Bundle: main 396KB / 121KB gz — small bump from 2.8.

🎉 Phase 2 — Live monitoring map — complete. All 9 tasks shipped.
End-to-end: login -> /monitor -> event auto-selects -> snapshot
positions render -> live updates flow via WS through the rAF
coalescer -> staleness fades stale markers -> connection chip
surfaces WS state. Dogfood-blocking work for Rally Albania 2026 done.
2026-05-03 09:33:41 +02:00

221 lines
11 KiB
Markdown

# Task 2.8 — Camera control trio
**Phase:** 2 — Live monitoring map
**Status:** ⬜ Not started
**Depends on:** 2.1, 2.5.
**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Camera control patterns"; `docs/wiki/sources/traccar-maps-architecture.md` §10.
## Goal
Three small components, each handling one camera concern, kept separate so dependency graphs stay small and "the camera jumped unexpectedly" bugs don't appear:
1. **`<MapDefaultCamera />`** — initial framing on event-load. Fits the bounds of the event's snapshot (or the user's saved view). Runs at most once per event-id change.
2. **`<MapSelectedDevice />`** — reactive follow. Watches `selectedDeviceId`; when the selected device's position updates and `mapFollow` is on, `easeTo` the new position.
3. **`<MapCamera />`** — one-shot fit, called imperatively when needed (e.g. "fit to selection" button later, replay mode in Phase 4).
Plus a `mapFollow` user preference (boolean), wired to a small toggle in the chrome.
## Deliverables
- **`src/map/core/camera/`** directory:
- `default-camera.tsx``<MapDefaultCamera />` side-effect-only component.
- `selected-device.tsx``<MapSelectedDevice />` side-effect-only component.
- `map-camera.tsx``<MapCamera coordinates={...} />` props-driven one-shot fit.
- `index.ts` — barrel.
- **`src/live/position-store.ts`** updated — adds `mapFollow: boolean` (default `true`) and `setMapFollow(v): void`. Persisted via Zustand's `persist` middleware on the prefs slice.
- **A toggle UI control** in the chrome — labelled "Follow selected" or just a small lock-pin icon. When on, selecting a device auto-pans to it; manual pan disables follow until the next selection change.
- **Manual-pan detection** — when the user pans/zooms the map, `mapFollow` flips to `false` automatically. Prevents the "I tried to scroll the map and it kept snapping back" frustration.
- **`src/routes/_authed/monitor.tsx`** updated — render `<MapDefaultCamera />` and `<MapSelectedDevice />` as children of `<MapView>`, alongside `<MapPositions />` / `<MapTrails />`.
## Specification
### `<MapDefaultCamera />` — initial fit
```tsx
function MapDefaultCamera() {
const activeEventId = usePositionStore((s) => s.activeEventId);
const latestByDevice = usePositionStore((s) => s.latestByDevice);
const initialised = useRef<string | null>(null); // tracks last event we fitted
useEffect(() => {
if (!activeEventId || initialised.current === activeEventId) return;
if (latestByDevice.size === 0) return; // wait for snapshot
const positions = Array.from(latestByDevice.values());
if (positions.length === 1) {
const [p] = positions;
map.easeTo({ center: [p.lon, p.lat], zoom: 12, duration: 0 });
} else {
const bounds = new maplibregl.LngLatBounds();
for (const p of positions) bounds.extend([p.lon, p.lat]);
map.fitBounds(bounds, {
padding: Math.min(map.getCanvas().clientWidth, map.getCanvas().clientHeight) * 0.1,
duration: 0,
});
}
initialised.current = activeEventId;
}, [activeEventId, latestByDevice]);
return null;
}
```
The `initialised` ref ensures the camera only fits once per event change. After the initial fit, the user owns the camera (they pan / zoom freely; the camera doesn't snap back).
### `<MapSelectedDevice />` — reactive follow
```tsx
function MapSelectedDevice() {
const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId);
const latest = usePositionStore((s) => s.latestByDevice);
const mapFollow = usePositionStore((s) => s.mapFollow);
const lastSelectionRef = useRef<string | null>(null);
useEffect(() => {
if (!selectedDeviceId) return;
const position = latest.get(selectedDeviceId);
if (!position) return;
// On selection change, always pan.
if (lastSelectionRef.current !== selectedDeviceId) {
lastSelectionRef.current = selectedDeviceId;
map.easeTo({
center: [position.lon, position.lat],
zoom: Math.max(map.getZoom(), 14),
duration: 600,
});
return;
}
// On position update, only pan if mapFollow is on.
if (mapFollow) {
map.easeTo({
center: [position.lon, position.lat],
duration: 300,
});
}
}, [selectedDeviceId, latest, mapFollow]);
return null;
}
```
Two behaviours:
- **Selection change:** always pans (zoom in if zoomed out). The user just clicked the device; they want to see it.
- **Position update with follow on:** smooth pan without a zoom change.
### `<MapCamera coordinates>` — imperative one-shot
```tsx
function MapCamera({
coordinates,
padding,
}: {
coordinates: [number, number][];
padding?: number;
}) {
useEffect(() => {
if (coordinates.length === 0) return;
if (coordinates.length === 1) {
map.easeTo({ center: coordinates[0], zoom: 14, duration: 600 });
return;
}
const bounds = new maplibregl.LngLatBounds();
for (const c of coordinates) bounds.extend(c);
map.fitBounds(bounds, { padding: padding ?? 60, duration: 600 });
}, [coordinates, padding]);
return null;
}
```
Used by future features (replay scrubbing, "fit to all" button). Phase 2 may not call it directly; just shipping the primitive.
### Manual-pan auto-disables follow
```tsx
// Inside MapSelectedDevice or as a separate effect:
useEffect(() => {
let lastReason: string | undefined;
const onMoveStart = (ev: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) => {
// The map fires moveStart for both programmatic easeTo and user gesture.
// We only want to disable follow on user gestures.
if (ev.originalEvent != null) {
// user gesture
if (usePositionStore.getState().mapFollow) {
usePositionStore.getState().setMapFollow(false);
}
}
};
map.on('movestart', onMoveStart);
return () => map.off('movestart', onMoveStart);
}, []);
```
`originalEvent` is present on user gestures, absent on programmatic moves. The check distinguishes "user dragged the map" from "our easeTo fired."
### Follow toggle UI
A small button or icon-button in the chrome (next to the basemap switcher works). Two states:
- "Follow on" — solid-fill location pin, tooltip "Following selected device."
- "Follow off" — outline pin, tooltip "Click to follow selected device."
Click toggles `mapFollow`. Selecting a device with follow off still pans to it once (selection change), then leaves the camera alone.
### What this task does NOT include
- **Auto-fit to all visible devices.** No "show me everyone" zoom button in v1. Defer.
- **Pitch / bearing controls.** MapLibre's default pinch-rotate is enough; no custom 3D camera controls.
- **Custom keyboard navigation.** Arrow keys to pan are MapLibre default and work fine.
- **Replay-mode camera.** Phase 4 — reuses `<MapCamera>` as the imperative primitive.
## Acceptance criteria
- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean.
- [ ] On `/monitor`, picking an event auto-fits the map to all devices in the snapshot.
- [ ] Clicking a device pans + zooms to it (zooms in if currently zoomed out, doesn't zoom out if already zoomed in).
- [ ] With "Follow on", selecting a moving device follows its position smoothly.
- [ ] With "Follow on", manually panning the map disables follow (the toggle visibly switches off).
- [ ] With "Follow off", selecting still pans once but doesn't follow subsequent updates.
- [ ] Switching events triggers a new initial fit (ignoring the previous event's "I last fit this" memory).
## Risks / open questions
- **Pan-disables-follow false positives.** Pinch-zoom on touch devices may also fire `movestart` with `originalEvent`. That's correct — pinch-zooming counts as user-driven and should disable follow.
- **Snapshot empty at fit time.** If the snapshot returns zero devices (edge case: event with no `entry_devices` rows), no fit happens. That's fine — the map stays at its previous extent.
- **Easing duration on update.** 300ms is smooth but at high update rates (>3Hz position updates) it can feel laggy. If real dogfood feedback complains, reduce to 150ms or 0.
- **Padding on `fitBounds`.** 10% of canvas width is reasonable but on a 4K display gives ~200px padding which may feel excessive. Cap at e.g. `Math.min(canvas * 0.1, 80)`.
## Done
- **`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 `259d1e9`.