05543529e4
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec / Acceptance / Risks / Done). README updated with full sequencing diagram, files-modified outline, tech stack additions, design rules, and phase acceptance. | # | Task | | --- | --------------------------------------------------------------------- | | 2.1 | MapView singleton + mapReady gate | | 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google) | | 2.3 | Sprite preload — 7 racing categories x 4 colour variants | | 2.4 | WS client + rAF coalescer + Zustand position store + connection store | | 2.5 | MapPositions — clustered + selected sources | | 2.6 | MapTrails — bounded ring buffer, polyline rendering | | 2.7 | Event picker — TanStack Query + WS subscription orchestration | | 2.8 | Camera control trio — default-fit / selected-follow / one-shot | | 2.9 | Connection status + per-device last-seen indicators | Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently. 2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5. Each task documents its deliverables down to file paths + interface shapes, includes concrete code sketches in the Specification, lists explicit out-of-scope items, and surfaces risks for the implementer to think about. An agent (or future me) can pick up any single task and ship it without re-deriving the design from the wiki. Resolved Phase 2 design decisions baked into the task files: - Trails: flat-colour-per-device for v1, defer speed-coloured segments to a Phase 3 polish task. - Cluster params: 14/50 (traccar default); tune after seeing real data. - Event picker placement: top-left dropdown. - Multi-event: out — single-select, one event at a time. - Stale-position visual: fade icon opacity; defer warning badges.
189 lines
8.2 KiB
Markdown
189 lines
8.2 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
|
|
|
|
(Filled in when the task lands.)
|