Files
spa/.planning/phase-2-live-map/08-camera-trio.md
T
julian 17439de34f 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.
2026-05-03 09:33:08 +02:00

11 KiB

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

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

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

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

// 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 easeTos 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.