# 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. **``** — 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. **``** — reactive follow. Watches `selectedDeviceId`; when the selected device's position updates and `mapFollow` is on, `easeTo` the new position. 3. **``** — 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` — `` side-effect-only component. - `selected-device.tsx` — `` side-effect-only component. - `map-camera.tsx` — `` 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 `` and `` as children of ``, alongside `` / ``. ## Specification ### `` — initial fit ```tsx function MapDefaultCamera() { const activeEventId = usePositionStore((s) => s.activeEventId); const latestByDevice = usePositionStore((s) => s.latestByDevice); const initialised = useRef(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). ### `` — 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(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. ### `` — 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 `` 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 (Filled in when the task lands.)