Skip to content

Commit

Permalink
refactor: introduce initialCameraParams
Browse files Browse the repository at this point in the history
  • Loading branch information
usefulthink committed Feb 5, 2024
1 parent e7b4859 commit bfdfeff
Show file tree
Hide file tree
Showing 12 changed files with 217 additions and 157 deletions.
1 change: 0 additions & 1 deletion examples/multiple-maps/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ const App = () => {
mapId={'49ae42fed52588c3'}
disableDefaultUI
onCameraChanged={isActive ? handleCameraChange : undefined}
controlled={!isActive}
onMouseover={() => setActiveMap(i)}
{...cameraState}></Map>
);
Expand Down
4 changes: 4 additions & 0 deletions src/components/__tests__/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ beforeEach(() => {
super(...args);
}
};

// no idea why the implementation in @googlemaps/jest-mocks doesn't work as it is,
// but this helps:
google.maps.event.addListener = jest.fn(() => ({remove: jest.fn()}));
});

afterEach(() => {
Expand Down
139 changes: 40 additions & 99 deletions src/components/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,14 @@
import React, {
CSSProperties,
PropsWithChildren,
Ref,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState
useMemo
} from 'react';

import {APIProviderContext, APIProviderContextValue} from '../api-provider';
import {APIProviderContext} from '../api-provider';

import {useApiIsLoaded} from '../../hooks/use-api-is-loaded';
import {logErrorOnce} from '../../libraries/errors';
import {useCallbackRef} from '../../libraries/use-callback-ref';
import {MapEventProps, useMapEvents} from './use-map-events';
import {useMapOptions} from './use-map-options';
import {useTrackedCameraStateRef} from './use-tracked-camera-state-ref';
Expand All @@ -24,9 +19,10 @@ import {
DeckGlCompatProps,
useDeckGLCameraUpdate
} from './use-deckgl-camera-update';
import {isLatLngLiteral} from '../../libraries/is-lat-lng-literal';
import {toLatLngLiteral} from '../../libraries/lat-lng-utils';
import {useMapCameraParams} from './use-map-camera-params';
import {AuthFailureMessage} from './auth-failure-message';
import {useMapInstance} from './use-map-instance';

export interface GoogleMapsContextValue {
map: google.maps.Map | null;
Expand All @@ -44,8 +40,8 @@ export type {
export type MapCameraProps = {
center: google.maps.LatLngLiteral;
zoom: number;
heading: number;
tilt: number;
heading?: number;
tilt?: number;
};

/**
Expand All @@ -54,16 +50,35 @@ export type MapCameraProps = {
export type MapProps = google.maps.MapOptions &
MapEventProps &
DeckGlCompatProps & {
/**
* An id for the map, is required when multiple maps are present in the same APIProvider context.
*/
id?: string;
/**
* Additional style rules to apply to the map dom-element.
*/
style?: CSSProperties;
/**
* Additional css class-name to apply to the element containing the map.
*/
className?: string;
initialBounds?: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral;
/**
* Indicates that the map will be controlled externally. Disables all controls provided by the map itself.
*/
controlled?: boolean;
/**
* The initial parameters for the camera. If specified, the map will be in uncontrolled mode and
* will ignore the regular camera parameters (center/zoom/heading/tilt).
*/
initialCameraProps?: MapCameraProps;
/**
* Alternative way to specify the initialCameraProps as geographic region that should be visible
*/
initialBounds?: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral;
};

export const Map = (props: PropsWithChildren<MapProps>) => {
const {children, id, className, style, controlled = false} = props;

const {children, id, className, style} = props;
const context = useContext(APIProviderContext);
const loadingStatus = useApiLoadingStatus();

Expand All @@ -74,15 +89,16 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
}

const [map, mapRef] = useMapInstance(props, context);
const cameraStateRef = useTrackedCameraStateRef();
const cameraStateRef = useTrackedCameraStateRef(map);

useMapCameraParams(map, cameraStateRef, props);
useMapEvents(map, cameraStateRef, props);
useMapEvents(map, props);
useMapOptions(map, props);

const isDeckGlControlled = useDeckGLCameraUpdate(map, props);
const isControlledExternally = !!props.controlled;

// disable interactions with the map for controlled modes
// disable interactions with the map for externally controlled maps
useEffect(() => {
if (!map) return;

Expand All @@ -93,8 +109,8 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
map.setOptions({disableDefaultUI: true});
}

// disable all control-inputs when map is controlled
if (isDeckGlControlled || controlled) {
// disable all control-inputs when the map is controlled externally
if (isDeckGlControlled || isControlledExternally) {
map.setOptions({
gestureHandling: 'none',
keyboardShortcuts: false
Expand All @@ -110,18 +126,13 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
}, [
map,
isDeckGlControlled,
controlled,
isControlledExternally,
props.gestureHandling,
props.keyboardShortcuts
]);

// in controlled mode, any change to the camera state that isn't reflected in the props has to be prevented
const center = props.center
? isLatLngLiteral(props.center)
? props.center
: props.center.toJSON()
: null;

// setup a stable cameraOptions object that can be used as dependency
const center = props.center ? toLatLngLiteral(props.center) : null;
let lat: number | null = null;
let lng: number | null = null;
if (center && Number.isFinite(center.lat) && Number.isFinite(center.lng)) {
Expand All @@ -138,17 +149,17 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
};
}, [lat, lng, props.zoom, props.heading, props.tilt]);

// controlled mode: reject all camera changes that don't correspond to changes in props
// externally controlled mode: reject all camera changes that don't correspond to changes in props
useLayoutEffect(() => {
if (!map || !controlled) return;
if (!map || !isControlledExternally) return;

map.moveCamera(cameraOptions);
const listener = map.addListener('bounds_changed', () => {
map.moveCamera(cameraOptions);
});

return () => listener.remove();
}, [map, controlled, cameraOptions]);
}, [map, isControlledExternally, cameraOptions]);

const combinedStyle: CSSProperties = useMemo(
() => ({
Expand Down Expand Up @@ -188,73 +199,3 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
);
};
Map.deckGLViewProps = true;

/**
* The main hook takes care of creating map-instances and registering them in
* the api-provider context.
* @return a tuple of the map-instance created (or null) and the callback
* ref that will be used to pass the map-container into this hook.
* @internal
*/
function useMapInstance(
props: MapProps,
context: APIProviderContextValue
): readonly [map: google.maps.Map | null, containerRef: Ref<HTMLDivElement>] {
const apiIsLoaded = useApiIsLoaded();
const [map, setMap] = useState<google.maps.Map | null>(null);
const [container, containerRef] = useCallbackRef<HTMLDivElement>();

const {
id,
initialBounds,

...mapOptions
} = props;

// create the map instance and register it in the context
useEffect(
() => {
if (!container || !apiIsLoaded) return;

const {addMapInstance, removeMapInstance} = context;
const newMap = new google.maps.Map(container, mapOptions);

setMap(newMap);
addMapInstance(newMap, id);

if (initialBounds) {
newMap.fitBounds(initialBounds);
}

return () => {
if (!container || !apiIsLoaded) return;

// remove all event-listeners to minimize memory-leaks
google.maps.event.clearInstanceListeners(newMap);

setMap(null);
removeMapInstance(id);
};
},

// eslint-disable-next-line react-hooks/exhaustive-deps
[id, container, apiIsLoaded, props.mapId]
);

// report an error if the same map-id is used multiple times
useEffect(() => {
if (!id) return;

const {mapInstances} = context;

if (mapInstances[id] && mapInstances[id] !== map) {
logErrorOnce(
`The map id '${id}' seems to have been used multiple times. ` +
'This can lead to unexpected problems when accessing the maps. ' +
'Please use unique ids for all <Map> components.'
);
}
}, [id, context, map]);

return [map, containerRef] as const;
}
4 changes: 2 additions & 2 deletions src/components/map/use-deckgl-camera-update.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useLayoutEffect, useMemo} from 'react';
import {useLayoutEffect} from 'react';

export type DeckGlCompatProps = {
/**
Expand All @@ -24,7 +24,7 @@ export function useDeckGLCameraUpdate(
props: DeckGlCompatProps
) {
const {viewport, viewState} = props;
const isDeckGlControlled = useMemo(() => Boolean(viewport), [viewport]);
const isDeckGlControlled = !!viewport;

useLayoutEffect(() => {
if (!map || !viewState) return;
Expand Down
27 changes: 13 additions & 14 deletions src/components/map/use-map-camera-params.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import {useLayoutEffect} from 'react';
import {CameraStateRef} from './use-tracked-camera-state-ref';
import {MapProps} from '@vis.gl/react-google-maps';
import {isLatLngLiteral} from '../../libraries/is-lat-lng-literal';
import {toLatLngLiteral} from '../../libraries/lat-lng-utils';
import {MapProps} from '../map';

export function useMapCameraParams(
map: google.maps.Map | null,
cameraStateRef: CameraStateRef,
mapProps: MapProps
) {
const center = mapProps.center
? isLatLngLiteral(mapProps.center)
? mapProps.center
: mapProps.center.toJSON()
: null;
const center = mapProps.center ? toLatLngLiteral(mapProps.center) : null;

let lat: number | null = null;
let lng: number | null = null;
Expand All @@ -32,15 +28,18 @@ export function useMapCameraParams(
? (mapProps.tilt as number)
: null;

/* 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 via mapOptions.
*/
// the following effect runs for every render of the map component and checks
// if there are differences between the known state of the map instance
// (cameraStateRef, which is updated by all bounds_changed events) and the
// desired state in the props.

useLayoutEffect(() => {
if (!map) return;

// when the map was configured with an initialCameraProps instead
// of center/zoom/..., we skip all camera updates here
if (mapProps.initialCameraProps) return;

const nextCamera: google.maps.CameraOptions = {};
let needsUpdate = false;

Expand Down Expand Up @@ -72,5 +71,5 @@ export function useMapCameraParams(
if (needsUpdate) {
map.moveCamera(nextCamera);
}
}, [cameraStateRef, lat, lng, zoom, heading, tilt]);
});
}
12 changes: 2 additions & 10 deletions src/components/map/use-map-events.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {useEffect} from 'react';
import {
CameraStateRef,
trackDispatchedEvent
} from './use-tracked-camera-state-ref';

/**
* Handlers for all events that could be emitted by map-instances.
Expand Down Expand Up @@ -45,7 +41,6 @@ export type MapEventProps = Partial<{
*/
export function useMapEvents(
map: google.maps.Map | null,
cameraStateRef: CameraStateRef,
props: MapEventProps
) {
// note: calling a useEffect hook from within a loop is prohibited by the
Expand All @@ -68,15 +63,12 @@ export function useMapEvents(
map,
eventType,
(ev?: google.maps.MapMouseEvent | google.maps.IconMouseEvent) => {
const mapEvent = createMapEvent(eventType, map, ev);
trackDispatchedEvent(mapEvent, cameraStateRef);

handler(mapEvent);
handler(createMapEvent(eventType, map, ev));
}
);

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

Expand Down
Loading

0 comments on commit bfdfeff

Please sign in to comment.