Skip to content

Commit

Permalink
feat: implement props for all map-events with custom MapEvent type
Browse files Browse the repository at this point in the history
  • Loading branch information
usefulthink committed Nov 9, 2023
1 parent d5e7dfd commit 820a301
Showing 1 changed file with 247 additions and 36 deletions.
283 changes: 247 additions & 36 deletions src/components/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,59 +19,126 @@ import {useCallbackRef} from '../libraries/use-callback-ref';
export interface GoogleMapsContextValue {
map: google.maps.Map | null;
}

export const GoogleMapsContext =
React.createContext<GoogleMapsContextValue | null>(null);
/**
* Handlers for all events that could be emitted by map-instances.
*/
type MapEventProps = Partial<{
// map view state events
onBoundsChanged: (event: MapCameraChangedEvent) => void;
onCenterChanged: (event: MapCameraChangedEvent) => void;
onHeadingChanged: (event: MapCameraChangedEvent) => void;
onTiltChanged: (event: MapCameraChangedEvent) => void;
onZoomChanged: (event: MapCameraChangedEvent) => void;
onProjectionChanged: (event: MapCameraChangedEvent) => void;

// mouse / touch / pointer events
onClick: (event: MapMouseEvent) => void;
onDblclick: (event: MapMouseEvent) => void;
onContextmenu: (event: MapMouseEvent) => void;
onMousemove: (event: MapMouseEvent) => void;
onMouseover: (event: MapMouseEvent) => void;
onMouseout: (event: MapMouseEvent) => void;
onDrag: (event: MapEvent) => void;
onDragend: (event: MapEvent) => void;
onDragstart: (event: MapEvent) => void;

// loading events
onTilesLoaded: (event: MapEvent) => void;
onIdle: (event: MapEvent) => void;

// configuration events
onIsFractionalZoomEnabledChanged: (event: MapEvent) => void;
onMapCapabilitiesChanged: (event: MapEvent) => void;
onMapTypeIdChanged: (event: MapEvent) => void;
onRenderingTypeChanged: (event: MapEvent) => void;
}>;

/**
* Maps the camelCased names of event-props to the corresponding event-types
* used in the maps API.
*/
const propNameToEventType: {[prop in keyof Required<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 & {
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<string, unknown>;
/**
* Initial View State from deck.gl
*/
initialViewState?: Record<string, unknown>;
};
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<string, unknown>;
/**
* Initial View State from deck.gl
*/
initialViewState?: Record<string, unknown>;
};

/**
* Component to render a Google Maps map
*/
export const Map = (props: PropsWithChildren<MapProps>) => {
const {children, id, className, style, viewState, viewport} = props;

const context = useContext(APIProviderContext) as APIProviderContextValue;
const context = useContext(APIProviderContext);

if (!context) {
throw new Error(
'<Map> can only be used inside an <ApiProvider> component.'
);
}

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]);
Expand Down Expand Up @@ -111,7 +178,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<HTMLDivElement>] {
Expand Down Expand Up @@ -144,7 +211,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);
Expand Down Expand Up @@ -182,12 +249,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(() => {
Expand Down Expand Up @@ -223,6 +295,40 @@ 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) {
// fixme: this cast is essentially a 'trust me, bro' for typescript, but
// a proper solution seems way too complicated right now
const handler = props[propName] as (ev: MapEvent) => void;
const eventType = propNameToEventType[propName];

// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!map) return;
if (!handler) return;

const listener = map.addListener(
eventType,
(ev?: google.maps.MapMouseEvent | google.maps.IconMouseEvent) => {
handler(createMapEvent(eventType, map, ev));
}
);

return () => listener.remove();
}, [map, eventType, handler]);
}
}

/**
Expand Down Expand Up @@ -263,3 +369,108 @@ function useDeckGLCameraUpdateEffect(
});
}, [map, viewState]);
}

export type MapEvent<T = unknown> = {
type: string;
map: google.maps.Map;
detail: T;

stoppable: boolean;
stop: () => void;
domEvent?: MouseEvent | TouchEvent | PointerEvent | KeyboardEvent | Event;
};

export type MapMouseEvent = MapEvent<{
latLng: google.maps.LatLngLiteral | null;
placeId: string | null;
}>;

export type MapCameraChangedEvent = MapEvent<{
center: google.maps.LatLngLiteral;
bounds: google.maps.LatLngBoundsLiteral;
zoom: number;
heading: number;
tilt: number;
}>;

const cameraEventTypes = [
'bounds_changed',
'center_changed',
'heading_changed',
'projection_changed',
'tilt_changed',
'zoom_changed'
];

const mouseEventTypes = [
'click',
'contextmenu',
'dblclick',
'mousemove',
'mouseout',
'mouseover'
];

export function createMapEvent(
type: string,
map: google.maps.Map,
srcEvent?: google.maps.MapMouseEvent | google.maps.IconMouseEvent
): MapEvent {
const ev: MapEvent = {
type,
map,
detail: {},
stoppable: false,
stop: () => {}
};

if (cameraEventTypes.includes(type)) {
const camEvent = ev as MapCameraChangedEvent;

const center = map.getCenter();
const zoom = map.getZoom();
const heading = map.getHeading() || 0;
const tilt = map.getTilt() || 0;
const bounds = map.getBounds();

if (!center || !bounds || !Number.isFinite(zoom)) {
console.warn(
'[createEvent] at least one of the values from the map ' +
'returned undefined. This is not expected to happen. Please ' +
'report an issue at https://github.com/visgl/react-google-maps/issues/new'
);
}

camEvent.detail = {
center: center?.toJSON() || {lat: 0, lng: 0},
zoom: zoom as number,
heading: heading as number,
tilt: tilt as number,
bounds: bounds?.toJSON() || {
north: 90,
east: 180,
south: -90,
west: -180
}
};

return camEvent;
} else if (mouseEventTypes.includes(type)) {
if (!srcEvent)
throw new Error('[createEvent] mouse events must provide a srcEvent');
const mouseEvent = ev as MapMouseEvent;

mouseEvent.domEvent = srcEvent.domEvent;
mouseEvent.stoppable = true;
mouseEvent.stop = () => srcEvent.stop();

mouseEvent.detail = {
latLng: srcEvent.latLng?.toJSON() || null,
placeId: (srcEvent as google.maps.IconMouseEvent).placeId
};

return mouseEvent;
}

return ev;
}

0 comments on commit 820a301

Please sign in to comment.