diff --git a/frontend/src/__tests__/map/maplibreCameraBounds.test.ts b/frontend/src/__tests__/map/maplibreCameraBounds.test.ts new file mode 100644 index 0000000..c26c1c6 --- /dev/null +++ b/frontend/src/__tests__/map/maplibreCameraBounds.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const viewer = readFileSync( + join(process.cwd(), 'src/components/MaplibreViewer.tsx'), + 'utf8', +); + +describe('MaplibreViewer bounds camera', () => { + it('derives zoom from bounds via cameraForBounds', () => { + expect(viewer).toContain('cameraForBounds'); + }); + + it('caps the fit, because a tiny bounds otherwise flies to maxZoom 22', () => { + expect(viewer).toMatch(/maxZoom:\s*ZOOM_MAX/); + }); + + it('handles cameraForBounds returning undefined on oversized padding', () => { + expect(viewer).toContain('ZOOM_FALLBACK'); + }); + + it('leaves the agent fly-to path untouched', () => { + expect(viewer).toContain('zoom: flyToLocation.zoom ?? 8,'); + }); +}); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index be4f968..d5c3643 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -437,6 +437,7 @@ export default function Dashboard() { lat: number; lng: number; zoom?: number; + bounds?: [number, number, number, number]; ts: number; } | null>(null); diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 6e0e77e..118ebd4 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -1,6 +1,7 @@ 'use client'; import { API_BASE } from '@/lib/api'; +import { clampZoom, ZOOM_MAX, ZOOM_FALLBACK } from '@/lib/mapZoom'; import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import Map, { Source, @@ -751,16 +752,32 @@ const MaplibreViewer = ({ }, [selectedEntity]); useEffect(() => { - if (flyToLocation && mapRef.current) { - // Agent moves (sar_focus_aoi) carry their own zoom: a world-view revert - // asks for ~2, a tight AOI for ~9. Ignoring it pinned every move at 8, - // which turned "show the whole world" into a patch of empty ocean. - mapRef.current.flyTo({ - center: [flyToLocation.lng, flyToLocation.lat], - zoom: flyToLocation.zoom ?? 8, + if (!flyToLocation || !mapRef.current) return; + const map = mapRef.current.getMap(); + + if (flyToLocation.bounds) { + // cameraForBounds returns undefined when padding exceeds the viewport, + // and silently returns the map's maxZoom (22) for a degenerate box — + // hence both the clamped padding and the explicit maxZoom. + const { clientWidth, clientHeight } = map.getContainer(); + const padding = Math.min(64, Math.min(clientWidth, clientHeight) / 6); + const cam = map.cameraForBounds(flyToLocation.bounds, { padding, maxZoom: ZOOM_MAX }); + map.flyTo({ + center: cam?.center ?? [flyToLocation.lng, flyToLocation.lat], + zoom: cam ? clampZoom(cam.zoom ?? ZOOM_FALLBACK) : ZOOM_FALLBACK, duration: 1500, }); + return; } + + // Agent moves (sar_focus_aoi) carry their own zoom: a world-view revert + // asks for ~2, a tight AOI for ~9. Ignoring it pinned every move at 8, + // which turned "show the whole world" into a patch of empty ocean. + mapRef.current.flyTo({ + center: [flyToLocation.lng, flyToLocation.lat], + zoom: flyToLocation.zoom ?? 8, + duration: 1500, + }); }, [flyToLocation]); const earthquakesGeoJSON = useMemo( diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 868f361..552250f 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -1334,7 +1334,7 @@ export interface MaplibreViewerProps { activeFilters?: Record; effects?: MapEffects; onEntityClick: (entity: SelectedEntity | null) => void; - flyToLocation: { lat: number; lng: number; zoom?: number; ts?: number } | null; + flyToLocation: { lat: number; lng: number; zoom?: number; bounds?: [number, number, number, number]; ts?: number } | null; selectedEntity: SelectedEntity | null; onMouseCoords: (coords: { lat: number; lng: number }) => void; onRightClick: (coords: { lat: number; lng: number }) => void;