UNPKG

@vis.gl/react-google-maps

Version:

React components and hooks for the Google Maps JavaScript API

1,182 lines (1,151 loc) 166 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react'), require('react-dom'), require('fast-deep-equal')) : typeof define === 'function' && define.amd ? define(['exports', 'react', 'react-dom', 'fast-deep-equal'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ReactGoogleMaps = {}, global.React, global.ReactDOM, global.fastDeepEqual)); })(this, (function (exports, React, reactDom, isDeepEqual) { 'use strict'; // This file is automatically updated by the build process. const VERSION = '1.8.2'; /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __rest(s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; } function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /* * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ function setScriptSrc(script, src) { script.src = src; } /* * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ const bootstrap = bootstrapParams => { var bootstrapPromise; var script; var bootstrapParamsKey; var PRODUCT_NAME = "The Google Maps JavaScript API"; var GOOGLE = "google"; var IMPORT_API_NAME = "importLibrary"; var PENDING_BOOTSTRAP_KEY = "__ib__"; var doc = document; var global_ = window; var google_ = global_[GOOGLE] || (global_[GOOGLE] = {}); var namespace = google_.maps || (google_.maps = {}); var libraries = new Set(); var searchParams = new URLSearchParams(); var triggerBootstrap = () => bootstrapPromise || (bootstrapPromise = new Promise(async(resolve, reject) => { await (script = doc.createElement("script")); searchParams.set("libraries", [...libraries] + ""); for (bootstrapParamsKey in bootstrapParams) { searchParams.set(bootstrapParamsKey.replace(/[A-Z]/g, g => "_" + g[0].toLowerCase()), bootstrapParams[bootstrapParamsKey]); } searchParams.set("callback", GOOGLE + ".maps." + PENDING_BOOTSTRAP_KEY); setScriptSrc(script, "https://maps.googleapis.com/maps/api/js?" + searchParams); namespace[PENDING_BOOTSTRAP_KEY] = resolve; script.onerror = () => bootstrapPromise = reject(Error(PRODUCT_NAME + " could not load.")); script.nonce = doc.querySelector("script[nonce]")?.nonce || ""; doc.head.append(script); })); namespace[IMPORT_API_NAME] ? console.warn(PRODUCT_NAME + " only loads once. Ignoring:", bootstrapParams) : namespace[IMPORT_API_NAME] = (libraryName, ...args) => libraries.add(libraryName) && triggerBootstrap().then(() => namespace[IMPORT_API_NAME](libraryName, ...args)); }; const MSG_REPEATED_SET_OPTIONS = (options) => `The setOptions() function should only be called once. The options passed ` + `to the additional call (${JSON.stringify(options)}) will be ignored.`; const MSG_IMPORT_LIBRARY_EXISTS = (options) => `The google.maps.importLibrary() function is already defined, and ` + `@googlemaps/js-api-loader will use the existing function instead of ` + `overwriting it. The options passed to setOptions ` + `(${JSON.stringify(options)}) will be ignored.`; const MSG_SET_OPTIONS_NOT_CALLED = "No options were set before calling importLibrary. Make sure to configure " + "the loader using setOptions()."; const MSG_SCRIPT_ELEMENT_EXISTS = "There already is a script loading the Google Maps JavaScript " + "API, and no google.maps.importLibrary function is defined. " + "@googlemaps/js-api-loader will proceed to bootstrap the API " + "with the specified options, but the existing script might cause " + "problems using the API. Make sure to remove the script " + "loading the API."; const __DEV__$1 = process.env.NODE_ENV !== 'production'; const logDevWarning = __DEV__$1 ? (message) => { console.warn(`[@googlemaps/js-api-loader] ${message}`); } : () => { }; const logDevNotice = __DEV__$1 ? (message) => { console.info(`[@googlemaps/js-api-loader] ${message}`); } : () => { }; /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const __DEV__ = process.env.NODE_ENV !== 'production'; let setOptionsWasCalled_ = false; /** * Sets the options for the Maps JavaScript API. * * Has to be called before any library is loaded. * * See https://developers.google.com/maps/documentation/javascript/load-maps-js-api#required_parameters * for the full documentation of available options. * * @param options The options to set. */ function setOptions(options) { if (setOptionsWasCalled_) { logDevWarning(MSG_REPEATED_SET_OPTIONS(options)); return; } installImportLibrary_(options); setOptionsWasCalled_ = true; } async function importLibrary(libraryName) { if (!setOptionsWasCalled_) { logDevWarning(MSG_SET_OPTIONS_NOT_CALLED); } if (!window?.google?.maps?.importLibrary) { throw new Error("google.maps.importLibrary is not installed."); } return (await google.maps.importLibrary(libraryName)); } /** * The installImportLibrary_ function makes sure that a usable version of the * `google.maps.importLibrary` function exists. */ function installImportLibrary_(options) { const importLibraryExists = Boolean(window.google?.maps?.importLibrary); if (importLibraryExists) { logDevNotice(MSG_IMPORT_LIBRARY_EXISTS(options)); } else if (__DEV__) { const scriptEl = document.querySelector('script[src*="maps.googleapis.com/maps/api/js"]'); if (scriptEl) { logDevWarning(MSG_SCRIPT_ELEMENT_EXISTS); } } // If the google.maps.importLibrary function already exists, bootstrap() // won't do anything, so we won't call it if (!importLibraryExists) { bootstrap(options); } } const APILoadingStatus = { NOT_LOADED: 'NOT_LOADED', LOADING: 'LOADING', LOADED: 'LOADED', FAILED: 'FAILED', AUTH_FAILURE: 'AUTH_FAILURE' }; const DEFAULT_SOLUTION_CHANNEL = 'GMP_visgl_rgmlibrary_v1_default'; const DEFAULT_INTERNAL_USAGE_ATTRIBUTION_IDS = [ `gmp_visgl_reactgooglemaps_v${VERSION}` ]; const APIProviderContext = React.createContext(null); // loading the Maps JavaScript API can only happen once in the runtime, so these // variables are kept at the module level. let loadingStatus = APILoadingStatus.NOT_LOADED; let serializedApiParams; const listeners = new Set(); /** * Called to update the local status and notify the listeners for any mounted * components. * @internal */ function updateLoadingStatus(status) { if (status === loadingStatus) { return; } loadingStatus = status; listeners.forEach(listener => listener(loadingStatus)); } /** * Local hook to set up the map-instance management context. * @internal */ function useMapInstances() { const [mapInstances, setMapInstances] = React.useState({}); const addMapInstance = (mapInstance, id = 'default') => { setMapInstances(instances => (Object.assign(Object.assign({}, instances), { [id]: mapInstance }))); }; const removeMapInstance = (id = 'default') => { setMapInstances((_a) => { var _b = id; _a[_b]; var remaining = __rest(_a, [typeof _b === "symbol" ? _b : _b + ""]); return remaining; }); }; const clearMapInstances = () => { setMapInstances({}); }; return { mapInstances, addMapInstance, removeMapInstance, clearMapInstances }; } /** * local hook to set up the 3D map-instance management context. */ function useMap3DInstances() { const [map3dInstances, setMap3DInstances] = React.useState({}); const addMap3DInstance = (map3dInstance, id = 'default') => { setMap3DInstances(instances => (Object.assign(Object.assign({}, instances), { [id]: map3dInstance }))); }; const removeMap3DInstance = (id = 'default') => { setMap3DInstances((_a) => { var _b = id; _a[_b]; var remaining = __rest(_a, [typeof _b === "symbol" ? _b : _b + ""]); return remaining; }); }; const clearMap3DInstances = () => { setMap3DInstances({}); }; return { map3dInstances, addMap3DInstance, removeMap3DInstance, clearMap3DInstances }; } /** * Local hook to handle the loading of the maps API. * @internal */ function useGoogleMapsApiLoader(props) { const { onLoad, onError, apiKey, version, libraries = [], region, language, authReferrerPolicy, channel, solutionChannel, fetchAppCheckToken } = props; const [status, setStatus] = React.useState(loadingStatus); const [loadedLibraries, addLoadedLibrary] = React.useReducer((loadedLibraries, action) => { return loadedLibraries[action.name] ? loadedLibraries : Object.assign(Object.assign({}, loadedLibraries), { [action.name]: action.value }); }, {}); const currentSerializedParams = React.useMemo(() => { const params = { apiKey, version, libraries: libraries.join(','), region, language, authReferrerPolicy, channel, solutionChannel }; return JSON.stringify(params); }, [ apiKey, version, libraries, region, language, authReferrerPolicy, channel, solutionChannel ]); const importLibraryCallback = React.useCallback((name) => __awaiter(this, void 0, void 0, function* () { if (loadedLibraries[name]) { return loadedLibraries[name]; } const res = yield importLibrary(name); addLoadedLibrary({ name, value: res }); return res; }), [loadedLibraries]); // effect: we want to get notified of global loading-status changes React.useEffect(() => { listeners.add(setStatus); // sync component state on mount (shouldn't be different from the initial state) setStatus(loadingStatus); return () => { listeners.delete(setStatus); }; }, []); // effect: set and store options React.useEffect(() => { (() => __awaiter(this, void 0, void 0, function* () { var _a, _b; try { // This indicates that the API has been loaded with a different set of parameters. // While this is not blocking, it's not recommended and we should warn the user. if (serializedApiParams && serializedApiParams !== currentSerializedParams) { console.warn(`The Google Maps JavaScript API has already been loaded with different parameters. ` + `The new parameters will be ignored. If you need to use different parameters, ` + `please refresh the page.`); } const librariesToLoad = ['core', 'maps', ...libraries]; // If the google.maps namespace is already available, the API has been loaded externally. if ((_b = (_a = window.google) === null || _a === void 0 ? void 0 : _a.maps) === null || _b === void 0 ? void 0 : _b.importLibrary) { if (!serializedApiParams) { updateLoadingStatus(APILoadingStatus.LOADED); } yield Promise.all(librariesToLoad.map(name => importLibraryCallback(name))); if (onLoad) onLoad(); return; } // Abort if the API is already loading or has been loaded. if (loadingStatus === APILoadingStatus.LOADING || loadingStatus === APILoadingStatus.LOADED) { if (loadingStatus === APILoadingStatus.LOADED && onLoad) onLoad(); return; } serializedApiParams = currentSerializedParams; updateLoadingStatus(APILoadingStatus.LOADING); const options = Object.fromEntries(Object.entries({ key: apiKey, v: version, libraries, region, language, authReferrerPolicy }).filter(([, value]) => value !== undefined)); if (channel !== undefined && channel >= 0 && channel <= 999) { options.channel = String(channel); } // solution-channel: when undefined, use the default; otherwise use // an explicit value. if (solutionChannel === undefined) { options.solutionChannel = DEFAULT_SOLUTION_CHANNEL; } else if (solutionChannel !== '') { options.solutionChannel = solutionChannel; } // this will actually trigger loading the maps API setOptions(options); // wait for all requested libraries (inluding 'core' and 'maps') to // finish loading yield Promise.all(librariesToLoad.map(name => importLibraryCallback(name))); updateLoadingStatus(APILoadingStatus.LOADED); if (onLoad) { onLoad(); } } catch (error) { updateLoadingStatus(APILoadingStatus.FAILED); if (onError) { onError(error); } else { console.error('The Google Maps JavaScript API failed to load.', error); } } }))(); }, // eslint-disable-next-line react-hooks/exhaustive-deps [currentSerializedParams, onLoad, onError, importLibraryCallback, libraries]); // set the fetchAppCheckToken if provided React.useEffect(() => { if (status !== APILoadingStatus.LOADED) return; const settings = google.maps.Settings.getInstance(); if (fetchAppCheckToken) { settings.fetchAppCheckToken = fetchAppCheckToken; } else if (settings.fetchAppCheckToken) { settings.fetchAppCheckToken = null; } }, [status, fetchAppCheckToken]); return { status, loadedLibraries, importLibrary: importLibraryCallback }; } function useInternalUsageAttributionIds(props) { return React.useMemo(() => props.disableUsageAttribution ? null : DEFAULT_INTERNAL_USAGE_ATTRIBUTION_IDS, [props.disableUsageAttribution]); } /** * Component to wrap the components from this library and load the Google Maps JavaScript API */ const APIProvider = props => { const { children } = props, loaderProps = __rest(props, ["children"]); const { mapInstances, addMapInstance, removeMapInstance, clearMapInstances } = useMapInstances(); const { map3dInstances, addMap3DInstance, removeMap3DInstance, clearMap3DInstances } = useMap3DInstances(); const { status, loadedLibraries, importLibrary } = useGoogleMapsApiLoader(loaderProps); const internalUsageAttributionIds = useInternalUsageAttributionIds(loaderProps); const contextValue = React.useMemo(() => ({ mapInstances, addMapInstance, removeMapInstance, clearMapInstances, map3dInstances, addMap3DInstance, removeMap3DInstance, clearMap3DInstances, status, loadedLibraries, importLibrary, internalUsageAttributionIds }), [ mapInstances, addMapInstance, removeMapInstance, clearMapInstances, map3dInstances, addMap3DInstance, removeMap3DInstance, clearMap3DInstances, status, loadedLibraries, importLibrary, internalUsageAttributionIds ]); return (React.createElement(APIProviderContext.Provider, { value: contextValue }, children)); }; /** * @internal * Resets module-level state for testing purposes only. * This should never be used in production code. */ function __resetModuleState() { loadingStatus = APILoadingStatus.NOT_LOADED; serializedApiParams = undefined; listeners.clear(); } /** * 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 React.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) { var _a; 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 || center === void 0 ? void 0 : center.toJSON()) || { lat: 0, lng: 0 }, zoom: zoom || 0, heading: heading, tilt: tilt, bounds: (bounds === null || bounds === void 0 ? void 0 : bounds.toJSON()) || { north: 90, east: 180, south: -90, west: -180 } }; return camEvent; } else if (mouseEventTypes.includes(type)) { if (!srcEvent) throw new Error('[createEvent] mouse events must provide a srcEvent'); const mouseEvent = ev; mouseEvent.domEvent = srcEvent.domEvent; mouseEvent.stoppable = true; mouseEvent.stop = () => srcEvent.stop(); mouseEvent.detail = { latLng: ((_a = srcEvent.latLng) === null || _a === void 0 ? void 0 : _a.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); /* eslint-disable react-hooks/refs */ // refs should not be used in render because changes to refs won't // trigger a re-render, making them unreliable for holding state. // In this case though, that is exactly what we want. function useMemoized(value, isEqual) { const ref = React.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 React.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 _a; return ((_a = React.useContext(APIProviderContext)) === null || _a === void 0 ? void 0 : _a.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; React.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 toLatLngBoundsLiteral(obj) { if ('north' in obj && 'south' in obj && 'east' in obj && 'west' in obj) { return obj; } const ne = obj.getNorthEast().toJSON(); const sw = obj.getSouthWest().toJSON(); return { north: ne.lat, east: ne.lng, south: sw.lat, west: sw.lng }; } function boundsEquals(a, b) { if (!a || !b) return false; const A = toLatLngBoundsLiteral(a); const B = toLatLngBoundsLiteral(b); return (A.north === B.north && A.south === B.south && A.east === B.east && A.west === B.west); } /** * Compares two paths (arrays of LatLng points) for equality. */ function pathEquals(a, b) { if (!a || !b) return a === b; const arrayB = 'getArray' in b ? b.getArray() : b; if (a.length !== arrayB.length) return false; for (let i = 0; i < a.length; i++) { if (!latLngEquals(a[i], arrayB[i])) return false; } return true; } /** * Compares two arrays of paths (for Polygon) for equality. */ function pathsEquals(a, b) { if (!a || !b) return a === b; const arrayB = 'getArray' in b ? b.getArray().map(inner => inner.getArray()) : b; if (a.length !== arrayB.length) return false; for (let i = 0; i < a.length; i++) { if (!pathEquals(a[i], arrayB[i])) return false; } return true; } 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. React.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 (React.createElement("div", { style: style }, React.createElement("h2", null, "Error: AuthFailure"), React.createElement("p", null, "A problem with your API key prevents the map from rendering correctly. Please make sure the value of the ", React.createElement("code", null, "APIProvider.apiKey"), " prop is correct. Check the error-message in the console for further details."))); }; function useCallbackRef() { const [el, setEl] = React.useState(null); const ref = React.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] = React.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 || center === void 0 ? 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 = React.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). React.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; } /** * 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 mapId/renderingType/colorScheme (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); } } CachedMapStack.entries = {}; /** * 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, context) { const apiIsLoaded = useApiIsLoaded(); const [map, setMap] = React.useState(null); const [container, containerRef] = useCallbackRef(); const cameraStateRef = useTrackedCameraStateRef(map); const { id, defaultBounds, defaultCenter, defaultZoom, defaultHeading, defaultTilt, reuseMaps, renderingType, colorScheme } = props, mapOptions = __rest(props, ["id", "defaultBounds", "defaultCenter", "defaultZoom", "defaultHeading", "defaultTilt", "reuseMaps", "renderingType", "colorScheme"]); 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; // Handle internalUsageAttributionIds const customIds = mapOptions.internalUsageAttributionIds; if (customIds == null) { // Not specified - use context default (which may be null if disabled) mapOptions.internalUsageAttributionIds = context.internalUsageAttributionIds; } else { // Merge context defaults with custom IDs mapOptions.internalUsageAttributionIds = [ ...(context.internalUsageAttributionIds || []), ...customIds ]; } for (const key of Object.keys(mapOptions)) if (mapOptions[key] === undefined) delete mapOptions[key]; const savedMapStateRef = React.useRef(undefined); // create the map instance and register it in the context React.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 sometimes causes the map to collapse // and no longer render tiles that should be in view after re-attaching it. // Triggering moveCamera after remounting should trigger a re-layout of // the map. setTimeout(() => map.moveCamera({}), 0); } else { mapDiv = document.createElement('div'); mapDiv.style.height = '100%'; container.appendChild(mapDiv); map = new google.maps.Map(mapDiv, Object.assign(Object.assign(Object.assign({}, mapOptions), (renderingType ? { renderingType: renderingType } : {})), (colorScheme ? { colorScheme: colorScheme } : {}))); } setMap(map); addMapInstance(map, id); if (defaultBounds) { const { padding } = defaultBounds, defBounds = __rest(defaultBounds, ["padding"]); 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.moveCamera(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 = React.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 React.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 = React.useMemo(() => { var _a, _b, _c; return { center: { lat: lat !== null && lat !== void 0 ? lat : 0, lng: lng !== null && lng !== void 0 ? lng : 0 }, zoom: (_a = props.zoom) !== null && _a !== void 0 ? _a : 0, heading: (_b = props.heading) !== null && _b !== void 0 ? _b : 0, tilt: (_c = props.tilt) !== null && _c !== void 0 ? _c : 0 }; }, [lat, lng, props.zoom, props.heading, props.tilt]); // externally controlled mode: reject all camera changes that don't correspond to changes in props React.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 = React.useMemo(() => (Object.assign({ 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 = React.useMemo(() => ({ map }), [map]); if (loadingStatus === APILoadingStatus.AUTH_FAILURE) { return (React.createElement("div", { style: Object.assign({ position: 'relative' }, (className ? {} :