import { useEffect } from 'react'; import type { LatLng } from './map.types'; export interface MapBoundsLiteral { north: number; south: number; east: number; west: number; } /** Either an explicit rectangle or a set of points to be enclosed. */ export type MapBounds = LatLng[] | MapBoundsLiteral; /** What the map should frame itself around. */ export type MapFitTarget = 'markers' | 'shapes' | 'all' | 'none'; /** A named camera position the user can jump back to. */ export interface MapView { id: string; label: string; center?: LatLng; zoom?: number; bounds?: MapBounds; } function isBoundsLiteral(value: MapBounds): value is MapBoundsLiteral { return !Array.isArray(value); } function toLatLngBounds(bounds: MapBounds): google.maps.LatLngBounds | null { if (typeof google === 'undefined' || !google.maps?.LatLngBounds) return null; if (isBoundsLiteral(bounds)) { return new google.maps.LatLngBounds( { lat: bounds.south, lng: bounds.west }, { lat: bounds.north, lng: bounds.east } ); } if (bounds.length === 0) return null; const result = new google.maps.LatLngBounds(); bounds.forEach(point => result.extend(point)); return result; } /** * Applies the bounds and clamps the zoom afterwards. * * `fitBounds` is asynchronous — the zoom it settles on is only readable once the * camera comes to rest — so `maxZoomOnFit` is enforced from a one-shot `idle` * listener rather than immediately after the call. */ function applyBounds( map: google.maps.Map, bounds: google.maps.LatLngBounds, padding: number, maxZoom?: number ) { map.fitBounds(bounds, padding); if (typeof maxZoom !== 'number') return; const listener = google.maps.event.addListenerOnce(map, 'idle', () => { const zoom = map.getZoom(); if (typeof zoom === 'number' && zoom > maxZoom) map.setZoom(maxZoom); }); return () => listener.remove(); } export interface UseMapFitOptions { map: google.maps.Map | null; /** Explicit frame. Takes precedence over `fitTo`. */ bounds?: MapBounds; /** @default 'none' — the map keeps whatever `center`/`zoom` say. */ fitTo?: MapFitTarget; /** Pixels of breathing room around the fitted content. @default 34 */ fitPadding?: number; /** Never zoom in past this level when fitting. */ maxZoomOnFit?: number; /** Positions contributed by `markers`. */ markerPositions: LatLng[]; /** Positions contributed by circles, polygons and other geometry. */ shapePositions: LatLng[]; views?: MapView[]; activeViewId?: string; } /** * Keeps the camera aligned with the data. * * @description * Seven of the sixteen surveyed applications re-frame the map on every filter * change and keep a "back to overview" button beside it. With only `center` and * `zoom` available, each filter change left the viewport pointing somewhere the * data no longer was. * * The effect re-runs when the fitted positions change, which is what makes * "filter the data, the map follows" work without any imperative call. An active * `views` entry always wins over automatic fitting — an explicit user choice * should not be overridden by the next data refresh. */ export function useMapFit({ map, bounds, fitTo = 'none', fitPadding = 34, maxZoomOnFit, markerPositions, shapePositions, views, activeViewId, }: UseMapFitOptions) { // Serialize positions so a new-but-equal array does not re-frame the camera // (and yank the viewport) on every parent render. const markerKey = markerPositions.map(p => `${p.lat},${p.lng}`).join('|'); const shapeKey = shapePositions.map(p => `${p.lat},${p.lng}`).join('|'); const boundsKey = bounds ? JSON.stringify(bounds) : ''; useEffect(() => { if (!map || typeof google === 'undefined') return; const activeView = activeViewId ? views?.find(view => view.id === activeViewId) : undefined; if (activeView) { if (activeView.bounds) { const viewBounds = toLatLngBounds(activeView.bounds); if (viewBounds) return applyBounds(map, viewBounds, fitPadding, maxZoomOnFit); return; } if (activeView.center) map.setCenter(activeView.center); if (typeof activeView.zoom === 'number') map.setZoom(activeView.zoom); return; } if (bounds) { const explicit = toLatLngBounds(bounds); if (explicit) return applyBounds(map, explicit, fitPadding, maxZoomOnFit); return; } if (fitTo === 'none') return; const positions = fitTo === 'markers' ? markerPositions : fitTo === 'shapes' ? shapePositions : [...markerPositions, ...shapePositions]; if (positions.length === 0) return; const computed = toLatLngBounds(positions); if (computed) return applyBounds(map, computed, fitPadding, maxZoomOnFit); // eslint-disable-next-line react-hooks/exhaustive-deps }, [map, boundsKey, fitTo, fitPadding, maxZoomOnFit, markerKey, shapeKey, activeViewId, views]); }