diff --git a/src/components/map.tsx b/src/components/map.tsx index 139ac01..5a1f175 100644 --- a/src/components/map.tsx +++ b/src/components/map.tsx @@ -19,50 +19,119 @@ import {useCallbackRef} from '../libraries/use-callback-ref'; export interface GoogleMapsContextValue { map: google.maps.Map | null; } - export const GoogleMapsContext = React.createContext(null); /** - * Props for the Google Maps Map Component + * Handlers for all events that could be emitted by map-instances.ß */ -export type MapProps = google.maps.MapOptions & { - style?: CSSProperties; - /** - * Adds custom style to the map by passing a css class. - */ - className?: string; - /** - * Adds initial bounds to the map as an alternative to specifying the center/zoom of the map. - * Calls the fitBounds method internally https://developers.google.com/maps/documentation/javascript/reference/map?hl=en#Map-Methods - */ - initialBounds?: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral; - /** - * An id that is added to the map. Needed when using more than one Map component. - * This is also needed to reference the map inside the useMap hook. - */ - id?: string; - /** - * Viewport from deck.gl - */ - viewport?: unknown; - /** - * View state from deck.gl - */ - viewState?: Record; - /** - * Initial View State from deck.gl - */ - initialViewState?: Record; +type MapEventProps = { + // map view state events + onBoundsChanged: () => void; + onCenterChanged: () => void; + onHeadingChanged: () => void; + onTiltChanged: () => void; + onZoomChanged: () => void; + onProjectionChanged: () => void; + + // mouse / touch / pointer events + onClick: ( + event: google.maps.MapMouseEvent | google.maps.IconMouseEvent + ) => void; + onDblclick: (event: google.maps.MapMouseEvent) => void; + onContextmenu: (event: google.maps.MapMouseEvent) => void; + onMousemove: (event: google.maps.MapMouseEvent) => void; + onMouseover: (event: google.maps.MapMouseEvent) => void; + onMouseout: (event: google.maps.MapMouseEvent) => void; + onDrag: () => void; + onDragend: () => void; + onDragstart: () => void; + + // loading events + onTilesloaded: () => void; + onIdle: () => void; + + // configuration events + onIsFractionalZoomEnabledChanged: () => void; + onMapCapabilitiesChanged: () => void; + onMapTypeIdChanged: () => void; + onRenderingtypeChanged: () => void; }; +/** + * Maps the camelCased names of event-props to the corresponding event-types + * used in the maps API. + */ +const propNameToEventType: {[prop in keyof MapEventProps]: string} = { + onBoundsChanged: 'bounds_changed', + onCenterChanged: 'center_changed', + onClick: 'click', + onContextmenu: 'contextmenu', + onDblclick: 'dblclick', + onDrag: 'drag', + onDragend: 'dragend', + onDragstart: 'dragstart', + onHeadingChanged: 'heading_changed', + onIdle: 'idle', + onIsFractionalZoomEnabledChanged: 'isfractionalzoomenabled_changed', + onMapCapabilitiesChanged: 'mapcapabilities_changed', + onMapTypeIdChanged: 'maptypeid_changed', + onMousemove: 'mousemove', + onMouseout: 'mouseout', + onMouseover: 'mouseover', + onProjectionChanged: 'projection_changed', + onRenderingtypeChanged: 'renderingtype_changed', + onTilesloaded: 'tilesloaded', + onTiltChanged: 'tilt_changed', + onZoomChanged: 'zoom_changed' +} as const; + +type MapEventPropName = keyof MapEventProps; +const eventPropNames = Object.freeze( + Object.keys(propNameToEventType) as MapEventPropName[] +); + +/** + * Props for the Google Maps Map Component + */ +export type MapProps = google.maps.MapOptions & + MapEventProps & { + style?: CSSProperties; + /** + * Adds custom style to the map by passing a css class. + */ + className?: string; + /** + * Adds initial bounds to the map as an alternative to specifying the center/zoom of the map. + * Calls the fitBounds method internally https://developers.google.com/maps/documentation/javascript/reference/map?hl=en#Map-Methods + */ + initialBounds?: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral; + /** + * An id that is added to the map. Needed when using more than one Map component. + * This is also needed to reference the map inside the useMap hook. + */ + id?: string; + /** + * Viewport from deck.gl + */ + viewport?: unknown; + /** + * View state from deck.gl + */ + viewState?: Record; + /** + * Initial View State from deck.gl + */ + initialViewState?: Record; + }; + /** * Component to render a Google Maps map */ export const Map = (props: PropsWithChildren) => { const {children, id, className, style, viewState, viewport} = props; - const context = useContext(APIProviderContext) as APIProviderContextValue; + const context = useContext(APIProviderContext); if (!context) { throw new Error( @@ -70,8 +139,9 @@ export const Map = (props: PropsWithChildren) => { ); } - const [map, mapRef] = useMapInstanceHandlerEffects(props, context); + const [map, mapRef] = useMapInstanceEffects(props, context); useMapOptionsEffects(map, props); + useMapEvents(map, props); useDeckGLCameraUpdateEffect(map, viewState); const isViewportSet = useMemo(() => Boolean(viewport), [viewport]); @@ -111,7 +181,7 @@ Map.deckGLViewProps = true; * ref that will be used to pass the map-container into this hook. * @internal */ -function useMapInstanceHandlerEffects( +function useMapInstanceEffects( props: MapProps, context: APIProviderContextValue ): readonly [map: google.maps.Map | null, containerRef: Ref] { @@ -144,7 +214,7 @@ function useMapInstanceHandlerEffects( if (!container || !apiIsLoaded) return; // remove all event-listeners to minimize memory-leaks - google.maps.event.clearInstanceListeners(container); + google.maps.event.clearInstanceListeners(newMap); setMap(null); removeMapInstance(id); @@ -182,12 +252,17 @@ function useMapInstanceHandlerEffects( /** * Internal hook to update the map-options and view-parameters when * props are changed. + * @internal */ function useMapOptionsEffects(map: google.maps.Map | null, mapProps: MapProps) { const {center, zoom, heading, tilt, ...mapOptions} = mapProps; - // All of these effects aren't triggered when the map is changed. - // In that case, the values have already been passed to the map constructor. + /* eslint-disable react-hooks/exhaustive-deps -- + * + * The following effects aren't triggered when the map is changed. + * In that case, the values will be or have been passed to the map + * constructor as mapOptions. + */ // update the map options when mapOptions is changed useEffect(() => { @@ -223,6 +298,37 @@ function useMapOptionsEffects(map: google.maps.Map | null, mapProps: MapProps) { map.setTilt(tilt as number); }, [tilt]); + /* eslint-enable react-hooks/exhaustive-deps */ +} + +/** + * Sets up effects to bind event-handlers for all event-props in MapEventProps. + * @internal + */ +function useMapEvents(map: google.maps.Map | null, props: MapEventProps) { + // note: calling a useEffect hook from within a loop is prohibited by the + // rules of hooks, but it's ok here since it's unconditional and the number + // and order of iterations is always strictly the same. + // (see https://legacy.reactjs.org/docs/hooks-rules.html) + + for (const propName of eventPropNames) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect( + () => { + if (!map) return; + if (!props[propName]) return; + + const listener = map.addListener( + propNameToEventType[propName], + props[propName] + ); + + return () => listener.remove(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props[propName]] + ); + } } /**