UNPKG

@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
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