- pnpm add maplibre-gl + -D @types/geojson. - src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2 replaces with the basemap-switcher descriptor table). - src/map/core/map-view.tsx: module-level Map singleton lazily created on first <MapView> mount, attached to a class="trm-map-host" detached <div> that React refs append/remove on mount/unmount. Style-data lifecycle flips mapReady false on every styledata event, polls loaded() at 33ms intervals, flips ready true once the style is loaded — the canonical MapLibre style-swap dance. - Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap() throws if called pre-mount; the explicit failure mode beats a null-able top-level export. - src/routes/_authed/monitor.tsx: new /monitor route, full-viewport <MapView /> for 2.1 (no children — subsequent tasks plug in here). - src/routes/_authed/index.tsx: home-page card now links to /monitor. - eslint.config.js: override for src/map/** + src/live/** disables react-refresh/only-export-components. Same pattern as the existing overrides for shadcn primitives and route files. Deviation: spec sketched a top-level `map` constant export; implemented as `getMap(): MapLibreMap` (a function) so the singleton stays lazy until <MapView> mounts. Top-level constant would either force eager init (breaks SSR/tests) or be nullable (footgun). The function form throws a clear error if called pre-mount. Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS). Other routes unaffected. Vite chunk-size warning is harmless.
8.2 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:
<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.<MapSelectedDevice />— reactive follow. WatchesselectedDeviceId; when the selected device's position updates andmapFollowis on,easeTothe new position.<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.tsupdated — addsmapFollow: boolean(defaulttrue) andsetMapFollow(v): void. Persisted via Zustand'spersistmiddleware 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,
mapFollowflips tofalseautomatically. Prevents the "I tried to scroll the map and it kept snapping back" frustration. src/routes/_authed/monitor.tsxupdated — 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 buildclean.- 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
movestartwithoriginalEvent. 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_devicesrows), 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.)