@vis.gl/react-google-maps
Version:
React components and hooks for the Google Maps JavaScript API
1,345 lines (1,316 loc) • 74.1 kB
JavaScript
import React, { useMemo, useState, useReducer, useCallback, useEffect, useRef, useContext, useLayoutEffect, forwardRef, useImperativeHandle, Children } from 'react';
import { createPortal } from 'react-dom';
import isDeepEqual from 'fast-deep-equal';
function _extends() {
return _extends = Object.assign ? Object.assign.bind() : function (n) {
for (var e = 1; e < arguments.length; e++) {
var t = arguments[e];
for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);
}
return n;
}, _extends.apply(null, arguments);
}
function _objectWithoutPropertiesLoose(r, e) {
if (null == r) return {};
var t = {};
for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
if (-1 !== e.indexOf(n)) continue;
t[n] = r[n];
}
return t;
}
function _toPrimitive(t, r) {
if ("object" != typeof t || !t) return t;
var e = t[Symbol.toPrimitive];
if (void 0 !== e) {
var i = e.call(t, r || "default");
if ("object" != typeof i) return i;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return ("string" === r ? String : Number)(t);
}
function _toPropertyKey(t) {
var i = _toPrimitive(t, "string");
return "symbol" == typeof i ? i : i + "";
}
const APILoadingStatus = {
NOT_LOADED: 'NOT_LOADED',
LOADING: 'LOADING',
LOADED: 'LOADED',
FAILED: 'FAILED',
AUTH_FAILURE: 'AUTH_FAILURE'
};
const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js';
/**
* A GoogleMapsApiLoader to reliably load and unload the Google Maps JavaScript API.
*
* The actual loading and unloading is delayed into the microtask queue, to
* allow using the API in an useEffect hook, without worrying about multiple API loads.
*/
class GoogleMapsApiLoader {
/**
* Loads the Maps JavaScript API with the specified parameters.
* Since the Maps library can only be loaded once per page, this will
* produce a warning when called multiple times with different
* parameters.
*
* The returned promise resolves when loading completes
* and rejects in case of an error or when the loading was aborted.
*/
static async load(params, onLoadingStatusChange) {
var _window$google;
const libraries = params.libraries ? params.libraries.split(',') : [];
const serializedParams = this.serializeParams(params);
this.listeners.push(onLoadingStatusChange);
// Note: if `google.maps.importLibrary` has been defined externally, we
// assume that loading is complete and successful.
// If it was defined by a previous call to this method, a warning
// message is logged if there are differences in api-parameters used
// for both calls.
if ((_window$google = window.google) != null && (_window$google = _window$google.maps) != null && _window$google.importLibrary) {
// no serialized parameters means it was loaded externally
if (!this.serializedApiParams) {
this.loadingStatus = APILoadingStatus.LOADED;
}
this.notifyLoadingStatusListeners();
} else {
this.serializedApiParams = serializedParams;
this.initImportLibrary(params);
}
if (this.serializedApiParams && this.serializedApiParams !== serializedParams) {
console.warn(`[google-maps-api-loader] The maps API has already been loaded ` + `with different parameters and will not be loaded again. Refresh the ` + `page for new values to have effect.`);
}
const librariesToLoad = ['maps', ...libraries];
await Promise.all(librariesToLoad.map(name => google.maps.importLibrary(name)));
}
/**
* Serialize the parameters used to load the library for easier comparison.
*/
static serializeParams(params) {
return [params.v, params.key, params.language, params.region, params.authReferrerPolicy, params.solutionChannel].join('/');
}
/**
* Creates the global `google.maps.importLibrary` function for bootstrapping.
* This is essentially a formatted version of the dynamic loading script
* from the official documentation with some minor adjustments.
*
* The created importLibrary function will load the Google Maps JavaScript API,
* which will then replace the `google.maps.importLibrary` function with the full
* implementation.
*
* @see https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import
*/
static initImportLibrary(params) {
if (!window.google) window.google = {};
if (!window.google.maps) window.google.maps = {};
if (window.google.maps['importLibrary']) {
console.error('[google-maps-api-loader-internal]: initImportLibrary must only be called once');
return;
}
let apiPromise = null;
const loadApi = () => {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => {
var _document$querySelect;
const scriptElement = document.createElement('script');
const urlParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const urlParamName = key.replace(/[A-Z]/g, t => '_' + t[0].toLowerCase());
urlParams.set(urlParamName, String(value));
}
urlParams.set('loading', 'async');
urlParams.set('callback', '__googleMapsCallback__');
scriptElement.async = true;
scriptElement.src = MAPS_API_BASE_URL + `?` + urlParams.toString();
scriptElement.nonce = ((_document$querySelect = document.querySelector('script[nonce]')) == null ? void 0 : _document$querySelect.nonce) || '';
scriptElement.onerror = () => {
this.loadingStatus = APILoadingStatus.FAILED;
this.notifyLoadingStatusListeners();
reject(new Error('The Google Maps JavaScript API could not load.'));
};
window.__googleMapsCallback__ = () => {
this.loadingStatus = APILoadingStatus.LOADED;
this.notifyLoadingStatusListeners();
resolve();
};
window.gm_authFailure = () => {
this.loadingStatus = APILoadingStatus.AUTH_FAILURE;
this.notifyLoadingStatusListeners();
};
this.loadingStatus = APILoadingStatus.LOADING;
this.notifyLoadingStatusListeners();
document.head.append(scriptElement);
});
return apiPromise;
};
// for the first load, we declare an importLibrary function that will
// be overwritten once the api is loaded.
google.maps.importLibrary = libraryName => loadApi().then(() => google.maps.importLibrary(libraryName));
}
/**
* Calls all registered loadingStatusListeners after a status update.
*/
static notifyLoadingStatusListeners() {
for (const fn of this.listeners) {
fn(this.loadingStatus);
}
}
}
/**
* The current loadingStatus of the API.
*/
GoogleMapsApiLoader.loadingStatus = APILoadingStatus.NOT_LOADED;
/**
* The parameters used for first loading the API.
*/
GoogleMapsApiLoader.serializedApiParams = void 0;
/**
* A list of functions to be notified when the loading status changes.
*/
GoogleMapsApiLoader.listeners = [];
const _excluded$3 = ["onLoad", "onError", "apiKey", "version", "libraries"],
_excluded2$1 = ["children"];
const DEFAULT_SOLUTION_CHANNEL = 'GMP_visgl_rgmlibrary_v1_default';
const APIProviderContext = React.createContext(null);
/**
* local hook to set up the map-instance management context.
*/
function useMapInstances() {
const [mapInstances, setMapInstances] = useState({});
const addMapInstance = (mapInstance, id = 'default') => {
setMapInstances(instances => _extends({}, instances, {
[id]: mapInstance
}));
};
const removeMapInstance = (id = 'default') => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
setMapInstances(_ref => {
let remaining = _objectWithoutPropertiesLoose(_ref, [id].map(_toPropertyKey));
return remaining;
});
};
const clearMapInstances = () => {
setMapInstances({});
};
return {
mapInstances,
addMapInstance,
removeMapInstance,
clearMapInstances
};
}
/**
* local hook to handle the loading of the maps API, returns the current loading status
* @param props
*/
function useGoogleMapsApiLoader(props) {
const {
onLoad,
onError,
apiKey,
version,
libraries = []
} = props,
otherApiParams = _objectWithoutPropertiesLoose(props, _excluded$3);
const [status, setStatus] = useState(GoogleMapsApiLoader.loadingStatus);
const [loadedLibraries, addLoadedLibrary] = useReducer((loadedLibraries, action) => {
return loadedLibraries[action.name] ? loadedLibraries : _extends({}, loadedLibraries, {
[action.name]: action.value
});
}, {});
const librariesString = useMemo(() => libraries == null ? void 0 : libraries.join(','), [libraries]);
const serializedParams = useMemo(() => JSON.stringify(_extends({
apiKey,
version
}, otherApiParams)), [apiKey, version, otherApiParams]);
const importLibrary = useCallback(async name => {
var _google;
if (loadedLibraries[name]) {
return loadedLibraries[name];
}
if (!((_google = google) != null && (_google = _google.maps) != null && _google.importLibrary)) {
throw new Error('[api-provider-internal] importLibrary was called before ' + 'google.maps.importLibrary was defined.');
}
const res = await window.google.maps.importLibrary(name);
addLoadedLibrary({
name,
value: res
});
return res;
}, [loadedLibraries]);
useEffect(() => {
(async () => {
try {
const params = _extends({
key: apiKey
}, otherApiParams);
if (version) params.v = version;
if ((librariesString == null ? void 0 : librariesString.length) > 0) params.libraries = librariesString;
if (params.channel === undefined || params.channel < 0 || params.channel > 999) delete params.channel;
if (params.solutionChannel === undefined) params.solutionChannel = DEFAULT_SOLUTION_CHANNEL;else if (params.solutionChannel === '') delete params.solutionChannel;
await GoogleMapsApiLoader.load(params, status => setStatus(status));
for (const name of ['core', 'maps', ...libraries]) {
await importLibrary(name);
}
if (onLoad) {
onLoad();
}
} catch (error) {
if (onError) {
onError(error);
} else {
console.error('<ApiProvider> failed to load the Google Maps JavaScript API', error);
}
}
})();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[apiKey, librariesString, serializedParams]);
return {
status,
loadedLibraries,
importLibrary
};
}
/**
* Component to wrap the components from this library and load the Google Maps JavaScript API
*/
const APIProvider = props => {
const {
children
} = props,
loaderProps = _objectWithoutPropertiesLoose(props, _excluded2$1);
const {
mapInstances,
addMapInstance,
removeMapInstance,
clearMapInstances
} = useMapInstances();
const {
status,
loadedLibraries,
importLibrary
} = useGoogleMapsApiLoader(loaderProps);
const contextValue = useMemo(() => ({
mapInstances,
addMapInstance,
removeMapInstance,
clearMapInstances,
status,
loadedLibraries,
importLibrary
}), [mapInstances, addMapInstance, removeMapInstance, clearMapInstances, status, loadedLibraries, importLibrary]);
return /*#__PURE__*/React.createElement(APIProviderContext.Provider, {
value: contextValue
}, children);
};
/**
* Sets up effects to bind event-handlers for all event-props in MapEventProps.
* @internal
*/
function useMapEvents(map, props) {
// 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];
const eventType = propNameToEventType[propName];
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!map) return;
if (!handler) return;
const listener = google.maps.event.addListener(map, eventType, ev => {
handler(createMapEvent(eventType, map, ev));
});
return () => listener.remove();
}, [map, eventType, handler]);
}
}
/**
* Create the wrapped map-events used for the event-props.
* @param type the event type as it is specified to the maps api
* @param map the map instance the event originates from
* @param srcEvent the source-event if there is one.
*/
function createMapEvent(type, map, srcEvent) {
const ev = {
type,
map,
detail: {},
stoppable: false,
stop: () => {}
};
if (cameraEventTypes.includes(type)) {
const camEvent = ev;
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 == null ? void 0 : center.toJSON()) || {
lat: 0,
lng: 0
},
zoom: zoom || 0,
heading: heading,
tilt: tilt,
bounds: (bounds == null ? void 0 : bounds.toJSON()) || {
north: 90,
east: 180,
south: -90,
west: -180
}
};
return camEvent;
} else if (mouseEventTypes.includes(type)) {
var _srcEvent$latLng;
if (!srcEvent) throw new Error('[createEvent] mouse events must provide a srcEvent');
const mouseEvent = ev;
mouseEvent.domEvent = srcEvent.domEvent;
mouseEvent.stoppable = true;
mouseEvent.stop = () => srcEvent.stop();
mouseEvent.detail = {
latLng: ((_srcEvent$latLng = srcEvent.latLng) == null ? void 0 : _srcEvent$latLng.toJSON()) || null,
placeId: srcEvent.placeId
};
return mouseEvent;
}
return ev;
}
/**
* maps the camelCased names of event-props to the corresponding event-types
* used in the maps API.
*/
const propNameToEventType = {
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',
// note: onCameraChanged is an alias for the bounds_changed event,
// since that is going to be fired in every situation where the camera is
// updated.
onCameraChanged: 'bounds_changed'
};
const cameraEventTypes = ['bounds_changed', 'center_changed', 'heading_changed', 'tilt_changed', 'zoom_changed'];
const mouseEventTypes = ['click', 'contextmenu', 'dblclick', 'mousemove', 'mouseout', 'mouseover'];
const eventPropNames = Object.keys(propNameToEventType);
function useMemoized(value, isEqual) {
const ref = useRef(value);
if (!isEqual(value, ref.current)) {
ref.current = value;
}
return ref.current;
}
function useCustomCompareEffect(effect, dependencies, isEqual) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, [useMemoized(dependencies, isEqual)]);
}
function useDeepCompareEffect(effect, dependencies) {
useCustomCompareEffect(effect, dependencies, isDeepEqual);
}
const mapOptionKeys = new Set(['backgroundColor', 'clickableIcons', 'controlSize', 'disableDefaultUI', 'disableDoubleClickZoom', 'draggable', 'draggableCursor', 'draggingCursor', 'fullscreenControl', 'fullscreenControlOptions', 'gestureHandling', 'headingInteractionEnabled', 'isFractionalZoomEnabled', 'keyboardShortcuts', 'mapTypeControl', 'mapTypeControlOptions', 'mapTypeId', 'maxZoom', 'minZoom', 'noClear', 'panControl', 'panControlOptions', 'restriction', 'rotateControl', 'rotateControlOptions', 'scaleControl', 'scaleControlOptions', 'scrollwheel', 'streetView', 'streetViewControl', 'streetViewControlOptions', 'styles', 'tiltInteractionEnabled', 'zoomControl', 'zoomControlOptions']);
/**
* Internal hook to update the map-options when props are changed.
*
* @param map the map instance
* @param mapProps the props to update the map-instance with
* @internal
*/
function useMapOptions(map, mapProps) {
/* 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.
*/
const mapOptions = {};
const keys = Object.keys(mapProps);
for (const key of keys) {
if (!mapOptionKeys.has(key)) continue;
mapOptions[key] = mapProps[key];
}
// update the map options when mapOptions is changed
// Note: due to the destructuring above, mapOptions will be seen as changed
// with every re-render, so we're assuming the maps-api will properly
// deal with unchanged option-values passed into setOptions.
useDeepCompareEffect(() => {
if (!map) return;
map.setOptions(mapOptions);
}, [mapOptions]);
/* eslint-enable react-hooks/exhaustive-deps */
}
function useApiLoadingStatus() {
var _useContext;
return ((_useContext = useContext(APIProviderContext)) == null ? void 0 : _useContext.status) || APILoadingStatus.NOT_LOADED;
}
/**
* Internal hook that updates the camera when deck.gl viewState changes.
* @internal
*/
function useDeckGLCameraUpdate(map, props) {
const {
viewport,
viewState
} = props;
const isDeckGlControlled = !!viewport;
useLayoutEffect(() => {
if (!map || !viewState) return;
const {
latitude,
longitude,
bearing: heading,
pitch: tilt,
zoom
} = viewState;
map.moveCamera({
center: {
lat: latitude,
lng: longitude
},
heading,
tilt,
zoom: zoom + 1
});
}, [map, viewState]);
return isDeckGlControlled;
}
function isLatLngLiteral(obj) {
if (!obj || typeof obj !== 'object') return false;
if (!('lat' in obj && 'lng' in obj)) return false;
return Number.isFinite(obj.lat) && Number.isFinite(obj.lng);
}
function latLngEquals(a, b) {
if (!a || !b) return false;
const A = toLatLngLiteral(a);
const B = toLatLngLiteral(b);
if (A.lat !== B.lat || A.lng !== B.lng) return false;
return true;
}
function toLatLngLiteral(obj) {
if (isLatLngLiteral(obj)) return obj;
return obj.toJSON();
}
function useMapCameraParams(map, cameraStateRef, mapProps) {
const center = mapProps.center ? toLatLngLiteral(mapProps.center) : null;
let lat = null;
let lng = null;
if (center && Number.isFinite(center.lat) && Number.isFinite(center.lng)) {
lat = center.lat;
lng = center.lng;
}
const zoom = Number.isFinite(mapProps.zoom) ? mapProps.zoom : null;
const heading = Number.isFinite(mapProps.heading) ? mapProps.heading : null;
const tilt = Number.isFinite(mapProps.tilt) ? mapProps.tilt : null;
// 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;
const nextCamera = {};
let needsUpdate = false;
if (lat !== null && lng !== null && (cameraStateRef.current.center.lat !== lat || cameraStateRef.current.center.lng !== lng)) {
nextCamera.center = {
lat,
lng
};
needsUpdate = true;
}
if (zoom !== null && cameraStateRef.current.zoom !== zoom) {
nextCamera.zoom = zoom;
needsUpdate = true;
}
if (heading !== null && cameraStateRef.current.heading !== heading) {
nextCamera.heading = heading;
needsUpdate = true;
}
if (tilt !== null && cameraStateRef.current.tilt !== tilt) {
nextCamera.tilt = tilt;
needsUpdate = true;
}
if (needsUpdate) {
map.moveCamera(nextCamera);
}
});
}
const AuthFailureMessage = () => {
const style = {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
zIndex: 999,
display: 'flex',
flexFlow: 'column nowrap',
textAlign: 'center',
justifyContent: 'center',
fontSize: '.8rem',
color: 'rgba(0,0,0,0.6)',
background: '#dddddd',
padding: '1rem 1.5rem'
};
return /*#__PURE__*/React.createElement("div", {
style: style
}, /*#__PURE__*/React.createElement("h2", null, "Error: AuthFailure"), /*#__PURE__*/React.createElement("p", null, "A problem with your API key prevents the map from rendering correctly. Please make sure the value of the ", /*#__PURE__*/React.createElement("code", null, "APIProvider.apiKey"), " prop is correct. Check the error-message in the console for further details."));
};
function useCallbackRef() {
const [el, setEl] = useState(null);
const ref = useCallback(value => setEl(value), [setEl]);
return [el, ref];
}
/**
* Hook to check if the Maps JavaScript API is loaded
*/
function useApiIsLoaded() {
const status = useApiLoadingStatus();
return status === APILoadingStatus.LOADED;
}
function useForceUpdate() {
const [, forceUpdate] = useReducer(x => x + 1, 0);
return forceUpdate;
}
function handleBoundsChange(map, ref) {
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('[useTrackedCameraState] 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');
}
// fixme: do we need the `undefined` cases for the camera-params? When are they used in the maps API?
Object.assign(ref.current, {
center: (center == null ? void 0 : center.toJSON()) || {
lat: 0,
lng: 0
},
zoom: zoom || 0,
heading: heading,
tilt: tilt
});
}
/**
* Creates a mutable ref object to track the last known state of the map camera.
* This is used in `useMapCameraParams` to reduce stuttering in normal operation
* by avoiding updates of the map camera with values that have already been processed.
*/
function useTrackedCameraStateRef(map) {
const forceUpdate = useForceUpdate();
const ref = useRef({
center: {
lat: 0,
lng: 0
},
heading: 0,
tilt: 0,
zoom: 0
});
// Record camera state with every bounds_changed event dispatched by the map.
// This data is used to prevent feeding these values back to the
// map-instance when a typical "controlled component" setup (state variable is
// fed into and updated by the map).
useEffect(() => {
if (!map) return;
const listener = google.maps.event.addListener(map, 'bounds_changed', () => {
handleBoundsChange(map, ref);
// When an event is occured, we have to update during the next cycle.
// The application could decide to ignore the event and not update any
// camera props of the map, meaning that in that case we will have to
// 'undo' the change to the camera.
forceUpdate();
});
return () => listener.remove();
}, [map, forceUpdate]);
return ref;
}
const _excluded$2 = ["id", "defaultBounds", "defaultCenter", "defaultZoom", "defaultHeading", "defaultTilt", "reuseMaps", "renderingType", "colorScheme"],
_excluded2 = ["padding"];
/**
* Stores a stack of map-instances for each mapId. Whenever an
* instance is used, it is removed from the stack while in use,
* and returned to the stack when the component unmounts.
* This allows us to correctly implement caching for multiple
* maps om the same page, while reusing as much as possible.
*
* FIXME: while it should in theory be possible to reuse maps solely
* based on the mapId (as all other parameters can be changed at
* runtime), we don't yet have good enough tracking of options to
* reliably unset all the options that have been set.
*/
class CachedMapStack {
static has(key) {
return this.entries[key] && this.entries[key].length > 0;
}
static pop(key) {
if (!this.entries[key]) return null;
return this.entries[key].pop() || null;
}
static push(key, value) {
if (!this.entries[key]) this.entries[key] = [];
this.entries[key].push(value);
}
}
/**
* 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
*/
CachedMapStack.entries = {};
function useMapInstance(props, context) {
const apiIsLoaded = useApiIsLoaded();
const [map, setMap] = useState(null);
const [container, containerRef] = useCallbackRef();
const cameraStateRef = useTrackedCameraStateRef(map);
const {
id,
defaultBounds,
defaultCenter,
defaultZoom,
defaultHeading,
defaultTilt,
reuseMaps,
renderingType,
colorScheme
} = props,
mapOptions = _objectWithoutPropertiesLoose(props, _excluded$2);
const hasZoom = props.zoom !== undefined || props.defaultZoom !== undefined;
const hasCenter = props.center !== undefined || props.defaultCenter !== undefined;
if (!defaultBounds && (!hasZoom || !hasCenter)) {
console.warn('<Map> component is missing configuration. ' + 'You have to provide zoom and center (via the `zoom`/`defaultZoom` and ' + '`center`/`defaultCenter` props) or specify the region to show using ' + '`defaultBounds`. See ' + 'https://visgl.github.io/react-google-maps/docs/api-reference/components/map#required');
}
// apply default camera props if available and not overwritten by controlled props
if (!mapOptions.center && defaultCenter) mapOptions.center = defaultCenter;
if (!mapOptions.zoom && Number.isFinite(defaultZoom)) mapOptions.zoom = defaultZoom;
if (!mapOptions.heading && Number.isFinite(defaultHeading)) mapOptions.heading = defaultHeading;
if (!mapOptions.tilt && Number.isFinite(defaultTilt)) mapOptions.tilt = defaultTilt;
for (const key of Object.keys(mapOptions)) if (mapOptions[key] === undefined) delete mapOptions[key];
const savedMapStateRef = useRef(undefined);
// create the map instance and register it in the context
useEffect(() => {
if (!container || !apiIsLoaded) return;
const {
addMapInstance,
removeMapInstance
} = context;
// note: colorScheme (upcoming feature) isn't yet in the typings, remove once that is fixed:
const {
mapId
} = props;
const cacheKey = `${mapId || 'default'}:${renderingType || 'default'}:${colorScheme || 'LIGHT'}`;
let mapDiv;
let map;
if (reuseMaps && CachedMapStack.has(cacheKey)) {
map = CachedMapStack.pop(cacheKey);
mapDiv = map.getDiv();
container.appendChild(mapDiv);
map.setOptions(mapOptions);
// detaching the element from the DOM lets the map fall back to its default
// size, setting the center will trigger reloading the map.
setTimeout(() => map.setCenter(map.getCenter()), 0);
} else {
mapDiv = document.createElement('div');
mapDiv.style.height = '100%';
container.appendChild(mapDiv);
map = new google.maps.Map(mapDiv, _extends({}, mapOptions, renderingType ? {
renderingType: renderingType
} : {}, colorScheme ? {
colorScheme: colorScheme
} : {}));
}
setMap(map);
addMapInstance(map, id);
if (defaultBounds) {
const {
padding
} = defaultBounds,
defBounds = _objectWithoutPropertiesLoose(defaultBounds, _excluded2);
map.fitBounds(defBounds, padding);
}
// prevent map not rendering due to missing configuration
else if (!hasZoom || !hasCenter) {
map.fitBounds({
east: 180,
west: -180,
south: -90,
north: 90
});
}
// the savedMapState is used to restore the camera parameters when the mapId is changed
if (savedMapStateRef.current) {
const {
mapId: savedMapId,
cameraState: savedCameraState
} = savedMapStateRef.current;
if (savedMapId !== mapId) {
map.setOptions(savedCameraState);
}
}
return () => {
savedMapStateRef.current = {
mapId,
// eslint-disable-next-line react-hooks/exhaustive-deps
cameraState: cameraStateRef.current
};
// detach the map-div from the dom
mapDiv.remove();
if (reuseMaps) {
// push back on the stack
CachedMapStack.push(cacheKey, map);
} else {
// remove all event-listeners to minimize the possibility of memory-leaks
google.maps.event.clearInstanceListeners(map);
}
setMap(null);
removeMapInstance(id);
};
},
// some dependencies are ignored in the list below:
// - defaultBounds and the default* camera props will only be used once, and
// changes should be ignored
// - mapOptions has special hooks that take care of updating the options
// eslint-disable-next-line react-hooks/exhaustive-deps
[container, apiIsLoaded, id,
// these props can't be changed after initialization and require a new
// instance to be created
props.mapId, props.renderingType, props.colorScheme]);
return [map, containerRef, cameraStateRef];
}
const GoogleMapsContext = React.createContext(null);
// ColorScheme and RenderingType are redefined here to make them usable before the
// maps API has been fully loaded.
const ColorScheme = {
DARK: 'DARK',
LIGHT: 'LIGHT',
FOLLOW_SYSTEM: 'FOLLOW_SYSTEM'
};
const RenderingType = {
VECTOR: 'VECTOR',
RASTER: 'RASTER',
UNINITIALIZED: 'UNINITIALIZED'
};
const Map = props => {
const {
children,
id,
className,
style
} = props;
const context = useContext(APIProviderContext);
const loadingStatus = useApiLoadingStatus();
if (!context) {
throw new Error('<Map> can only be used inside an <ApiProvider> component.');
}
const [map, mapRef, cameraStateRef] = useMapInstance(props, context);
useMapCameraParams(map, cameraStateRef, props);
useMapEvents(map, props);
useMapOptions(map, props);
const isDeckGlControlled = useDeckGLCameraUpdate(map, props);
const isControlledExternally = !!props.controlled;
// disable interactions with the map for externally controlled maps
useEffect(() => {
if (!map) return;
// fixme: this doesn't seem to belong here (and it's mostly there for convenience anyway).
// The reasoning is that a deck.gl canvas will be put on top of the map, rendering
// any default map controls pretty much useless
if (isDeckGlControlled) {
map.setOptions({
disableDefaultUI: true
});
}
// disable all control-inputs when the map is controlled externally
if (isDeckGlControlled || isControlledExternally) {
map.setOptions({
gestureHandling: 'none',
keyboardShortcuts: false
});
}
return () => {
map.setOptions({
gestureHandling: props.gestureHandling,
keyboardShortcuts: props.keyboardShortcuts
});
};
}, [map, isDeckGlControlled, isControlledExternally, props.gestureHandling, props.keyboardShortcuts]);
// setup a stable cameraOptions object that can be used as dependency
const center = props.center ? toLatLngLiteral(props.center) : null;
let lat = null;
let lng = null;
if (center && Number.isFinite(center.lat) && Number.isFinite(center.lng)) {
lat = center.lat;
lng = center.lng;
}
const cameraOptions = useMemo(() => {
var _lat, _lng, _props$zoom, _props$heading, _props$tilt;
return {
center: {
lat: (_lat = lat) != null ? _lat : 0,
lng: (_lng = lng) != null ? _lng : 0
},
zoom: (_props$zoom = props.zoom) != null ? _props$zoom : 0,
heading: (_props$heading = props.heading) != null ? _props$heading : 0,
tilt: (_props$tilt = props.tilt) != null ? _props$tilt : 0
};
}, [lat, lng, props.zoom, props.heading, props.tilt]);
// externally controlled mode: reject all camera changes that don't correspond to changes in props
useLayoutEffect(() => {
if (!map || !isControlledExternally) return;
map.moveCamera(cameraOptions);
const listener = map.addListener('bounds_changed', () => {
map.moveCamera(cameraOptions);
});
return () => listener.remove();
}, [map, isControlledExternally, cameraOptions]);
const combinedStyle = useMemo(() => _extends({
width: '100%',
height: '100%',
position: 'relative',
// when using deckgl, the map should be sent to the back
zIndex: isDeckGlControlled ? -1 : 0
}, style), [style, isDeckGlControlled]);
const contextValue = useMemo(() => ({
map
}), [map]);
if (loadingStatus === APILoadingStatus.AUTH_FAILURE) {
return /*#__PURE__*/React.createElement("div", {
style: _extends({
position: 'relative'
}, className ? {} : combinedStyle),
className: className
}, /*#__PURE__*/React.createElement(AuthFailureMessage, null));
}
return /*#__PURE__*/React.createElement("div", _extends({
ref: mapRef,
"data-testid": 'map',
style: className ? undefined : combinedStyle,
className: className
}, id ? {
id
} : {}), map ? /*#__PURE__*/React.createElement(GoogleMapsContext.Provider, {
value: contextValue
}, children) : null);
};
// The deckGLViewProps flag here indicates to deck.gl that the Map component is
// able to handle viewProps from deck.gl when deck.gl is used to control the map.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Map.deckGLViewProps = true;
const shownMessages = new Set();
function logErrorOnce(...args) {
const key = JSON.stringify(args);
if (!shownMessages.has(key)) {
shownMessages.add(key);
console.error(...args);
}
}
/**
* Retrieves a map-instance from the context. This is either an instance
* identified by id or the parent map instance if no id is specified.
* Returns null if neither can be found.
*/
const useMap = (id = null) => {
const ctx = useContext(APIProviderContext);
const {
map
} = useContext(GoogleMapsContext) || {};
if (ctx === null) {
logErrorOnce('useMap(): failed to retrieve APIProviderContext. ' + 'Make sure that the <APIProvider> component exists and that the ' + 'component you are calling `useMap()` from is a sibling of the ' + '<APIProvider>.');
return null;
}
const {
mapInstances
} = ctx;
// if an id is specified, the corresponding map or null is returned
if (id !== null) return mapInstances[id] || null;
// otherwise, return the closest ancestor
if (map) return map;
// finally, return the default map instance
return mapInstances['default'] || null;
};
function useMapsLibrary(name) {
const apiIsLoaded = useApiIsLoaded();
const ctx = useContext(APIProviderContext);
useEffect(() => {
if (!apiIsLoaded || !ctx) return;
// Trigger loading the libraries via our proxy-method.
// The returned promise is ignored, since importLibrary will update loadedLibraries
// list in the context, triggering a re-render.
void ctx.importLibrary(name);
}, [apiIsLoaded, ctx, name]);
return (ctx == null ? void 0 : ctx.loadedLibraries[name]) || null;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Internally used to bind events to Maps JavaScript API objects.
* @internal
*/
function useMapsEventListener(target, name, callback) {
useEffect(() => {
if (!target || !name || !callback) return;
const listener = google.maps.event.addListener(target, name, callback);
return () => listener.remove();
}, [target, name, callback]);
}
/**
* Internally used to copy values from props into API-Objects
* whenever they change.
*
* @example
* usePropBinding(marker, 'position', position);
*
* @internal
*/
function usePropBinding(object, prop, value) {
useEffect(() => {
if (!object) return;
object[prop] = value;
}, [object, prop, value]);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Internally used to bind events to DOM nodes.
* @internal
*/
function useDomEventListener(target, name, callback) {
useEffect(() => {
if (!target || !name || !callback) return;
target.addEventListener(name, callback);
return () => target.removeEventListener(name, callback);
}, [target, name, callback]);
}
/* eslint-disable complexity */
function isAdvancedMarker(marker) {
return marker.content !== undefined;
}
function isElementNode(node) {
return node.nodeType === Node.ELEMENT_NODE;
}
/**
* Copy of the `google.maps.CollisionBehavior` constants.
* They have to be duplicated here since we can't wait for the maps API to load to be able to use them.
*/
const CollisionBehavior = {
REQUIRED: 'REQUIRED',
REQUIRED_AND_HIDES_OPTIONAL: 'REQUIRED_AND_HIDES_OPTIONAL',
OPTIONAL_AND_HIDES_LOWER_PRIORITY: 'OPTIONAL_AND_HIDES_LOWER_PRIORITY'
};
const AdvancedMarkerContext = React.createContext(null);
// [xPosition, yPosition] when the top left corner is [0, 0]
const AdvancedMarkerAnchorPoint = {
TOP_LEFT: ['0%', '0%'],
TOP_CENTER: ['50%', '0%'],
TOP: ['50%', '0%'],
TOP_RIGHT: ['100%', '0%'],
LEFT_CENTER: ['0%', '50%'],
LEFT_TOP: ['0%', '0%'],
LEFT: ['0%', '50%'],
LEFT_BOTTOM: ['0%', '100%'],
RIGHT_TOP: ['100%', '0%'],
RIGHT: ['100%', '50%'],
RIGHT_CENTER: ['100%', '50%'],
RIGHT_BOTTOM: ['100%', '100%'],
BOTTOM_LEFT: ['0%', '100%'],
BOTTOM_CENTER: ['50%', '100%'],
BOTTOM: ['50%', '100%'],
BOTTOM_RIGHT: ['100%', '100%'],
CENTER: ['50%', '50%']
};
const MarkerContent = ({
children,
styles,
className,
anchorPoint
}) => {
const [xTranslation, yTranslation] = anchorPoint != null ? anchorPoint : AdvancedMarkerAnchorPoint['BOTTOM'];
let xTranslationFlipped = `-${xTranslation}`;
let yTranslationFlipped = `-${yTranslation}`;
if (xTranslation.trimStart().startsWith('-')) {
xTranslationFlipped = xTranslation.substring(1);
}
if (yTranslation.trimStart().startsWith('-')) {
yTranslationFlipped = yTranslation.substring(1);
}
// The "translate(50%, 100%)" is here to counter and reset the default anchoring of the advanced marker element
// that comes from the api
const transformStyle = `translate(50%, 100%) translate(${xTranslationFlipped}, ${yTranslationFlipped})`;
return (
/*#__PURE__*/
// anchoring container
React.createElement("div", {
style: {
transform: transformStyle
}
}, /*#__PURE__*/React.createElement("div", {
className: className,
style: styles
}, children))
);
};
function useAdvancedMarker(props) {
const [marker, setMarker] = useState(null);
const [contentContainer, setContentContainer] = useState(null);
const map = useMap();
const markerLibrary = useMapsLibrary('marker');
const {
children,
onClick,
className,
onMouseEnter,
onMouseLeave,
onDrag,
onDragStart,
onDragEnd,
collisionBehavior,
clickable,
draggable,
position,
title,
zIndex
} = props;
const numChildren = Children.count(children);
// create an AdvancedMarkerElement instance and add it to the map once available
useEffect(() => {
if (!map || !markerLibrary) return;
const newMarker = new markerLibrary.AdvancedMarkerElement();
newMarker.map = map;
setMarker(newMarker);
// create the container for marker content if there are children
let contentElement = null;
if (numChildren > 0) {
contentElement = document.createElement('div');
// We need some kind of flag to identify the custom marker content
// in the infowindow component. Choosing a custom property instead of a className
// to not encourage users to style the marker content directly.
contentElement.isCustomMarker = true;
newMarker.content = contentElement;
setContentContainer(contentElement);
}
return () => {
var _contentElement;
newMarker.map = null;
(_contentElement = contentElement) == null || _contentElement.remove();
setMarker(null);
setContentContainer(null);
};
}, [map, markerLibrary, numChildren]);
// When no children are present we don't have our own wrapper div
// which usually gets the user provided className. In this case
// we set the className directly on the marker.content element that comes
// with the AdvancedMarker.
useEffect(() => {
if (!(marker != null && marker.content) || !isElementNode(marker.content) || numChildren > 0) return;
marker.content.className = className != null ? className : '';
}, [marker, className, numChildren]);
// copy other props
usePropBinding(marker, 'position', position);
usePropBinding(marker, 'title', title != null ? title : '');
usePropBinding(marker, 'zIndex', zIndex);
usePropBinding(marker, 'collisionBehavior', collisionBehavior);
// set gmpDraggable from props (when unspecified, it's true if any drag-event
// callbacks are specified)
useEffect(() => {
if (!marker) return;
if (draggable !== undefined) marker.gmpDraggable = draggable;else if (onDrag || onDragStart || onDragEnd) marker.gmpDraggable = true;else marker.gmpDraggable = false;
}, [marker, draggable, onDrag, onDragEnd, onDragStart]);
// set gmpClickable from props (when unspecified, it's true if the onClick or one of
// the hover events callbacks are specified)
useEffect(() => {
if (!marker) return;
const gmpClickable = clickable !== undefined || Boolean(onClick) || Boolean(onMouseEnter) || Boolean(onMouseLeave);
// gmpClickable is only available in beta version of the
// maps api (as of 2024-10-10)
marker.gmpClickable = gmpClickable;
// enable pointer events for the markers with custom content
if (gmpClickable && marker != null && marker.content && isElementNode(marker.content)) {
marker.content.style.pointerEvents = 'none';
if (marker.content.firstElementChild) {
marker.content.firstElementChild.style.pointerEvents = 'all';
}
}
}, [marker, clickable, onClick, onMouseEnter, onMouseLeave]);
useMapsEventListener(marker, 'click', onClick);
useMapsEventListener(marker, 'drag', onDrag);
useMapsEventListener(marker, 'dragstart', onDragStart);
useMapsEventListener(marker, 'dragend', onDragEnd);
useDomEventListener(marker == null ? void 0 : marker.element, 'mouseenter', onMouseEnter);
useDomEventListener(marker == null ? void 0 : marker.element, 'mouseleave', onMouseLeave);
return [marker, contentContainer];
}
const AdvancedMarker = forwardRef((props, ref) => {
const {
children,
style,
className,
anchorPoint
} = props;
const [marker, contentContainer] = useAdvancedMarker(props);
const advancedMarkerContextValue = useMemo(() => marker ? {
marker
} : null, [marker]);
useImperativeHandle(ref, () => marker, [marker]);
if (!contentContainer) return null;
return /*#__PURE__*/React.createElement(AdvancedMarkerContext.Provider, {
value: advancedMarkerContextValue
}, createPortal(/*#__PURE__*/React.createElement(MarkerContent, {
anchorPoint: anchorPoint,
styles: style,
className: className
}, children), contentContainer));
});
function useAdvancedMarkerRef() {
const [marker, setMarker] = useState(null);
const refCallback = useCallback(m => {
setMarker(m);
}, []);
return [refCallback, marker];
}
function setValueForStyles(element, styles, prevStyles) {
if (styles != null && typeof styles !== 'object') {
throw new Error('The `style` prop expects a mapping from style properties to values, ' + "not a string. For example, style={{marginRight: spacing + 'em'}} when " + 'using JSX.');
}
const elementStyle = element.style;
// without `prevStyles`, just set all values
if (prevStyles == null) {
if (styles == null) return;
for (const styleName in styles) {
if (!styles.hasOwnProperty(styleName)) continue;
setValueForStyle(elementStyle, styleName, styles[styleName]);
}
return;
}
// unset all styles in `prevStyles` that aren't in `styles`
for (const styleName in prevStyles) {
if (prevStyles.hasOwnProperty(styleName) && (styles == null || !styles.hasOwnProperty(styleName))) {
// Clear style
const isCustomProperty = styleName.indexOf('--') === 0;
if (isCustomProperty) {
elementStyle.setProperty(styleName, '');
} else if (styleName === 'float') {
elementStyle.cssFloat = '';
} else {
elementStyle[styleName] = '';
}
}
}
// only assign values from `styles` that are different from `prevStyles`
if (styles == null) return;
for (const styleName in styles) {
const value = styles[styleName];
if (styles.hasOwnProperty(styleName) && prevStyles[styleName] !== value) {
setValueForStyle(elementStyle, styleName, value);
}
}
}
function setValueForStyle(elementStyle, styleName, value) {
const isCustomProperty = styleName.indexOf('--') === 0;
// falsy values will unset the style property
if (value == null || typeof value === 'boolean' || value === '') {
if (isCustomProperty) {
elementStyle.setProperty(styleName, '');
} else if (styleName === 'float') {
elementStyle.cssFloat = '';
} else {
elementStyle[styleName] = '';
}
}
// custom properties can't be directly assigned
else if (isCustomProperty) {
elementStyle.setProperty(styleName, value);
}
// numeric values are treated as 'px' unless the style property expects unitless numbers
else if (typeof value === 'number' && value !== 0 && !isUnitlessNumber(styleName)) {
elementStyle[styleName] = value + 'px'; // Presumes implicit 'px' suffix for unitless numbers
}
// everything else can just be assigned
else {
if (styleName === 'float') {
elementStyle.cssFloat = value;
} else {
elementStyle[styleName] = ('' + value).trim();
}
}
}
// CSS properties which accept numbers but are not in units of "px".
const unitlessNumbers = new Set(['animationIterationCount', 'aspectRatio', 'borderImageOutset', 'borderImageSlice', 'borderImageWidth', 'boxFlex', 'boxFlexGroup', 'boxOrdinalGroup', 'columnCount', 'columns', 'flex', 'flexGrow', 'flexPositive', 'flexShrink', 'flexNegative', 'flexOrder', 'gridArea', 'gridRow', 'gridRowEnd', 'gridRowSpan', 'gridRowStart', 'gridColumn', 'gridColumnEnd', 'gridColumnSpan', 'gridColumnStart', 'fontWeight', 'lineClamp', 'lineHeight', 'opacity', 'order', 'orphans', 'scale', 'tabSize', 'widows', 'zIndex', 'zoom', 'fillOpacity',
// SVG-related properties
'floodOpacity', 'stopOpacity', 'strokeDasharray', 'strokeDashoffset', 'strokeMiterlimit', 'strokeOpacity', 'strokeWidth']);
function isUnitlessNumber(name) {
return unitlessNumbers.has(name);
}
const _excluded$1 = ["children", "headerContent", "style", "className", "pixelOffset", "anchor", "shouldFocus", "onClose", "onCloseClick"];
/**
* Component to render an Info Window with the Maps JavaScript API
*/
const InfoWindow = props => {
const {
// content options
children,
headerContent,
style,
className,
pixelOffset,
// open options
anchor,
shouldFocus,
// events
onClose,
onCloseClick
// other options
} = props,
volatileInfoWindowOptions = _objectWithoutPropertiesLoose(props, _excluded$1);
// ## create infowindow instance once the mapsLibrary is available.
const mapsLibrary = useMapsLibrary('maps');
const [infoWindow, setInfoWindow] = useState(null);
const contentContainerRef = useRef(null);
const headerContainerRef = useRef(null);
const infoWindowOptions = useMemoized(volatileInfoWindowOptions, isDeepEqual);
useEffect(() => {
if (!mapsLibrary) return;
contentContainerRef.current = document.createElement('div');
headerContainerRef.current = document.createElement('div');
const opts = infoWindowOptions;
if (pixelOffset) {
opts.pixelOffset = new google.maps.Size(pixelOffset[0], pixelOffset[1]);
}
if (headerContent) {
// if headerContent is specified as string we can directly forward it,
// otherwise we'll pass the element the portal will render into
opts.headerContent = typeof headerContent === 'string' ? headerContent : headerContainerRef.current;
}
// intentionally shadowing the state variables here
const infoWindow = new google.maps.InfoWindow(infoWindowOptions);
infoWindow.setContent(contentContainerRef.current);
setInfoWindow(infoWindow);
// unmount: remove infoWindow and content elements (note: close is called in a different effect-cleanup)
return () => {
var _contentContainerRef$, _headerContainerRef$c;
infoWindow.setContent(null);
(_contentContainerRef$ = contentContainerRef.current) == null || _contentContainerRef$.remove();
(_headerContainerRef$c = headerContainerRef.current) == null || _headerContainerRef$c.remove();
contentContainerRef.current = null;
headerContainerRef.current = null