266 lines
7.4 KiB
TypeScript
266 lines
7.4 KiB
TypeScript
/**
|
|
* useMapInstance - Hook for managing MapLibre GL JS instance
|
|
*
|
|
* Provides:
|
|
* - Map initialization and cleanup
|
|
* - Viewport state management
|
|
* - Event handlers (click, move, zoom)
|
|
* - Ref to underlying map instance for advanced usage
|
|
*/
|
|
|
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import maplibregl from 'maplibre-gl';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
import type { MapViewport, Coordinate, TileServiceConfig } from '../types';
|
|
|
|
interface UseMapInstanceOptions {
|
|
container: HTMLElement | null;
|
|
config: TileServiceConfig;
|
|
initialViewport?: MapViewport;
|
|
onViewportChange?: (viewport: MapViewport) => void;
|
|
onClick?: (coordinate: Coordinate, event: maplibregl.MapMouseEvent) => void;
|
|
onDoubleClick?: (coordinate: Coordinate, event: maplibregl.MapMouseEvent) => void;
|
|
onMoveStart?: () => void;
|
|
onMoveEnd?: (viewport: MapViewport) => void;
|
|
interactive?: boolean;
|
|
}
|
|
|
|
interface UseMapInstanceReturn {
|
|
isLoaded: boolean;
|
|
error: Error | null;
|
|
viewport: MapViewport;
|
|
setViewport: (viewport: MapViewport) => void;
|
|
flyTo: (coordinate: Coordinate, zoom?: number, options?: maplibregl.FlyToOptions) => void;
|
|
fitBounds: (bounds: [[number, number], [number, number]], options?: maplibregl.FitBoundsOptions) => void;
|
|
getMap: () => maplibregl.Map | null;
|
|
resize: () => void;
|
|
}
|
|
|
|
const DEFAULT_VIEWPORT: MapViewport = {
|
|
center: { lat: 40.7128, lng: -74.006 }, // NYC default
|
|
zoom: 10,
|
|
bearing: 0,
|
|
pitch: 0,
|
|
};
|
|
|
|
// Default style using OpenStreetMap tiles via MapLibre
|
|
const DEFAULT_STYLE: maplibregl.StyleSpecification = {
|
|
version: 8,
|
|
sources: {
|
|
'osm-raster': {
|
|
type: 'raster',
|
|
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
|
tileSize: 256,
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
},
|
|
},
|
|
layers: [
|
|
{
|
|
id: 'osm-raster-layer',
|
|
type: 'raster',
|
|
source: 'osm-raster',
|
|
minzoom: 0,
|
|
maxzoom: 19,
|
|
},
|
|
],
|
|
};
|
|
|
|
export function useMapInstance({
|
|
container,
|
|
config,
|
|
initialViewport = DEFAULT_VIEWPORT,
|
|
onViewportChange,
|
|
onClick,
|
|
onDoubleClick,
|
|
onMoveStart,
|
|
onMoveEnd,
|
|
interactive = true,
|
|
}: UseMapInstanceOptions): UseMapInstanceReturn {
|
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [viewport, setViewportState] = useState<MapViewport>(initialViewport);
|
|
|
|
// Initialize map
|
|
useEffect(() => {
|
|
if (!container) return;
|
|
|
|
// Prevent double initialization
|
|
if (mapRef.current) {
|
|
mapRef.current.remove();
|
|
mapRef.current = null;
|
|
}
|
|
|
|
try {
|
|
const style = config.styleUrl || DEFAULT_STYLE;
|
|
|
|
const map = new maplibregl.Map({
|
|
container,
|
|
style,
|
|
center: [initialViewport.center.lng, initialViewport.center.lat],
|
|
zoom: initialViewport.zoom,
|
|
bearing: initialViewport.bearing,
|
|
pitch: initialViewport.pitch,
|
|
interactive,
|
|
attributionControl: false,
|
|
maxZoom: config.maxZoom ?? 19,
|
|
});
|
|
|
|
mapRef.current = map;
|
|
|
|
// Handle map load
|
|
map.on('load', () => {
|
|
setIsLoaded(true);
|
|
setError(null);
|
|
});
|
|
|
|
// Handle map errors
|
|
map.on('error', (e) => {
|
|
console.error('MapLibre error:', e);
|
|
setError(new Error(e.error?.message || 'Map error occurred'));
|
|
});
|
|
|
|
// Handle viewport changes
|
|
map.on('move', () => {
|
|
const center = map.getCenter();
|
|
const newViewport: MapViewport = {
|
|
center: { lat: center.lat, lng: center.lng },
|
|
zoom: map.getZoom(),
|
|
bearing: map.getBearing(),
|
|
pitch: map.getPitch(),
|
|
};
|
|
setViewportState(newViewport);
|
|
onViewportChange?.(newViewport);
|
|
});
|
|
|
|
// Handle move start/end
|
|
map.on('movestart', () => {
|
|
onMoveStart?.();
|
|
});
|
|
|
|
map.on('moveend', () => {
|
|
const center = map.getCenter();
|
|
const finalViewport: MapViewport = {
|
|
center: { lat: center.lat, lng: center.lng },
|
|
zoom: map.getZoom(),
|
|
bearing: map.getBearing(),
|
|
pitch: map.getPitch(),
|
|
};
|
|
onMoveEnd?.(finalViewport);
|
|
});
|
|
|
|
// Handle click events
|
|
map.on('click', (e) => {
|
|
onClick?.({ lat: e.lngLat.lat, lng: e.lngLat.lng }, e);
|
|
});
|
|
|
|
map.on('dblclick', (e) => {
|
|
onDoubleClick?.({ lat: e.lngLat.lat, lng: e.lngLat.lng }, e);
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('Failed to initialize MapLibre:', err);
|
|
setError(err instanceof Error ? err : new Error('Failed to initialize map'));
|
|
}
|
|
|
|
return () => {
|
|
if (mapRef.current) {
|
|
mapRef.current.remove();
|
|
mapRef.current = null;
|
|
setIsLoaded(false);
|
|
}
|
|
};
|
|
}, [container]); // Only re-init if container changes
|
|
|
|
// Update viewport when props change (external control)
|
|
useEffect(() => {
|
|
if (!mapRef.current || !isLoaded) return;
|
|
|
|
const map = mapRef.current;
|
|
const currentCenter = map.getCenter();
|
|
const currentZoom = map.getZoom();
|
|
const currentBearing = map.getBearing();
|
|
const currentPitch = map.getPitch();
|
|
|
|
// Only update if significantly different to avoid feedback loops
|
|
const centerChanged =
|
|
Math.abs(currentCenter.lat - initialViewport.center.lat) > 0.0001 ||
|
|
Math.abs(currentCenter.lng - initialViewport.center.lng) > 0.0001;
|
|
const zoomChanged = Math.abs(currentZoom - initialViewport.zoom) > 0.01;
|
|
const bearingChanged = Math.abs(currentBearing - initialViewport.bearing) > 0.1;
|
|
const pitchChanged = Math.abs(currentPitch - initialViewport.pitch) > 0.1;
|
|
|
|
if (centerChanged || zoomChanged || bearingChanged || pitchChanged) {
|
|
map.jumpTo({
|
|
center: [initialViewport.center.lng, initialViewport.center.lat],
|
|
zoom: initialViewport.zoom,
|
|
bearing: initialViewport.bearing,
|
|
pitch: initialViewport.pitch,
|
|
});
|
|
}
|
|
}, [initialViewport, isLoaded]);
|
|
|
|
const setViewport = useCallback(
|
|
(newViewport: MapViewport) => {
|
|
setViewportState(newViewport);
|
|
onViewportChange?.(newViewport);
|
|
|
|
if (mapRef.current && isLoaded) {
|
|
mapRef.current.jumpTo({
|
|
center: [newViewport.center.lng, newViewport.center.lat],
|
|
zoom: newViewport.zoom,
|
|
bearing: newViewport.bearing,
|
|
pitch: newViewport.pitch,
|
|
});
|
|
}
|
|
},
|
|
[isLoaded, onViewportChange]
|
|
);
|
|
|
|
const flyTo = useCallback(
|
|
(coordinate: Coordinate, zoom?: number, options?: maplibregl.FlyToOptions) => {
|
|
if (!mapRef.current || !isLoaded) return;
|
|
|
|
mapRef.current.flyTo({
|
|
center: [coordinate.lng, coordinate.lat],
|
|
zoom: zoom ?? mapRef.current.getZoom(),
|
|
...options,
|
|
});
|
|
},
|
|
[isLoaded]
|
|
);
|
|
|
|
const fitBounds = useCallback(
|
|
(bounds: [[number, number], [number, number]], options?: maplibregl.FitBoundsOptions) => {
|
|
if (!mapRef.current || !isLoaded) return;
|
|
|
|
mapRef.current.fitBounds(bounds, {
|
|
padding: 50,
|
|
...options,
|
|
});
|
|
},
|
|
[isLoaded]
|
|
);
|
|
|
|
const getMap = useCallback(() => mapRef.current, []);
|
|
|
|
const resize = useCallback(() => {
|
|
if (mapRef.current && isLoaded) {
|
|
mapRef.current.resize();
|
|
}
|
|
}, [isLoaded]);
|
|
|
|
return {
|
|
isLoaded,
|
|
error,
|
|
viewport,
|
|
setViewport,
|
|
flyTo,
|
|
fitBounds,
|
|
getMap,
|
|
resize,
|
|
};
|
|
}
|
|
|
|
export default useMapInstance;
|