From 20815dc46086b6773d7f0d364afe9bec8aff5412 Mon Sep 17 00:00:00 2001 From: Malte Modrow Date: Tue, 7 Oct 2025 16:41:17 +0200 Subject: [PATCH 01/21] fix: make pointer events more robust --- .../advanced-marker-interaction/src/app.tsx | 48 +++++++++--- .../advanced-marker-interaction/src/data.ts | 16 +++- .../advanced-marker-interaction/tsconfig.json | 11 +++ .../types/global.d.ts | 7 ++ src/components/advanced-marker.tsx | 74 +++++++++---------- src/components/info-window.tsx | 14 ++-- src/components/pin.tsx | 4 +- src/libraries/global-style-manager.ts | 38 ++++++++++ 8 files changed, 152 insertions(+), 60 deletions(-) create mode 100644 examples/advanced-marker-interaction/tsconfig.json create mode 100644 examples/advanced-marker-interaction/types/global.d.ts create mode 100644 src/libraries/global-style-manager.ts diff --git a/examples/advanced-marker-interaction/src/app.tsx b/examples/advanced-marker-interaction/src/app.tsx index ccb2f1bc..22eb8391 100644 --- a/examples/advanced-marker-interaction/src/app.tsx +++ b/examples/advanced-marker-interaction/src/app.tsx @@ -13,10 +13,10 @@ import { CollisionBehavior } from '@vis.gl/react-google-maps'; -import {getData} from './data'; - import ControlPanel from './control-panel'; +import {getData, MarkerType, textSnippets} from './data'; + import './style.css'; export type AnchorPointName = keyof typeof AdvancedMarkerAnchorPoint; @@ -27,7 +27,10 @@ export type AnchorPointName = keyof typeof AdvancedMarkerAnchorPoint; // thus appear in front. const data = getData() .sort((a, b) => b.position.lat - a.position.lat) - .map((dataItem, index) => ({...dataItem, zIndex: index})); + .map((dataItem, index) => ({ + ...dataItem, + zIndex: index + })); const Z_INDEX_SELECTED = data.length; const Z_INDEX_HOVER = data.length + 1; @@ -40,8 +43,13 @@ const App = () => { const [hoverId, setHoverId] = useState(null); const [selectedId, setSelectedId] = useState(null); + const [infowindowContent, setInfowindowContent] = useState( + null + ); - const [anchorPoint, setAnchorPoint] = useState('BOTTOM' as AnchorPointName); + const [anchorPoint, setAnchorPoint] = useState( + 'LEFT_CENTER' as AnchorPointName + ); const [selectedMarker, setSelectedMarker] = useState(null); const [infoWindowShown, setInfoWindowShown] = useState(false); @@ -49,13 +57,19 @@ const App = () => { const onMouseEnter = useCallback((id: string | null) => setHoverId(id), []); const onMouseLeave = useCallback(() => setHoverId(null), []); const onMarkerClick = useCallback( - (id: string | null, marker?: google.maps.marker.AdvancedMarkerElement) => { + ( + id: string | null, + marker?: google.maps.marker.AdvancedMarkerElement, + type?: MarkerType + ) => { setSelectedId(id); if (marker) { setSelectedMarker(marker); } + setInfowindowContent(type ? textSnippets[type] : null); + if (id !== selectedId) { setInfoWindowShown(true); } else { @@ -97,12 +111,25 @@ const App = () => { zIndex = Z_INDEX_SELECTED; } + if (type === 'default') { + return ( + onMarkerClick(id, marker, type)} + onMouseEnter={() => onMouseEnter(id)} + onMouseLeave={onMouseLeave} + /> + ); + } + if (type === 'pin') { return ( onMarkerClick(id, marker)} + ) => onMarkerClick(id, marker, type)} onMouseEnter={() => onMouseEnter(id)} onMouseLeave={onMouseLeave} key={id} @@ -114,7 +141,7 @@ const App = () => { }} position={position}> @@ -137,7 +164,9 @@ const App = () => { }} onMarkerClick={( marker: google.maps.marker.AdvancedMarkerElement - ) => onMarkerClick(id, marker)} + ) => { + onMarkerClick(id, marker, type); + }} onMouseEnter={() => onMouseEnter(id)} collisionBehavior={ CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY @@ -166,11 +195,12 @@ const App = () => { {infoWindowShown && selectedMarker && (

Marker {selectedId}

-

Some arbitrary html to be rendered into the InfoWindow.

+

{infowindowContent}

)} diff --git a/examples/advanced-marker-interaction/src/data.ts b/examples/advanced-marker-interaction/src/data.ts index 57790275..0c37cc58 100644 --- a/examples/advanced-marker-interaction/src/data.ts +++ b/examples/advanced-marker-interaction/src/data.ts @@ -1,20 +1,32 @@ +export type MarkerType = 'default' | 'pin' | 'html'; + type MarkerData = Array<{ id: string; position: google.maps.LatLngLiteral; - type: 'pin' | 'html'; + type: MarkerType; zIndex: number; + infowindowContent?: string; }>; +export const textSnippets = { + default: 'This is a default AdvancedMarkerElement without custom content', + pin: 'This is a AdvancedMarkerElement with custom pin-style marker', + html: 'This is a AdvancedMarkerElement with custom HTML content' +} as const; + export function getData() { const data: MarkerData = []; // create 50 random markers for (let index = 0; index < 50; index++) { + const type = + Math.random() < 0.1 ? 'default' : Math.random() < 0.5 ? 'pin' : 'html'; + data.push({ id: String(index), position: {lat: rnd(53.52, 53.63), lng: rnd(9.88, 10.12)}, zIndex: index, - type: Math.random() < 0.5 ? 'pin' : 'html' + type }); } diff --git a/examples/advanced-marker-interaction/tsconfig.json b/examples/advanced-marker-interaction/tsconfig.json new file mode 100644 index 00000000..dca80a4f --- /dev/null +++ b/examples/advanced-marker-interaction/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@vis.gl/react-google-maps": ["../../src"] + } + }, + "include": ["src/**/*", "../../src/**/*", "./types/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/advanced-marker-interaction/types/global.d.ts b/examples/advanced-marker-interaction/types/global.d.ts new file mode 100644 index 00000000..b65b2bbc --- /dev/null +++ b/examples/advanced-marker-interaction/types/global.d.ts @@ -0,0 +1,7 @@ +export declare global { + // const or let does not work in this case, it has to be var + // eslint-disable-next-line no-var + var GOOGLE_MAPS_API_KEY: string | undefined; + // eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any + var process: any; +} diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 9dbabf79..5e35f2b7 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -18,6 +18,7 @@ import type {Ref, PropsWithChildren} from 'react'; import {useMapsEventListener} from '../hooks/use-maps-event-listener'; import {usePropBinding} from '../hooks/use-prop-binding'; import {useDomEventListener} from '../hooks/use-dom-event-listener'; +import {globalStyleManager} from '../libraries/global-style-manager'; export interface AdvancedMarkerContextValue { marker: google.maps.marker.AdvancedMarkerElement; @@ -119,42 +120,16 @@ type MarkerContentProps = PropsWithChildren & { anchorPoint?: AdvancedMarkerAnchorPoint | [string, string]; }; -const MarkerContent = ({ - children, - styles, - className, - anchorPoint -}: MarkerContentProps) => { - const [xTranslation, yTranslation] = - 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})`; - +const MarkerContent = ({children, styles, className}: MarkerContentProps) => { + /* AdvancedMarker div that user can give styles and classes */ return ( - // anchoring container -
- {/* AdvancedMarker div that user can give styles and classes */} -
- {children} -
+
+ {children}
); }; -export type CustomMarkerContent = - | (HTMLDivElement & {isCustomMarker?: boolean}) - | null; +export type CustomMarkerContent = HTMLDivElement | null; export type AdvancedMarkerRef = google.maps.marker.AdvancedMarkerElement | null; function useAdvancedMarker(props: AdvancedMarkerProps) { @@ -180,11 +155,15 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { draggable, position, title, - zIndex + zIndex, + anchorPoint } = props; const numChildren = Children.count(children); + const [xTranslation, yTranslation] = + anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; + // create an AdvancedMarkerElement instance and add it to the map once available useEffect(() => { if (!map || !markerLibrary) return; @@ -200,9 +179,25 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { 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; + // in the infowindow component. Choosing a data attribute to also be able + // to target it via CSS to disable pointer event when using custom anchor point + newMarker.dataset.origin = 'rgm'; + + 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})`; + contentElement.style.transform = transformStyle; + globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); newMarker.content = contentElement; setContentContainer(contentElement); @@ -214,7 +209,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { setMarker(null); setContentContainer(null); }; - }, [map, markerLibrary, numChildren]); + }, [xTranslation, yTranslation, 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 @@ -264,11 +259,10 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { // enable pointer events for the markers with custom content if (gmpClickable && marker?.content && isElementNode(marker.content)) { - marker.content.style.pointerEvents = 'none'; + marker.content.style.pointerEvents = 'all'; - if (marker.content.firstElementChild) { - (marker.content.firstElementChild as HTMLElement).style.pointerEvents = - 'all'; + if (onClick) { + marker.content.style.cursor = 'pointer'; } } }, [marker, clickable, onClick, onMouseEnter, onMouseLeave]); diff --git a/src/components/info-window.tsx b/src/components/info-window.tsx index 384134a8..3f252430 100644 --- a/src/components/info-window.tsx +++ b/src/components/info-window.tsx @@ -16,7 +16,7 @@ import {useMapsEventListener} from '../hooks/use-maps-event-listener'; import {useMapsLibrary} from '../hooks/use-maps-library'; import {useMemoized} from '../hooks/use-memoized'; import {setValueForStyles} from '../libraries/set-value-for-styles'; -import {CustomMarkerContent, isAdvancedMarker} from './advanced-marker'; +import {isAdvancedMarker} from './advanced-marker'; export type InfoWindowProps = Omit< google.maps.InfoWindowOptions, @@ -187,14 +187,13 @@ export const InfoWindow: FunctionComponent< // Only do the infowindow adjusting when dealing with an AdvancedMarker if (isAdvancedMarker(anchor) && anchor.content instanceof Element) { - const wrapper = anchor.content as CustomMarkerContent; - const wrapperBcr = wrapper?.getBoundingClientRect(); + const anchorBcr = anchor?.getBoundingClientRect(); // This checks whether or not the anchor has custom content with our own // div wrapper. If not, that means we have a regular AdvancedMarker without any children. // In that case we do not want to adjust the infowindow since it is all handled correctly // by the Google Maps API. - if (wrapperBcr && wrapper?.isCustomMarker) { + if (anchorBcr && anchor.dataset.origin === 'rgm') { // We can safely typecast here since we control that element and we know that // it is a div const anchorDomContent = anchor.content.firstElementChild @@ -205,9 +204,10 @@ export const InfoWindow: FunctionComponent< // center infowindow above marker const anchorOffsetX = contentBcr.x - - wrapperBcr.x + - (contentBcr.width - wrapperBcr.width) / 2; - const anchorOffsetY = contentBcr.y - wrapperBcr.y; + anchorBcr.x + + (contentBcr.width - anchorBcr.width) / 2; + + const anchorOffsetY = contentBcr.y - anchorBcr.y; const opts: google.maps.InfoWindowOptions = infoWindowOptions; diff --git a/src/components/pin.tsx b/src/components/pin.tsx index e6044e96..78a85943 100644 --- a/src/components/pin.tsx +++ b/src/components/pin.tsx @@ -61,8 +61,8 @@ export const Pin: FunctionComponent = props => { // Set content of Advanced Marker View to the Pin View element // Here we are selecting the anchor container. // The hierarchy is as follows: - // "advancedMarker.content" (from google) -> "pointer events reset div" -> "anchor container" - const markerContent = advancedMarker.content?.firstChild?.firstChild; + // "advancedMarker.content" (from google) -> "anchor container" + const markerContent = advancedMarker.content?.firstChild; while (markerContent?.firstChild) { markerContent.removeChild(markerContent.firstChild); diff --git a/src/libraries/global-style-manager.ts b/src/libraries/global-style-manager.ts new file mode 100644 index 00000000..879fc84c --- /dev/null +++ b/src/libraries/global-style-manager.ts @@ -0,0 +1,38 @@ +// Global style manager to track rendered styles and avoid duplicates +class GlobalStyleManager { + private renderedStyles = new Set(); + private styleElement: HTMLStyleElement | null = null; + + private getStyleElement(): HTMLStyleElement { + if (!this.styleElement) { + this.styleElement = document.createElement('style'); + this.styleElement.setAttribute('data-rgm-anchor-styles', ''); + document.head.appendChild(this.styleElement); + } + return this.styleElement; + } + + addAdvancedMarkerPointerEventsOverwrite(): void { + if (this.renderedStyles.has('marker-pointer-events')) { + return; + } + + const styleElement = this.getStyleElement(); + styleElement.textContent += ` + gmp-advanced-marker[data-origin='rgm'] { + pointer-events: none !important; + } + `; + this.renderedStyles.add('marker-pointer-events'); + } + + cleanup(): void { + if (this.styleElement) { + this.styleElement.remove(); + this.styleElement = null; + this.renderedStyles.clear(); + } + } +} + +export const globalStyleManager = new GlobalStyleManager(); From 5800cae43e6d15457b390d115d8e275abed71e13 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 20 Oct 2025 17:11:45 +0200 Subject: [PATCH 02/21] feat(advanced-marker): add anchorLeft and anchorTop props Adds support for the 'anchorLeft' and 'anchorTop' properties on the AdvancedMarker component. This provides a more direct way to control the anchor point of the marker, especially with modern versions of the Google Maps JavaScript API. The implementation includes: - A new internal 'useAdvancedMarkerAnchorPoint' hook to encapsulate anchoring logic. - Version detection to use native 'anchorLeft'/'anchorTop' properties on Google Maps API v3.62+ and fallback to a CSS transform on older versions. - A warning is logged when using the new props on unsupported API versions. - Added TypeScript definitions of anchor options to type augmentation - Added API documentation. --- .../components/advanced-marker.md | 19 ++ src/components/advanced-marker.tsx | 206 ++++++++++++------ src/libraries/version-utils.ts | 15 ++ types/google.maps.d.ts | 26 ++- 4 files changed, 193 insertions(+), 73 deletions(-) create mode 100644 src/libraries/version-utils.ts diff --git a/docs/api-reference/components/advanced-marker.md b/docs/api-reference/components/advanced-marker.md index a89aa10f..e9162048 100644 --- a/docs/api-reference/components/advanced-marker.md +++ b/docs/api-reference/components/advanced-marker.md @@ -173,6 +173,25 @@ The position is measured from the top-left corner and can be anything that can be consumed by a CSS translate() function. For example in percent `[10%, 90%]` or in pixels `[10px, 20px]`. +#### `anchorLeft`: string + +A [CSS length-percentage] value which is used to translate the marker +content relative to the anchor point. A value of 0 means the anchor-point +will be at the left edge of the content-element. The default value is `-%50`, +so the anchor point will be at the center of the content element. +You can also use CSS `calc()` expressions to combine percentage and pixel +values. + +#### `anchorTop`: string + +A [CSS length-percentage] value which is used to translate the marker content +relative to the anchor point. When this value is 0, the anchor-point will be +at the top-edge of the content element. The default value is `-%100`, which +places the anchor-point at the bottom edge. You can also use CSS `calc()` +expressions to combine percentage and pixel values. + +[CSS length-percentage]: https://developer.mozilla.org/en-US/docs/Web/CSS/length-percentage + ### Other Props #### `clickable`: boolean diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 0ecff488..591f4173 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -1,3 +1,4 @@ +import type {PropsWithChildren, Ref} from 'react'; import React, { Children, CSSProperties, @@ -12,29 +13,16 @@ import React, { import {createPortal} from 'react-dom'; import {useMap} from '../hooks/use-map'; import {useMapsLibrary} from '../hooks/use-maps-library'; - -import type {Ref, PropsWithChildren} from 'react'; import {useMapsEventListener} from '../hooks/use-maps-event-listener'; import {usePropBinding} from '../hooks/use-prop-binding'; import {useDomEventListener} from '../hooks/use-dom-event-listener'; import {globalStyleManager} from '../libraries/global-style-manager'; +import {isVersionGreaterEqual} from '../libraries/version-utils'; export interface AdvancedMarkerContextValue { marker: google.maps.marker.AdvancedMarkerElement; } -export function isAdvancedMarker( - marker: google.maps.Marker | google.maps.marker.AdvancedMarkerElement -): marker is google.maps.marker.AdvancedMarkerElement { - return ( - (marker as google.maps.marker.AdvancedMarkerElement).content !== undefined - ); -} - -function isElementNode(node: Node): node is HTMLElement { - 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. @@ -101,6 +89,14 @@ export type AdvancedMarkerProps = PropsWithChildren< * For example in percent ("50%") or in pixels ("20px"). */ anchorPoint?: AdvancedMarkerAnchorPoint | [string, string]; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%50". + */ + anchorLeft?: string; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%100". + */ + anchorTop?: string; /** * A className for the content element. * (can only be used with HTML Marker content) @@ -119,6 +115,63 @@ type MarkerContentProps = PropsWithChildren & { anchorPoint?: AdvancedMarkerAnchorPoint | [string, string]; }; +export const AdvancedMarker = forwardRef( + (props: AdvancedMarkerProps, ref: Ref) => { + const {children, style, className, anchorPoint} = props; + const [marker, contentContainer] = useAdvancedMarker(props); + + const advancedMarkerContextValue: AdvancedMarkerContextValue | null = + useMemo(() => (marker ? {marker} : null), [marker]); + + useImperativeHandle( + ref, + () => marker as google.maps.marker.AdvancedMarkerElement, + [marker] + ); + + if (!contentContainer) return null; + + return ( + + {createPortal( + + {children} + , + contentContainer + )} + + ); + } +); + +AdvancedMarker.displayName = 'AdvancedMarker'; + +export function useAdvancedMarkerRef() { + const [marker, setMarker] = + useState(null); + + const refCallback = useCallback((m: AdvancedMarkerRef | null) => { + setMarker(m); + }, []); + + return [refCallback, marker] as const; +} + +export function isAdvancedMarker( + marker: google.maps.Marker | google.maps.marker.AdvancedMarkerElement +): marker is google.maps.marker.AdvancedMarkerElement { + return ( + (marker as google.maps.marker.AdvancedMarkerElement).content !== undefined + ); +} + +function isElementNode(node: Node): node is HTMLElement { + return node.nodeType === Node.ELEMENT_NODE; +} + const MarkerContent = ({children, styles, className}: MarkerContentProps) => { /* AdvancedMarker div that user can give styles and classes */ return ( @@ -131,6 +184,7 @@ const MarkerContent = ({children, styles, className}: MarkerContentProps) => { export type CustomMarkerContent = HTMLDivElement | null; export type AdvancedMarkerRef = google.maps.marker.AdvancedMarkerElement | null; + function useAdvancedMarker(props: AdvancedMarkerProps) { const [marker, setMarker] = useState(null); @@ -155,14 +209,13 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { position, title, zIndex, - anchorPoint + anchorPoint, + anchorLeft, + anchorTop } = props; const numChildren = Children.count(children); - const [xTranslation, yTranslation] = - anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; - // create an AdvancedMarkerElement instance and add it to the map once available useEffect(() => { if (!map || !markerLibrary) return; @@ -182,22 +235,6 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { // to target it via CSS to disable pointer event when using custom anchor point newMarker.dataset.origin = 'rgm'; - 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})`; - contentElement.style.transform = transformStyle; - globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); - newMarker.content = contentElement; setContentContainer(contentElement); } @@ -208,7 +245,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { setMarker(null); setContentContainer(null); }; - }, [xTranslation, yTranslation, map, markerLibrary, numChildren]); + }, [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 @@ -221,6 +258,14 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { marker.content.className = className ?? ''; }, [marker, className, numChildren]); + useAdvancedMarkerAnchorPoint( + marker, + anchorPoint, + anchorLeft, + anchorTop, + numChildren > 0 + ); + // copy other props usePropBinding(marker, 'position', position); usePropBinding(marker, 'title', title ?? ''); @@ -277,47 +322,64 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { return [marker, contentContainer] as const; } -export const AdvancedMarker = forwardRef( - (props: AdvancedMarkerProps, ref: Ref) => { - const {children, style, className, anchorPoint} = props; - const [marker, contentContainer] = useAdvancedMarker(props); - - const advancedMarkerContextValue: AdvancedMarkerContextValue | null = - useMemo(() => (marker ? {marker} : null), [marker]); - - useImperativeHandle( - ref, - () => marker as google.maps.marker.AdvancedMarkerElement, - [marker] - ); +function useAdvancedMarkerAnchorPoint( + marker: google.maps.marker.AdvancedMarkerElement | null, + anchorPoint: AdvancedMarkerAnchorPoint | [string, string] | undefined, + anchorLeft: string | undefined, + anchorTop: string | undefined, + hasChildren: boolean +) { + useEffect(() => { + if (!marker || !hasChildren) return; + + // The anchorLeft and anchorTop options are available since version 3.62.9c + // With the release of 3.65 (~May 2026) there will no longer be a version + // that doesn't support it. + const anchorOptionsSupported = isVersionGreaterEqual(3, 62); + const contentElement = marker.content; + if (!contentElement || !isElementNode(contentElement)) return; + + if (anchorLeft !== undefined || anchorTop !== undefined) { + if (!anchorOptionsSupported) { + console.warn( + 'AdvancedMarker: The anchorLeft and anchorTop props are only supported ' + + 'in Google Maps API version 3.62 and above. ' + + `The current version is ${google.maps.version}.` + ); + } - if (!contentContainer) return null; + marker.anchorLeft = anchorLeft; + marker.anchorTop = anchorTop; - return ( - - {createPortal( - - {children} - , - contentContainer - )} - - ); - } -); + // when anchorLeft and/or anchorTop are set, we'll ignore the anchorPoint + if (anchorPoint !== undefined) { + console.warn( + 'AdvancedMarker: the anchorPoint prop is ignored when anchorLeft ' + + 'and/or anchorTop are set.' + ); + } + return; + } -AdvancedMarker.displayName = 'AdvancedMarker'; + if (anchorPoint !== undefined) { + const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; + const translateX = `calc(-1 * ${x})`; + const translateY = `calc(-1 * ${y})`; -export function useAdvancedMarkerRef() { - const [marker, setMarker] = - useState(null); + if (anchorOptionsSupported) { + // implement anchorPoint using the new anchorLeft and anchorTop options + marker.anchorLeft = translateX; + marker.anchorTop = translateY; - const refCallback = useCallback((m: AdvancedMarkerRef | null) => { - setMarker(m); - }, []); + // reset transform from legacy implementation + contentElement.style.transform = ''; + } else { + // The "translate(50%, 100%)" counters and resets the default + // anchoring of the advanced marker element from the api + contentElement.style.transform = `translate(50%, 100%) translate(${translateX}, ${translateY})`; - return [refCallback, marker] as const; + globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); + } + } + }, [marker, anchorPoint, anchorLeft, anchorTop, hasChildren]); } diff --git a/src/libraries/version-utils.ts b/src/libraries/version-utils.ts new file mode 100644 index 00000000..bb9fe226 --- /dev/null +++ b/src/libraries/version-utils.ts @@ -0,0 +1,15 @@ +export function isVersionGreaterEqual( + major: number, + minor: number +): boolean | undefined { + if (!google?.maps?.version) return undefined; + + const version = google.maps.version.split('.'); + + const currentMajor = parseInt(version[0], 10); + const currentMinor = parseInt(version[1], 10); + + return ( + currentMajor > major || (currentMajor === major && currentMinor >= minor) + ); +} diff --git a/types/google.maps.d.ts b/types/google.maps.d.ts index eb86797b..059128e5 100644 --- a/types/google.maps.d.ts +++ b/types/google.maps.d.ts @@ -14,4 +14,28 @@ declare namespace google.maps { */ internalUsageAttributionIds?: Iterable | null; } -} + + namespace marker { + interface AdvancedMarkerElementOptions { + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%50". + */ + anchorLeft?: string; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%100". + */ + anchorTop?: string; + } + + interface AdvancedMarkerElement { + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%50". + */ + anchorLeft?: string; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%100". + */ + anchorTop?: string; + } + } +} \ No newline at end of file From 67628c671012c69d00457081ffc1f5e499c32c89 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 20 Oct 2025 19:58:13 +0200 Subject: [PATCH 03/21] test(advanced-marker): add anchor prop tests Adds a suite of tests for the anchor-related props ('anchorLeft', 'anchorTop', and 'anchorPoint') on the AdvancedMarker component. This suite covers: - Precedence of 'anchorLeft'/'anchorTop' over 'anchorPoint' on modern APIs. - Correct fallback to 'anchorPoint' on modern APIs. - Correct application of 'anchorPoint' via CSS transform on legacy APIs. - Warning generation when using modern props on legacy APIs. - Snapshot testing for console warnings. --- .../advanced-marker.test.tsx.snap | 17 ++++ .../__tests__/advanced-marker.test.tsx | 93 ++++++++++++++++++- src/components/advanced-marker.tsx | 7 +- 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap diff --git a/src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap b/src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap new file mode 100644 index 00000000..b05367f3 --- /dev/null +++ b/src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`map and marker-library loaded anchoring with legacy API should warn when using anchorLeft/Top 1`] = ` +[ + [ + "AdvancedMarker: The anchorLeft and anchorTop props are only supported in Google Maps API version 3.62 and above. The current version is 3.61.0.", + ], +] +`; + +exports[`map and marker-library loaded anchoring with modern API anchorLeft/anchorTop should have precedence over anchorPoint 1`] = ` +[ + [ + "AdvancedMarker: the anchorPoint prop is ignored when anchorLeft and/or anchorTop are set.", + ], +] +`; diff --git a/src/components/__tests__/advanced-marker.test.tsx b/src/components/__tests__/advanced-marker.test.tsx index 84013cc2..17604e53 100644 --- a/src/components/__tests__/advanced-marker.test.tsx +++ b/src/components/__tests__/advanced-marker.test.tsx @@ -4,7 +4,7 @@ import {initialize, mockInstances} from '@googlemaps/jest-mocks'; import {cleanup, queryByTestId, render} from '@testing-library/react'; import '@testing-library/jest-dom'; -import {AdvancedMarker} from '../advanced-marker'; +import {AdvancedMarker, AdvancedMarkerAnchorPoint} from '../advanced-marker'; import {useMap} from '../../hooks/use-map'; import {useMapsLibrary} from '../../hooks/use-maps-library'; @@ -162,4 +162,95 @@ describe('map and marker-library loaded', () => { test.todo('marker should work with options'); test.todo('marker should have a click listener'); + + describe('anchoring with modern API', () => { + beforeEach(() => { + google.maps.version = '3.62.9'; + }); + + test('anchorLeft/anchorTop should have precedence over anchorPoint', async () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render( + +
+ + ); + const marker = await waitForMockInstance( + google.maps.marker.AdvancedMarkerElement + ); + + expect(marker.anchorLeft).toBe('10px'); + expect(marker.anchorTop).toBe('20px'); + expect(consoleWarnSpy.mock.calls).toMatchSnapshot(); + + consoleWarnSpy.mockRestore(); + }); + + test('anchorPoint should be used as fallback', async () => { + render( + +
+ + ); + const marker = await waitForMockInstance( + google.maps.marker.AdvancedMarkerElement + ); + + expect(marker.anchorLeft).toBe('calc(-1 * 12%)'); + expect(marker.anchorTop).toBe('calc(-1 * 34%)'); + }); + }); + + describe('anchoring with legacy API', () => { + beforeEach(() => { + google.maps.version = '3.61.0'; + }); + + test('anchorPoint is applied as css transform', async () => { + render( + +
+ + ); + const marker = await waitForMockInstance( + google.maps.marker.AdvancedMarkerElement + ); + + expect(marker.content).toBeInstanceOf(HTMLElement); + expect((marker.content as HTMLElement).style.transform).toBe( + 'translate(50%, 100%) translate(calc(-1 * 50%), calc(-1 * 50%))' + ); + }); + + test('should warn when using anchorLeft/Top', async () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render( + +
+ + ); + await waitForMockInstance(google.maps.marker.AdvancedMarkerElement); + + expect(consoleWarnSpy.mock.calls).toMatchSnapshot(); + + consoleWarnSpy.mockRestore(); + }); + }); }); diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 591f4173..b795620d 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -258,7 +258,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { marker.content.className = className ?? ''; }, [marker, className, numChildren]); - useAdvancedMarkerAnchorPoint( + useAdvancedMarkerAnchoring( marker, anchorPoint, anchorLeft, @@ -322,7 +322,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { return [marker, contentContainer] as const; } -function useAdvancedMarkerAnchorPoint( +function useAdvancedMarkerAnchoring( marker: google.maps.marker.AdvancedMarkerElement | null, anchorPoint: AdvancedMarkerAnchorPoint | [string, string] | undefined, anchorLeft: string | undefined, @@ -363,6 +363,9 @@ function useAdvancedMarkerAnchorPoint( if (anchorPoint !== undefined) { const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; + + // NOTE: since x and y can theoretically be any valid CSS length-percentage + // value, we need to use calc() to negate them. const translateX = `calc(-1 * ${x})`; const translateY = `calc(-1 * ${y})`; From 5b99c8a36e87a0145b6f5c4950ea65e73f928482 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 20 Oct 2025 21:26:48 +0200 Subject: [PATCH 04/21] feat(advanced-marker): deprecate anchorPoint prop Marks the 'anchorPoint' prop as deprecated in favor of the 'anchorLeft' and 'anchorTop' props. - Adds a '@deprecated' JSDoc tag to the 'anchorPoint' prop. - Adds a TODO comment to add a console warning in a future version. --- src/components/advanced-marker.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index b795620d..5fe394a9 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -81,6 +81,8 @@ export type AdvancedMarkerProps = PropsWithChildren< clickable?: boolean; collisionBehavior?: CollisionBehavior; /** + * @deprecated Use `anchorLeft` and `anchorTop` instead. + * * The anchor point for the Advanced Marker. * Either use one of the predefined anchor point from the "AdvancedMarkerAnchorPoint" export * or provide a string tuple in the form of ["xPosition", "yPosition"]. @@ -362,6 +364,7 @@ function useAdvancedMarkerAnchoring( } if (anchorPoint !== undefined) { + // TODO: add console.warn in a future version to inform about deprecation const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; // NOTE: since x and y can theoretically be any valid CSS length-percentage From 9db18d86c01307dbb093af301688ed8bdf3be72d Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Tue, 21 Oct 2025 10:12:31 +0200 Subject: [PATCH 05/21] fix: restrict event-handling hacks to custom anchoring --- src/components/advanced-marker.tsx | 12 ++++++------ src/components/info-window.tsx | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 5fe394a9..5ec39c82 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -232,11 +232,6 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { 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 data attribute to also be able - // to target it via CSS to disable pointer event when using custom anchor point - newMarker.dataset.origin = 'rgm'; - newMarker.content = contentElement; setContentContainer(contentElement); } @@ -367,7 +362,7 @@ function useAdvancedMarkerAnchoring( // TODO: add console.warn in a future version to inform about deprecation const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; - // NOTE: since x and y can theoretically be any valid CSS length-percentage + // NOTE: since x and y can be any valid CSS length-percentage // value, we need to use calc() to negate them. const translateX = `calc(-1 * ${x})`; const translateY = `calc(-1 * ${y})`; @@ -384,6 +379,11 @@ function useAdvancedMarkerAnchoring( // anchoring of the advanced marker element from the api contentElement.style.transform = `translate(50%, 100%) translate(${translateX}, ${translateY})`; + // We need some kind of flag to identify the custom marker content + // in the infowindow component. Choosing a data attribute to also be able + // to target it via CSS to disable pointer event when using custom anchor point + marker.dataset.origin = 'rgm'; + globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); } } diff --git a/src/components/info-window.tsx b/src/components/info-window.tsx index 38eeee9b..c354cf8d 100644 --- a/src/components/info-window.tsx +++ b/src/components/info-window.tsx @@ -189,7 +189,8 @@ export const InfoWindow: FunctionComponent< const anchorBcr = anchor?.getBoundingClientRect(); // This checks whether or not the anchor has custom content with our own - // div wrapper. If not, that means we have a regular AdvancedMarker without any children. + // div wrapper. If not, that means we have a regular AdvancedMarker without + // children, or an AdvancedMarker that uses the anchorLeft/anchorTop props. // In that case we do not want to adjust the infowindow since it is all handled correctly // by the Google Maps API. if (anchorBcr && anchor.dataset.origin === 'rgm') { From 5ed37903302743237ef6d6f28c2897a93d4bb1cd Mon Sep 17 00:00:00 2001 From: Malte Modrow Date: Wed, 3 Dec 2025 14:47:28 +0100 Subject: [PATCH 06/21] feat: initial map3d implementation --- dev-docs/RFCs/.gitkeep | 0 examples/map-3d-2/README.md | 49 + examples/map-3d-2/index.html | 55 ++ examples/map-3d-2/package.json | 14 + examples/map-3d-2/src/app.tsx | 98 ++ examples/map-3d-2/src/control-panel.tsx | 60 ++ examples/map-3d-2/vite.config.js | 17 + package-lock.json | 4 +- package.json | 1 + src/components/map-3d/index.tsx | 234 +++++ .../map-3d/use-map-3d-camera-params.ts | 95 ++ src/components/map-3d/use-map-3d-events.ts | 288 ++++++ src/components/map-3d/use-map-3d-instance.ts | 115 +++ src/components/map-3d/use-map-3d-options.ts | 50 + .../map-3d/use-tracked-camera-state-ref-3d.ts | 94 ++ src/hooks/use-map-3d.ts | 57 ++ src/hooks/use-maps-library.ts | 1 + src/index.ts | 2 + tsconfig.build.json | 1 + tsconfig.json | 2 +- types/google.maps.d.ts | 854 +++++++++++++++++- 21 files changed, 2087 insertions(+), 4 deletions(-) delete mode 100644 dev-docs/RFCs/.gitkeep create mode 100644 examples/map-3d-2/README.md create mode 100644 examples/map-3d-2/index.html create mode 100644 examples/map-3d-2/package.json create mode 100644 examples/map-3d-2/src/app.tsx create mode 100644 examples/map-3d-2/src/control-panel.tsx create mode 100644 examples/map-3d-2/vite.config.js create mode 100644 src/components/map-3d/index.tsx create mode 100644 src/components/map-3d/use-map-3d-camera-params.ts create mode 100644 src/components/map-3d/use-map-3d-events.ts create mode 100644 src/components/map-3d/use-map-3d-instance.ts create mode 100644 src/components/map-3d/use-map-3d-options.ts create mode 100644 src/components/map-3d/use-tracked-camera-state-ref-3d.ts create mode 100644 src/hooks/use-map-3d.ts diff --git a/dev-docs/RFCs/.gitkeep b/dev-docs/RFCs/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/map-3d-2/README.md b/examples/map-3d-2/README.md new file mode 100644 index 00000000..c3c88419 --- /dev/null +++ b/examples/map-3d-2/README.md @@ -0,0 +1,49 @@ +# Map3D Component Example + +This example demonstrates the new `` component which provides a React wrapper for Google Maps' 3D Map functionality. + +## Features + +- 3D map rendering with photorealistic tiles +- Camera controls (center, range, heading, tilt, roll) +- Camera animation methods (flyCameraAround, flyCameraTo, stopCameraAnimation) +- Event handling for camera changes and clicks + +## Google Maps Platform API Key + +This example requires a valid API key for the Google Maps Platform with the following APIs enabled: +- Maps JavaScript API +- Map Tiles API (for 3D photorealistic tiles) + +See [the official documentation][get-api-key] on how to create and configure your own key. + +The API key has to be provided via an environment variable `GOOGLE_MAPS_API_KEY`. This can be done by creating a +file named `.env` in the example directory with the following content: + +```shell title=".env" +GOOGLE_MAPS_API_KEY="" +``` + +If you are on the CodeSandbox playground you can also choose to [provide the API key like this](https://codesandbox.io/docs/learn/environment/secrets) + +## Development + +Go into the example-directory and run + +```shell +npm install +``` + +To start the example with the local library run + +```shell +npm run start-local +``` + +To start the example with the latest published library version on npm run + +```shell +npm start +``` + +[get-api-key]: https://developers.google.com/maps/documentation/javascript/get-api-key diff --git a/examples/map-3d-2/index.html b/examples/map-3d-2/index.html new file mode 100644 index 00000000..59e3eff5 --- /dev/null +++ b/examples/map-3d-2/index.html @@ -0,0 +1,55 @@ + + + + + + Map3D Component Example + + + + +
+ + + diff --git a/examples/map-3d-2/package.json b/examples/map-3d-2/package.json new file mode 100644 index 00000000..e9fbd2e4 --- /dev/null +++ b/examples/map-3d-2/package.json @@ -0,0 +1,14 @@ +{ + "type": "module", + "dependencies": { + "@vis.gl/react-google-maps": "latest", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.1.7" + }, + "scripts": { + "start": "vite", + "start-local": "vite --config ../vite.config.local.js", + "build": "vite build" + } +} diff --git a/examples/map-3d-2/src/app.tsx b/examples/map-3d-2/src/app.tsx new file mode 100644 index 00000000..53f05786 --- /dev/null +++ b/examples/map-3d-2/src/app.tsx @@ -0,0 +1,98 @@ +import React, {useRef, useState} from 'react'; +import {createRoot} from 'react-dom/client'; + +import {APIProvider, Map3D, Map3DRef} from '@vis.gl/react-google-maps'; +import ControlPanel from './control-panel'; + +const API_KEY = + globalThis.GOOGLE_MAPS_API_KEY ?? (process.env.GOOGLE_MAPS_API_KEY as string); + +// San Francisco coordinates with altitude +const INITIAL_CENTER = {lat: 37.7749, lng: -122.4194, altitude: 500}; +const INITIAL_RANGE = 2000; +const INITIAL_HEADING = 0; +const INITIAL_TILT = 60; + +const App = () => { + const map3dRef = useRef(null); + const [isAnimating, setIsAnimating] = useState(false); + + const handleFlyCameraAround = () => { + if (!map3dRef.current?.map3d) return; + + setIsAnimating(true); + // Use the current camera position for the orbit animation + const map3d = map3dRef.current.map3d; + map3dRef.current.flyCameraAround({ + camera: { + center: map3d.center || INITIAL_CENTER, + range: map3d.range || INITIAL_RANGE, + heading: map3d.heading || INITIAL_HEADING, + tilt: map3d.tilt || INITIAL_TILT + }, + durationMillis: 15000, + repeatCount: 1 + }); + }; + + const handleFlyCameraTo = () => { + if (!map3dRef.current) return; + + setIsAnimating(true); + // Fly to Golden Gate Bridge + map3dRef.current.flyCameraTo({ + endCamera: { + center: {lat: 37.8199, lng: -122.4783, altitude: 100}, + range: 1000, + heading: 45, + tilt: 65 + }, + durationMillis: 5000 + }); + }; + + const handleStopAnimation = () => { + if (!map3dRef.current) return; + + map3dRef.current.stopCameraAnimation(); + setIsAnimating(false); + }; + + return ( + + { + console.log(ev); + }} + defaultTilt={INITIAL_TILT} + mode="HYBRID" + onAnimationEnd={() => setIsAnimating(false)} + onCameraChanged={e => { + console.log('Camera changed:', e.detail); + }} + /> + + + ); +}; + +export default App; + +export function renderToDom(container: HTMLElement) { + const root = createRoot(container); + + root.render( + + + + ); +} diff --git a/examples/map-3d-2/src/control-panel.tsx b/examples/map-3d-2/src/control-panel.tsx new file mode 100644 index 00000000..a8d1e9fd --- /dev/null +++ b/examples/map-3d-2/src/control-panel.tsx @@ -0,0 +1,60 @@ +import {useMap3D} from '@vis.gl/react-google-maps'; +import * as React from 'react'; + +interface ControlPanelProps { + onFlyCameraAround: () => void; + onFlyCameraTo: () => void; + onStopAnimation: () => void; + isAnimating: boolean; +} + +function ControlPanel({ + onFlyCameraAround, + onFlyCameraTo, + onStopAnimation, + isAnimating +}: ControlPanelProps) { + return ( +
+

Map3D Component Example

+

+ This example demonstrates the new Map3D component with camera animations + using the Google Maps 3D API. +

+ +
+ + + +
+ + {isAnimating && ( +

+ Animation in progress... +

+ )} + + +
+ ); +} + +export default React.memo(ControlPanel); diff --git a/examples/map-3d-2/vite.config.js b/examples/map-3d-2/vite.config.js new file mode 100644 index 00000000..522c6cb9 --- /dev/null +++ b/examples/map-3d-2/vite.config.js @@ -0,0 +1,17 @@ +import {defineConfig, loadEnv} from 'vite'; + +export default defineConfig(({mode}) => { + const {GOOGLE_MAPS_API_KEY = ''} = loadEnv(mode, process.cwd(), ''); + + return { + define: { + 'process.env.GOOGLE_MAPS_API_KEY': JSON.stringify(GOOGLE_MAPS_API_KEY) + }, + resolve: { + alias: { + '@vis.gl/react-google-maps/examples.js': + 'https://visgl.github.io/react-google-maps/scripts/examples.js' + } + } + }; +}); diff --git a/package-lock.json b/package-lock.json index 2aa789b0..c9594284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "rollup": "^4.52.4", "rollup-plugin-dts": "^6.2.3", "ts-jest": "^29.0.5", + "tslib": "^2.8.1", "typescript": "^5.1.6", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" @@ -10142,8 +10143,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index cda431b3..6dc591fa 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "rollup": "^4.52.4", "rollup-plugin-dts": "^6.2.3", "ts-jest": "^29.0.5", + "tslib": "^2.8.1", "typescript": "^5.1.6", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/src/components/map-3d/index.tsx b/src/components/map-3d/index.tsx new file mode 100644 index 00000000..9a42634c --- /dev/null +++ b/src/components/map-3d/index.tsx @@ -0,0 +1,234 @@ +import React, { + CSSProperties, + forwardRef, + PropsWithChildren, + useContext, + useImperativeHandle, + useMemo +} from 'react'; + +import {APIProviderContext} from '../api-provider'; +import {useMap3DInstance} from './use-map-3d-instance'; +import {useMap3DCameraParams} from './use-map-3d-camera-params'; +import {Map3DEventProps, useMap3DEvents} from './use-map-3d-events'; +import {useMap3DOptions} from './use-map-3d-options'; + +/** + * Augment React's JSX namespace to include the gmp-map-3d custom element. + */ +declare module 'react' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + 'gmp-map-3d': React.DetailedHTMLProps< + React.HTMLAttributes, + google.maps.maps3d.Map3DElement + >; + } + } +} + +// Re-export event types for consumers +export type { + Map3DEvent, + Map3DCameraChangedEvent, + Map3DClickEvent, + Map3DSteadyChangeEvent, + Map3DEventProps +} from './use-map-3d-events'; + +/** + * Extended Map3DElement type with animation methods that may not be in @types/google.maps yet. + * These methods are part of the Maps JavaScript API but type definitions may lag behind. + */ +interface Map3DElementWithAnimations extends google.maps.maps3d.Map3DElement { + flyCameraAround(options: google.maps.maps3d.FlyAroundAnimationOptions): void; + flyCameraTo(options: google.maps.maps3d.FlyToAnimationOptions): void; + stopCameraAnimation(): void; +} + +/** + * Context value for Map3D, providing access to the Map3DElement instance. + */ +export interface GoogleMaps3DContextValue { + map3d: google.maps.maps3d.Map3DElement | null; +} + +/** + * React context for accessing the Map3D instance from child components. + */ +export const GoogleMaps3DContext = + React.createContext(null); + +/** + * Ref handle exposed by Map3D for imperative actions. + */ +export interface Map3DRef { + /** The underlying Map3DElement instance. */ + map3d: google.maps.maps3d.Map3DElement | null; + /** Fly the camera around a center point. */ + flyCameraAround: ( + options: google.maps.maps3d.FlyAroundAnimationOptions + ) => void; + /** Fly the camera to a destination. */ + flyCameraTo: (options: google.maps.maps3d.FlyToAnimationOptions) => void; + /** Stop any ongoing camera animation. */ + stopCameraAnimation: () => void; +} + +/** + * Props for the Map3D component. + */ +export type Map3DProps = PropsWithChildren< + Omit & + Map3DEventProps & { + /** + * An id for the map, this is required when multiple maps are present + * in the same APIProvider context. + */ + id?: string; + + /** + * Additional style rules to apply to the map container element. + */ + style?: CSSProperties; + + /** + * Additional CSS class name to apply to the map container element. + */ + className?: string; + + /** + * The center of the map. Can be a LatLngAltitude or LatLngAltitudeLiteral. + */ + center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + + // Default values for uncontrolled usage + defaultCenter?: google.maps.LatLngAltitudeLiteral; + defaultHeading?: number; + defaultTilt?: number; + defaultRange?: number; + defaultRoll?: number; + } +>; + +/** + * Default styles for the map container. + */ +const DEFAULT_CONTAINER_STYLE: CSSProperties = { + width: '100%', + height: '100%', + position: 'relative' +}; + +/** + * A React component that renders a 3D map using the Google Maps JavaScript API. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export const Map3D = forwardRef((props, ref) => { + const {children, id, className, style} = props; + + // Verify we're inside an APIProvider + const context = useContext(APIProviderContext); + if (!context) { + throw new Error( + ' can only be used inside an component.' + ); + } + + // Set up the map instance + const [map3d, containerRef, map3dRef, cameraStateRef, isReady] = + useMap3DInstance(props); + + // Set up camera params tracking and updates + useMap3DCameraParams(map3d, cameraStateRef, props); + + // Set up event handlers + useMap3DEvents(map3d, props); + + // Set up options updates + useMap3DOptions(map3d, props); + + // Expose imperative handle for animations + // Cast to extended type since @types/google.maps may not have animation methods yet + const map3dWithAnimations = map3d as Map3DElementWithAnimations | null; + useImperativeHandle( + ref, + () => ({ + map3d, + flyCameraAround: ( + options: google.maps.maps3d.FlyAroundAnimationOptions + ) => { + map3dWithAnimations?.flyCameraAround(options); + }, + flyCameraTo: (options: google.maps.maps3d.FlyToAnimationOptions) => { + map3dWithAnimations?.flyCameraTo(options); + }, + stopCameraAnimation: () => { + map3dWithAnimations?.stopCameraAnimation(); + } + }), + [map3d, map3dWithAnimations] + ); + + // Combine styles + const combinedStyle = useMemo( + () => ({ + ...DEFAULT_CONTAINER_STYLE, + ...style + }), + [style] + ); + + // Context value for child components + const contextValue = useMemo( + () => ({map3d}), + [map3d] + ); + + // Apply default mode if not specified + const mode = props.mode ?? google.maps?.maps3d?.MapMode?.HYBRID ?? 'HYBRID'; + + // Don't render the custom element until everything is ready + if (!isReady) { + return ( +
+ ); + } + + return ( +
+ + + {map3d && ( + + {children} + + )} +
+ ); +}); + +Map3D.displayName = 'Map3D'; diff --git a/src/components/map-3d/use-map-3d-camera-params.ts b/src/components/map-3d/use-map-3d-camera-params.ts new file mode 100644 index 00000000..ee367b06 --- /dev/null +++ b/src/components/map-3d/use-map-3d-camera-params.ts @@ -0,0 +1,95 @@ +import {useLayoutEffect} from 'react'; + +import {CameraStateRef3D} from './use-tracked-camera-state-ref-3d'; +import {Map3DProps} from './index'; + +/** + * Converts a LatLngAltitude or LatLngAltitudeLiteral to a literal object. + */ +function toLatLngAltitudeLiteral( + value: + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | undefined + | null +): google.maps.LatLngAltitudeLiteral | null { + if (!value) return null; + + // Check if it's a LatLngAltitude object with toJSON method + if ('toJSON' in value && typeof value.toJSON === 'function') { + return value.toJSON(); + } + + return value as google.maps.LatLngAltitudeLiteral; +} + +/** + * Hook to update Map3D camera parameters when props change. + * Compares the current camera state with props and updates only when there are differences. + * + * @internal + */ +export function useMap3DCameraParams( + map3d: google.maps.maps3d.Map3DElement | null, + cameraStateRef: CameraStateRef3D, + props: Map3DProps +) { + const centerLiteral = toLatLngAltitudeLiteral(props.center); + + const lat = centerLiteral?.lat ?? null; + const lng = centerLiteral?.lng ?? null; + const altitude = centerLiteral?.altitude ?? null; + + const range = props.range ?? null; + const heading = props.heading ?? null; + const tilt = props.tilt ?? null; + const roll = props.roll ?? null; + + // This effect runs on every render to check for camera differences + useLayoutEffect(() => { + if (!map3d) return; + + const currentState = cameraStateRef.current; + let needsUpdate = false; + + // Check center + if ( + lat !== null && + lng !== null && + (currentState.center.lat !== lat || + currentState.center.lng !== lng || + (altitude !== null && currentState.center.altitude !== altitude)) + ) { + map3d.center = { + lat, + lng, + altitude: altitude ?? currentState.center.altitude ?? 0 + }; + needsUpdate = true; + } + + // Check range + if (range !== null && currentState.range !== range) { + map3d.range = range; + needsUpdate = true; + } + + // Check heading + if (heading !== null && currentState.heading !== heading) { + map3d.heading = heading; + needsUpdate = true; + } + + // Check tilt + if (tilt !== null && currentState.tilt !== tilt) { + map3d.tilt = tilt; + needsUpdate = true; + } + + // Check roll + if (roll !== null && currentState.roll !== roll) { + map3d.roll = roll; + needsUpdate = true; + } + }); +} diff --git a/src/components/map-3d/use-map-3d-events.ts b/src/components/map-3d/use-map-3d-events.ts new file mode 100644 index 00000000..5e530f7e --- /dev/null +++ b/src/components/map-3d/use-map-3d-events.ts @@ -0,0 +1,288 @@ +import {useEffect} from 'react'; + +/** + * Base event type for all Map3D events. + */ +export interface Map3DEvent { + type: string; + map3d: google.maps.maps3d.Map3DElement; +} + +/** + * Event fired when a camera property changes. + */ +export interface Map3DCameraChangedEvent extends Map3DEvent { + detail: { + center: google.maps.LatLngAltitudeLiteral; + range: number; + heading: number; + tilt: number; + roll: number; + }; +} + +/** + * Event fired when the map is clicked. + */ +export interface Map3DClickEvent extends Map3DEvent { + detail: { + position: google.maps.LatLngAltitude | null; + placeId?: string; + }; +} + +/** + * Event fired when the map's steady state changes. + */ +export interface Map3DSteadyChangeEvent extends Map3DEvent { + detail: { + isSteady: boolean; + }; +} + +/** + * Props for Map3D event handlers. + */ +export interface Map3DEventProps { + /** Called when the center property changes. */ + onCenterChanged?: (event: Map3DCameraChangedEvent) => void; + /** Called when the heading property changes. */ + onHeadingChanged?: (event: Map3DCameraChangedEvent) => void; + /** Called when the tilt property changes. */ + onTiltChanged?: (event: Map3DCameraChangedEvent) => void; + /** Called when the range property changes. */ + onRangeChanged?: (event: Map3DCameraChangedEvent) => void; + /** Called when the roll property changes. */ + onRollChanged?: (event: Map3DCameraChangedEvent) => void; + /** Called when any camera property changes (aggregated). */ + onCameraChanged?: (event: Map3DCameraChangedEvent) => void; + /** Called when the map is clicked. */ + onClick?: (event: Map3DClickEvent) => void; + /** Called when the map's steady state changes. */ + onSteadyChange?: (event: Map3DSteadyChangeEvent) => void; + /** Called when a fly animation ends. */ + onAnimationEnd?: (event: Map3DEvent) => void; + /** Called when a map error occurs. */ + onError?: (event: Map3DEvent) => void; +} + +/** + * Mapping from prop names to DOM event names. + */ +const EVENT_MAP: Record = { + onCenterChanged: 'gmp-centerchange', + onHeadingChanged: 'gmp-headingchange', + onTiltChanged: 'gmp-tiltchange', + onRangeChanged: 'gmp-rangechange', + onRollChanged: 'gmp-rollchange', + onCameraChanged: '', // Special case: synthetic event + onClick: 'gmp-click', + onSteadyChange: 'gmp-steadychange', + onAnimationEnd: 'gmp-animationend', + onError: 'gmp-error' +}; + +/** + * Camera-related event types for the aggregated onCameraChanged handler. + */ +const CAMERA_EVENTS = [ + 'gmp-centerchange', + 'gmp-headingchange', + 'gmp-tiltchange', + 'gmp-rangechange', + 'gmp-rollchange' +]; + +/** + * Creates a camera changed event with current camera state. + */ +function createCameraEvent( + map3d: google.maps.maps3d.Map3DElement, + type: string +): Map3DCameraChangedEvent { + const center = map3d.center; + + // Normalize center to LatLngAltitudeLiteral + // If center is a LatLngAltitude class instance, it has a toJSON method + // Otherwise it's already a literal object + let centerLiteral: google.maps.LatLngAltitudeLiteral; + if (center && 'toJSON' in center && typeof center.toJSON === 'function') { + centerLiteral = (center as google.maps.LatLngAltitude).toJSON(); + } else if (center) { + centerLiteral = center as google.maps.LatLngAltitudeLiteral; + } else { + centerLiteral = {lat: 0, lng: 0, altitude: 0}; + } + + return { + type, + map3d, + detail: { + center: centerLiteral, + range: map3d.range || 0, + heading: map3d.heading || 0, + tilt: map3d.tilt || 0, + roll: map3d.roll || 0 + } + }; +} + +/** + * Creates a click event from a LocationClickEvent or PlaceClickEvent. + */ +function createClickEvent( + map3d: google.maps.maps3d.Map3DElement, + srcEvent: + | google.maps.maps3d.LocationClickEvent + | google.maps.maps3d.PlaceClickEvent +): Map3DClickEvent { + const placeClickEvent = srcEvent as google.maps.maps3d.PlaceClickEvent; + + return { + type: 'gmp-click', + map3d, + detail: { + position: srcEvent.position || null, + placeId: placeClickEvent.placeId + } + }; +} + +/** + * Creates a steady change event. + */ +function createSteadyChangeEvent( + map3d: google.maps.maps3d.Map3DElement, + srcEvent: google.maps.maps3d.SteadyChangeEvent +): Map3DSteadyChangeEvent { + return { + type: 'gmp-steadychange', + map3d, + detail: { + isSteady: srcEvent.isSteady + } + }; +} + +/** + * Hook to set up event handlers for Map3D events. + * + * @internal + */ +export function useMap3DEvents( + map3d: google.maps.maps3d.Map3DElement | null, + props: Map3DEventProps +) { + const { + onCenterChanged, + onHeadingChanged, + onTiltChanged, + onRangeChanged, + onRollChanged, + onCameraChanged, + onClick, + onSteadyChange, + onAnimationEnd, + onError + } = props; + + // Individual camera events + useMap3DEvent(map3d, 'gmp-centerchange', onCenterChanged, createCameraEvent); + useMap3DEvent( + map3d, + 'gmp-headingchange', + onHeadingChanged, + createCameraEvent + ); + useMap3DEvent(map3d, 'gmp-tiltchange', onTiltChanged, createCameraEvent); + useMap3DEvent(map3d, 'gmp-rangechange', onRangeChanged, createCameraEvent); + useMap3DEvent(map3d, 'gmp-rollchange', onRollChanged, createCameraEvent); + + // Aggregated camera changed event + useEffect(() => { + if (!map3d || !onCameraChanged) return; + + const handler = () => { + onCameraChanged(createCameraEvent(map3d, 'camerachange')); + }; + + for (const eventName of CAMERA_EVENTS) { + map3d.addEventListener(eventName, handler); + } + + return () => { + for (const eventName of CAMERA_EVENTS) { + map3d.removeEventListener(eventName, handler); + } + }; + }, [map3d, onCameraChanged]); + + // Click event + useEffect(() => { + if (!map3d || !onClick) return; + + const handler = (ev: Event) => { + onClick( + createClickEvent( + map3d, + ev as + | google.maps.maps3d.LocationClickEvent + | google.maps.maps3d.PlaceClickEvent + ) + ); + }; + + map3d.addEventListener('gmp-click', handler); + return () => map3d.removeEventListener('gmp-click', handler); + }, [map3d, onClick]); + + // Steady change event + useEffect(() => { + if (!map3d || !onSteadyChange) return; + + const handler = (ev: Event) => { + onSteadyChange( + createSteadyChangeEvent( + map3d, + ev as google.maps.maps3d.SteadyChangeEvent + ) + ); + }; + + map3d.addEventListener('gmp-steadychange', handler); + return () => map3d.removeEventListener('gmp-steadychange', handler); + }, [map3d, onSteadyChange]); + + // Animation end event + useMap3DEvent(map3d, 'gmp-animationend', onAnimationEnd, (map3d, type) => ({ + type, + map3d + })); + + // Error event + useMap3DEvent(map3d, 'gmp-error', onError, (map3d, type) => ({ + type, + map3d + })); +} + +/** + * Helper hook for individual events. + */ +function useMap3DEvent( + map3d: google.maps.maps3d.Map3DElement | null, + eventName: string, + handler: ((event: T) => void) | undefined, + createEvent: (map3d: google.maps.maps3d.Map3DElement, type: string) => T +) { + useEffect(() => { + if (!map3d || !handler) return; + + const listener = () => { + handler(createEvent(map3d, eventName)); + }; + + map3d.addEventListener(eventName, listener); + return () => map3d.removeEventListener(eventName, listener); + }, [map3d, eventName, handler, createEvent]); +} diff --git a/src/components/map-3d/use-map-3d-instance.ts b/src/components/map-3d/use-map-3d-instance.ts new file mode 100644 index 00000000..25ce8d2b --- /dev/null +++ b/src/components/map-3d/use-map-3d-instance.ts @@ -0,0 +1,115 @@ +import {Ref, useEffect, useState} from 'react'; + +import {useCallbackRef} from '../../hooks/use-callback-ref'; +import {useMapsLibrary} from '../../hooks/use-maps-library'; +import { + CameraStateRef3D, + useTrackedCameraStateRef3D +} from './use-tracked-camera-state-ref-3d'; +import {Map3DProps} from './index'; + +/** + * Hook to manage the Map3DElement instance lifecycle. + * + * Handles: + * - Waiting for the 'maps3d' library to load + * - Waiting for the 'gmp-map-3d' custom element to be defined + * - Creating a callback ref for the element + * - Applying initial options when the element is ready + * - Tracking camera state + * + * @internal + */ +export function useMap3DInstance( + props: Map3DProps +): readonly [ + map3d: google.maps.maps3d.Map3DElement | null, + containerRef: Ref, + map3dRef: Ref, + cameraStateRef: CameraStateRef3D, + isReady: boolean +] { + // Load the maps3d library + const maps3dLib = useMapsLibrary('maps3d'); + + // Track if the custom element is defined + const [customElementReady, setCustomElementReady] = useState(false); + + // Container ref for the wrapper div + const [container, containerRef] = useCallbackRef(); + + // Ref for the gmp-map-3d element + const [map3d, map3dRef] = useCallbackRef(); + + // Track camera state + const cameraStateRef = useTrackedCameraStateRef3D(map3d); + + // Wait for custom element definition + useEffect(() => { + customElements.whenDefined('gmp-map-3d').then(() => { + setCustomElementReady(true); + }); + }, []); + + // Apply initial options when map3d element is ready + useEffect(() => { + if (!map3d) return; + + const { + // Extract camera props + center, + heading, + tilt, + range, + roll, + // Extract default* props (not applied to element) + defaultCenter, + defaultHeading, + defaultTilt, + defaultRange, + defaultRoll, + // Extract non-element props + id, + style, + className, + // Extract event props + onCenterChanged, + onHeadingChanged, + onTiltChanged, + onRangeChanged, + onRollChanged, + onCameraChanged, + onClick, + onSteadyChange, + onAnimationEnd, + onError, + // Remaining are element options + ...elementOptions + } = props; + + // Apply initial camera state from props or defaults + const initialCenter = center ?? defaultCenter; + const initialHeading = heading ?? defaultHeading; + const initialTilt = tilt ?? defaultTilt; + const initialRange = range ?? defaultRange; + const initialRoll = roll ?? defaultRoll; + + // Build initial options object + const initialOptions: Partial = { + ...elementOptions + }; + + if (initialCenter) initialOptions.center = initialCenter; + if (initialHeading !== undefined) initialOptions.heading = initialHeading; + if (initialTilt !== undefined) initialOptions.tilt = initialTilt; + if (initialRange !== undefined) initialOptions.range = initialRange; + if (initialRoll !== undefined) initialOptions.roll = initialRoll; + + // Apply all initial options to the element + Object.assign(map3d, initialOptions); + }, [map3d]); // Only run when map3d element first becomes available + + const isReady = !!maps3dLib && customElementReady; + + return [map3d, containerRef, map3dRef, cameraStateRef, isReady] as const; +} diff --git a/src/components/map-3d/use-map-3d-options.ts b/src/components/map-3d/use-map-3d-options.ts new file mode 100644 index 00000000..90b991f2 --- /dev/null +++ b/src/components/map-3d/use-map-3d-options.ts @@ -0,0 +1,50 @@ +import {useDeepCompareEffect} from '../../hooks/use-deep-compare-effect'; +import {Map3DProps} from './index'; + +/** + * Set of option keys that can be updated on Map3DElement. + * Camera props (center, heading, tilt, range, roll) are handled separately. + */ +const MAP_3D_OPTION_KEYS = new Set< + keyof google.maps.maps3d.Map3DElementOptions +>([ + 'bounds', + 'defaultUIHidden', + 'internalUsageAttributionIds', + 'maxAltitude', + 'maxHeading', + 'maxTilt', + 'minAltitude', + 'minHeading', + 'minTilt', + 'mode' +]); + +/** + * Hook to update Map3D options when props change. + * + * @internal + */ +export function useMap3DOptions( + map3d: google.maps.maps3d.Map3DElement | null, + props: Map3DProps +) { + // Filter props to only include valid option keys + const options: Partial = {}; + const keys = Object.keys( + props + ) as (keyof google.maps.maps3d.Map3DElementOptions)[]; + + for (const key of keys) { + if (!MAP_3D_OPTION_KEYS.has(key)) continue; + if (props[key] === undefined) continue; + + options[key] = props[key] as never; + } + + useDeepCompareEffect(() => { + if (!map3d) return; + + Object.assign(map3d, options); + }, [map3d, options]); +} diff --git a/src/components/map-3d/use-tracked-camera-state-ref-3d.ts b/src/components/map-3d/use-tracked-camera-state-ref-3d.ts new file mode 100644 index 00000000..051de524 --- /dev/null +++ b/src/components/map-3d/use-tracked-camera-state-ref-3d.ts @@ -0,0 +1,94 @@ +import {MutableRefObject, RefObject, useEffect, useRef} from 'react'; + +import {useForceUpdate} from '../../hooks/use-force-update'; + +/** + * Represents the 3D camera state with all position and orientation parameters. + */ +export type CameraState3D = { + center: google.maps.LatLngAltitudeLiteral; + range: number; + heading: number; + tilt: number; + roll: number; +}; + +export type CameraStateRef3D = RefObject; + +const DEFAULT_CAMERA_STATE: CameraState3D = { + center: {lat: 0, lng: 0, altitude: 0}, + range: 0, + heading: 0, + tilt: 0, + roll: 0 +}; + +/** + * Camera property names that correspond to gmp-*change events. + */ +const CAMERA_PROPS = ['center', 'range', 'heading', 'tilt', 'roll'] as const; +type CameraProp = (typeof CAMERA_PROPS)[number]; + +/** + * Updates the camera state ref with values from the map element. + */ +function updateCameraState( + map3d: google.maps.maps3d.Map3DElement, + ref: CameraStateRef3D, + prop: CameraProp +) { + const value = map3d[prop]; + + if (value == null) return; + + if (prop === 'center') { + // The center property returns a LatLngAltitude object, convert to literal + const center = value as google.maps.LatLngAltitude; + ref.current.center = center.toJSON + ? center.toJSON() + : (center as google.maps.LatLngAltitudeLiteral); + } else { + ref.current[prop] = value as number; + } +} + +/** + * Creates a mutable ref object to track the last known state of the 3D map camera. + * This is used in `useMap3DCameraParams` to reduce stuttering by avoiding updates + * of the map camera with values that have already been processed. + * + * @internal + */ +export function useTrackedCameraStateRef3D( + map3d: google.maps.maps3d.Map3DElement | null +): CameraStateRef3D { + const forceUpdate = useForceUpdate(); + const ref = useRef({...DEFAULT_CAMERA_STATE}); + + useEffect(() => { + if (!map3d) return; + + const listeners: (() => void)[] = []; + + for (const prop of CAMERA_PROPS) { + const eventName = `gmp-${prop}change`; + + const handler = () => { + updateCameraState(map3d, ref, prop); + // Force update to allow controlled component pattern to work + forceUpdate(); + }; + + map3d.addEventListener(eventName, handler); + listeners.push(() => map3d.removeEventListener(eventName, handler)); + } + + return () => { + for (const removeListener of listeners) { + removeListener(); + } + }; + }, [map3d, forceUpdate]); + + return ref; +} diff --git a/src/hooks/use-map-3d.ts b/src/hooks/use-map-3d.ts new file mode 100644 index 00000000..ca78d6e0 --- /dev/null +++ b/src/hooks/use-map-3d.ts @@ -0,0 +1,57 @@ +import {useContext} from 'react'; + +import {APIProviderContext} from '../components/api-provider'; +import {GoogleMaps3DContext} from '../components/map-3d'; +import {logErrorOnce} from '../libraries/errors'; + +/** + * Hook to retrieve the Map3DElement instance from context. + * + * Must be used within a Map3D component. + * + * @example + * ```tsx + * function MyComponent() { + * const map3d = useMap3D(); + * + * const handleClick = () => { + * map3d?.flyCameraTo({ + * endCamera: { + * center: { lat: 37.7749, lng: -122.4194, altitude: 1000 }, + * range: 5000 + * }, + * durationMillis: 2000 + * }); + * }; + * + * return ; + * } + * ``` + */ +export function useMap3D(): google.maps.maps3d.Map3DElement | null { + const apiContext = useContext(APIProviderContext); + const map3dContext = useContext(GoogleMaps3DContext); + + if (apiContext === null) { + logErrorOnce( + 'useMap3D(): failed to retrieve APIProviderContext. ' + + 'Make sure that the component exists and that the ' + + 'component you are calling `useMap3D()` from is a child of the ' + + '.' + ); + + return null; + } + + if (map3dContext === null) { + logErrorOnce( + 'useMap3D(): failed to retrieve GoogleMaps3DContext. ' + + 'Make sure that the component you are calling `useMap3D()` from is ' + + 'a child of the component.' + ); + + return null; + } + + return map3dContext.map3d; +} diff --git a/src/hooks/use-maps-library.ts b/src/hooks/use-maps-library.ts index db46e005..734b63d1 100644 --- a/src/hooks/use-maps-library.ts +++ b/src/hooks/use-maps-library.ts @@ -16,6 +16,7 @@ interface ApiLibraries { journeySharing: google.maps.JourneySharingLibrary; drawing: google.maps.DrawingLibrary; visualization: google.maps.VisualizationLibrary; + maps3d: google.maps.Maps3DLibrary; } export function useMapsLibrary< diff --git a/src/index.ts b/src/index.ts index 082e7c3c..3c71eeda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from './components/advanced-marker'; export * from './components/api-provider'; export * from './components/info-window'; export * from './components/map'; +export * from './components/map-3d'; export * from './components/static-map'; export * from './components/map-control'; export * from './components/marker'; @@ -12,6 +13,7 @@ export * from './hooks/use-api-loading-status'; export * from './hooks/use-api-is-loaded'; export * from './hooks/use-maps-library'; export * from './hooks/use-map'; +export * from './hooks/use-map-3d'; export * from './libraries/lat-lng-utils'; export * from './libraries/api-loading-status'; export {limitTiltRange} from './libraries/limit-tilt-range'; diff --git a/tsconfig.build.json b/tsconfig.build.json index b59e753f..b5394e66 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,5 +1,6 @@ { "extends": "./tsconfig.json", + "include": ["./src/**/*", "./types/**/*"], "exclude": ["./src/**/__*__", "./examples"], "compilerOptions": { "noEmit": false diff --git a/tsconfig.json b/tsconfig.json index f615279a..29126d2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,6 @@ "@vis.gl/react-google-maps": ["./src"] } }, - "include": ["./src/**/*"], + "include": ["./src/**/*", "./examples/**/*", "./types/**/*"], "exclude": ["./dist", "./node_modules"] } diff --git a/types/google.maps.d.ts b/types/google.maps.d.ts index 059128e5..af44cf0d 100644 --- a/types/google.maps.d.ts +++ b/types/google.maps.d.ts @@ -38,4 +38,856 @@ declare namespace google.maps { anchorTop?: string; } } -} \ No newline at end of file + + /** + * Namespace for 3D Maps functionality. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map + */ + namespace maps3d { + /** + * Map3DElement is an HTML interface for the 3D Map view. + * Note that the mode must be set for the 3D Map to start rendering. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Map3DElement + */ + class Map3DElement extends HTMLElement implements Map3DElementOptions { + constructor(options?: Map3DElementOptions); + + /** + * When set, restricts the position of the camera within the specified lat/lng bounds. + */ + bounds?: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral; + + /** + * The center of the map given as a LatLngAltitude, where altitude is in meters above ground level. + */ + center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + + /** + * When true, all default UI buttons are hidden. + * @default false + */ + defaultUIHidden?: boolean; + + /** + * The compass heading of the map, in degrees, where due north is zero. + */ + heading?: number; + + /** + * Attribution IDs for internal usage tracking. + */ + internalUsageAttributionIds?: Iterable; + + /** + * The maximum altitude above the ground which will be displayed on the map. + * A valid value is between 0 and 63170000 meters (Earth radius multiplied by 10). + */ + maxAltitude?: number; + + /** + * The maximum angle of heading (rotation) of the map. + * A valid value is between 0 and 360 degrees. + */ + maxHeading?: number; + + /** + * The maximum angle of incidence of the map. + * A valid value is between 0 and 90 degrees. + */ + maxTilt?: number; + + /** + * The minimum altitude above the ground which will be displayed on the map. + * A valid value is between 0 and 63170000 meters (Earth radius multiplied by 10). + */ + minAltitude?: number; + + /** + * The minimum angle of heading (rotation) of the map. + * A valid value is between 0 and 360 degrees. + */ + minHeading?: number; + + /** + * The minimum angle of incidence of the map. + * A valid value is between 0 and 90 degrees. + */ + minTilt?: number; + + /** + * Specifies a mode the map should be rendered in. + * If not set, the map won't be rendered. + */ + mode?: MapMode; + + /** + * The distance from camera to the center of the map, in meters. + */ + range?: number; + + /** + * The roll of the camera around the view vector in degrees. + */ + roll?: number; + + /** + * The tilt of the camera's view vector in degrees. + */ + tilt?: number; + + /** + * @deprecated Please use Map3DElement.defaultUIHidden instead. + * When true, all default UI buttons are disabled. + * @default false + */ + defaultUIDisabled?: boolean; + + /** + * This method orbits the camera around a given location for a given duration. + */ + flyCameraAround(options: FlyAroundAnimationOptions): void; + + /** + * This method moves the camera parabolically from the current location to a given end location. + */ + flyCameraTo(options: FlyToAnimationOptions): void; + + /** + * This method stops any fly animation that might happen to be running. + */ + stopCameraAnimation(): void; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Map3DElementOptions object used to define the properties that can be set on a Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Map3DElementOptions + */ + interface Map3DElementOptions { + bounds?: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral; + center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + /** @deprecated Please use defaultUIHidden instead. */ + defaultUIDisabled?: boolean; + defaultUIHidden?: boolean; + heading?: number; + internalUsageAttributionIds?: Iterable; + maxAltitude?: number; + maxHeading?: number; + maxTilt?: number; + minAltitude?: number; + minHeading?: number; + minTilt?: number; + mode?: MapMode; + range?: number; + roll?: number; + tilt?: number; + } + + /** + * Specifies a mode the map should be rendered in. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#MapMode + */ + enum MapMode { + /** This map mode displays a transparent layer of major streets on satellite imagery. */ + HYBRID = 'HYBRID', + /** This map mode displays satellite or photorealistic imagery. */ + SATELLITE = 'SATELLITE' + } + + /** + * Customization options for the FlyCameraAround Animation. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#FlyAroundAnimationOptions + */ + interface FlyAroundAnimationOptions { + /** + * The central point at which the camera should look at during the orbit animation. + */ + camera: CameraOptions; + /** + * The duration of one animation cycle in milliseconds. + */ + durationMillis?: number; + /** + * Specifies the number of times an animation should repeat. + * If the number is zero, the animation will complete immediately after it starts. + */ + repeatCount?: number; + /** + * @deprecated Please use repeatCount instead. + */ + rounds?: number; + } + + /** + * Customization options for the FlyCameraTo Animation. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#FlyToAnimationOptions + */ + interface FlyToAnimationOptions { + /** + * The location at which the camera should point at the end of the animation. + */ + endCamera: CameraOptions; + /** + * The duration of the animation in milliseconds. + * A duration of 0 will teleport the camera straight to the end position. + */ + durationMillis?: number; + } + + /** + * CameraOptions object used to define the properties that can be set on a camera object. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#CameraOptions + */ + interface CameraOptions { + center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + heading?: number; + range?: number; + roll?: number; + tilt?: number; + } + + /** + * This event is created from monitoring a steady state of Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#SteadyChangeEvent + */ + class SteadyChangeEvent extends Event { + /** + * Indicates whether Map3DElement is steady (i.e. all rendering for the current scene has completed) or not. + */ + isSteady: boolean; + } + + /** + * This event is created from clicking a Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#LocationClickEvent + */ + class LocationClickEvent extends Event { + /** + * The latitude/longitude/altitude that was below the cursor when the event occurred. + */ + position?: google.maps.LatLngAltitude; + } + + /** + * This event is created from clicking on a place icon on a Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#PlaceClickEvent + */ + class PlaceClickEvent extends LocationClickEvent { + /** + * The place id of the map feature. + */ + placeId: string; + + /** + * Fetches a Place for this place id. + */ + fetchPlace(): Promise; + } + + /** + * Shows a position on a 3D map. + * Note that the position must be set for the Marker3DElement to display. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DElement + */ + class Marker3DElement + extends HTMLElement + implements Marker3DElementOptions + { + constructor(options?: Marker3DElementOptions); + + /** + * Specifies how the altitude component of the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * An enumeration specifying how a Marker3DElement should behave when it collides with another Marker3DElement or with the basemap labels. + * @default CollisionBehavior.REQUIRED + */ + collisionBehavior?: google.maps.marker.CollisionBehavior; + + /** + * Specifies whether this marker should be drawn or not when it's occluded. + * @default false + */ + drawsWhenOccluded?: boolean; + + /** + * Specifies whether to connect the marker to the ground. + * @default false + */ + extruded?: boolean; + + /** + * Text to be displayed by this marker. + */ + label?: string; + + /** + * The location of the tip of the marker. + */ + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + + /** + * Specifies whether this marker should preserve its size or not regardless of distance from camera. + * @default false + */ + sizePreserved?: boolean; + + /** + * The zIndex compared to other markers. + */ + zIndex?: number; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Marker3DElementOptions object used to define the properties that can be set on a Marker3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DElementOptions + */ + interface Marker3DElementOptions { + altitudeMode?: AltitudeMode; + collisionBehavior?: google.maps.marker.CollisionBehavior; + drawsWhenOccluded?: boolean; + extruded?: boolean; + label?: string; + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + sizePreserved?: boolean; + zIndex?: number; + } + + /** + * Shows a position on a 3D map with interactive capabilities. + * Unlike Marker3DElement, Marker3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DInteractiveElement + */ + class Marker3DInteractiveElement + extends Marker3DElement + implements Marker3DInteractiveElementOptions + { + constructor(options?: Marker3DInteractiveElementOptions); + + /** + * When set, the popover element will be open on this marker's click. + */ + gmpPopoverTargetElement?: PopoverElement; + + /** + * Rollover text. + */ + title: string; + } + + /** + * Marker3DInteractiveElementOptions object used to define the properties that can be set on a Marker3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DInteractiveElementOptions + */ + interface Marker3DInteractiveElementOptions extends Marker3DElementOptions { + gmpPopoverTargetElement?: PopoverElement; + title?: string; + } + + /** + * A 3D model which allows the rendering of gLTF models. + * Note that the position and the src must be set for the Model3DElement to display. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DElement + */ + class Model3DElement extends HTMLElement implements Model3DElementOptions { + constructor(options?: Model3DElementOptions); + + /** + * Specifies how altitude in the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * Describes rotation of a 3D model's coordinate system to position the model on the 3D Map. + */ + orientation?: + | google.maps.Orientation3D + | google.maps.Orientation3DLiteral; + + /** + * Sets the Model3DElement's position. + */ + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + + /** + * Scales the model along the x, y, and z axes in the model's coordinate space. + * @default 1 + */ + scale?: number | google.maps.Vector3D | google.maps.Vector3DLiteral; + + /** + * Specifies the url of the 3D model. At this time, only models in the .glb format are supported. + */ + src?: string | URL; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Model3DElementOptions object used to define the properties that can be set on a Model3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DElementOptions + */ + interface Model3DElementOptions { + altitudeMode?: AltitudeMode; + orientation?: + | google.maps.Orientation3D + | google.maps.Orientation3DLiteral; + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + scale?: number | google.maps.Vector3D | google.maps.Vector3DLiteral; + src?: string | URL; + } + + /** + * A 3D model with interactive capabilities. + * Unlike Model3DElement, Model3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DInteractiveElement + */ + class Model3DInteractiveElement + extends Model3DElement + implements Model3DInteractiveElementOptions + { + constructor(options?: Model3DElementOptions); + } + + /** + * Model3DInteractiveElementOptions object used to define the properties that can be set on a Model3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DInteractiveElementOptions + */ + interface Model3DInteractiveElementOptions extends Model3DElementOptions {} + + /** + * A 3D polyline is a linear overlay of connected line segments on a 3D map. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DElement + */ + class Polyline3DElement + extends HTMLElement + implements Polyline3DElementOptions + { + constructor(options?: Polyline3DElementOptions); + + /** + * Specifies how altitude components in the coordinates are interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * Specifies whether parts of the polyline which could be occluded are drawn or not. + * @default false + */ + drawsOccludedSegments?: boolean; + + /** + * Specifies whether to connect the polyline to the ground. + * @default false + */ + extruded?: boolean; + + /** + * When true, edges of the polyline are interpreted as geodesic. + * @default false + */ + geodesic?: boolean; + + /** + * The outer color. All CSS3 colors are supported. + */ + outerColor?: string; + + /** + * The outer width is between 0.0 and 1.0. This is a percentage of the strokeWidth. + */ + outerWidth?: number; + + /** + * The ordered sequence of coordinates of the Polyline. + */ + path?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + /** + * The stroke color. All CSS3 colors are supported. + */ + strokeColor?: string; + + /** + * The stroke width in pixels. + */ + strokeWidth?: number; + + /** + * The zIndex compared to other polys. + */ + zIndex?: number; + + /** + * @deprecated Use path instead. + */ + coordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Polyline3DElementOptions object used to define the properties that can be set on a Polyline3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DElementOptions + */ + interface Polyline3DElementOptions { + altitudeMode?: AltitudeMode; + /** @deprecated Use path instead. */ + coordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + drawsOccludedSegments?: boolean; + extruded?: boolean; + geodesic?: boolean; + outerColor?: string; + outerWidth?: number; + strokeColor?: string; + strokeWidth?: number; + zIndex?: number; + } + + /** + * A 3D polyline with interactive capabilities. + * Unlike Polyline3DElement, Polyline3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DInteractiveElement + */ + class Polyline3DInteractiveElement + extends Polyline3DElement + implements Polyline3DInteractiveElementOptions + { + constructor(options?: Polyline3DElementOptions); + } + + /** + * Polyline3DInteractiveElementOptions object used to define the properties that can be set on a Polyline3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DInteractiveElementOptions + */ + interface Polyline3DInteractiveElementOptions + extends Polyline3DElementOptions {} + + /** + * A 3D polygon defines a series of connected coordinates in an ordered sequence. + * Polygons form a closed loop and define a filled region. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DElement + */ + class Polygon3DElement + extends HTMLElement + implements Polygon3DElementOptions + { + constructor(options?: Polygon3DElementOptions); + + /** + * Specifies how altitude components in the coordinates are interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * Specifies whether parts of the polygon which could be occluded are drawn or not. + * @default false + */ + drawsOccludedSegments?: boolean; + + /** + * Specifies whether to connect the polygon to the ground. + * @default false + */ + extruded?: boolean; + + /** + * The fill color. All CSS3 colors are supported. + */ + fillColor?: string; + + /** + * When true, edges of the polygon are interpreted as geodesic. + * @default false + */ + geodesic?: boolean; + + /** + * The ordered sequence of coordinates that designates a closed loop. + */ + innerPaths?: Iterable< + Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + > + >; + + /** + * The ordered sequence of coordinates that designates a closed loop. + */ + path?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + /** + * The stroke color. All CSS3 colors are supported. + */ + strokeColor?: string; + + /** + * The stroke width in pixels. + */ + strokeWidth?: number; + + /** + * The zIndex compared to other polys. + */ + zIndex?: number; + + /** + * @deprecated Use path instead. + */ + outerCoordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + /** + * @deprecated Use innerPaths instead. + */ + innerCoordinates?: Iterable< + Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + > + >; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Polygon3DElementOptions object used to define the properties that can be set on a Polygon3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DElementOptions + */ + interface Polygon3DElementOptions { + altitudeMode?: AltitudeMode; + drawsOccludedSegments?: boolean; + extruded?: boolean; + fillColor?: string; + geodesic?: boolean; + /** @deprecated Use innerPaths instead. */ + innerCoordinates?: Iterable< + Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + > + >; + /** @deprecated Use path instead. */ + outerCoordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + strokeColor?: string; + strokeWidth?: number; + zIndex?: number; + } + + /** + * A 3D polygon with interactive capabilities. + * Unlike Polygon3DElement, Polygon3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DInteractiveElement + */ + class Polygon3DInteractiveElement + extends Polygon3DElement + implements Polygon3DInteractiveElementOptions + { + constructor(options?: Polygon3DElementOptions); + } + + /** + * Polygon3DInteractiveElementOptions object used to define the properties that can be set on a Polygon3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DInteractiveElementOptions + */ + interface Polygon3DInteractiveElementOptions + extends Polygon3DElementOptions {} + + /** + * A custom HTML element that renders a popover. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#PopoverElement + */ + class PopoverElement extends HTMLElement implements PopoverElementOptions { + constructor(options?: PopoverElementOptions); + + /** + * Specifies how the altitude component of the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * Specifies whether this popover should be "light dismissed" or not. + * @default false + */ + lightDismissDisabled?: boolean; + + /** + * Specifies whether this popover should be open or not. + * @default false + */ + open?: boolean; + + /** + * The position at which to display this popover. + */ + positionAnchor?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitudeLiteral + | Marker3DInteractiveElement + | string; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * PopoverElementOptions object used to define the properties that can be set on a PopoverElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#PopoverElementOptions + */ + interface PopoverElementOptions { + altitudeMode?: AltitudeMode; + lightDismissDisabled?: boolean; + open?: boolean; + positionAnchor?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitudeLiteral + | string + | Marker3DInteractiveElement; + } + + /** + * Specifies how altitude components in the coordinates are interpreted. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#AltitudeMode + */ + enum AltitudeMode { + /** + * Allows to express objects relative to the average mean sea level. + */ + ABSOLUTE = 'ABSOLUTE', + /** + * Allows to express objects placed on the ground. + */ + CLAMP_TO_GROUND = 'CLAMP_TO_GROUND', + /** + * Allows to express objects relative to the ground surface. + */ + RELATIVE_TO_GROUND = 'RELATIVE_TO_GROUND', + /** + * Allows to express objects relative to the highest of ground+building+water surface. + */ + RELATIVE_TO_MESH = 'RELATIVE_TO_MESH' + } + } + + /** + * Maps3D Library interface for use with importLibrary('maps3d'). + */ + interface Maps3DLibrary { + Map3DElement: typeof maps3d.Map3DElement; + Marker3DElement: typeof maps3d.Marker3DElement; + Marker3DInteractiveElement: typeof maps3d.Marker3DInteractiveElement; + Model3DElement: typeof maps3d.Model3DElement; + Polyline3DElement: typeof maps3d.Polyline3DElement; + Polygon3DElement: typeof maps3d.Polygon3DElement; + AltitudeMode: typeof maps3d.AltitudeMode; + MapMode: typeof maps3d.MapMode; + } +} From 93d432ad2d9b2ccd4fd1ee97a4fb43e611a3d477 Mon Sep 17 00:00:00 2001 From: Malte Modrow Date: Fri, 5 Dec 2025 08:53:03 +0100 Subject: [PATCH 07/21] feat: add mapmode and gesture handling --- src/components/map-3d/index.tsx | 53 ++++++++++++++++++- .../map-3d/use-tracked-camera-state-ref-3d.ts | 2 +- types/google.maps.d.ts | 32 +++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/components/map-3d/index.tsx b/src/components/map-3d/index.tsx index 9a42634c..ab8c6b40 100644 --- a/src/components/map-3d/index.tsx +++ b/src/components/map-3d/index.tsx @@ -17,7 +17,6 @@ import {useMap3DOptions} from './use-map-3d-options'; * Augment React's JSX namespace to include the gmp-map-3d custom element. */ declare module 'react' { - // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { 'gmp-map-3d': React.DetailedHTMLProps< @@ -37,6 +36,41 @@ export type { Map3DEventProps } from './use-map-3d-events'; +/** + * MapMode for specifying how the 3D map should be rendered. + * This mirrors google.maps.maps3d.MapMode but is available without waiting for the API to load. + */ +export const MapMode = { + /** This map mode displays a transparent layer of major streets on satellite imagery. */ + HYBRID: 'HYBRID', + /** This map mode displays satellite or photorealistic imagery. */ + SATELLITE: 'SATELLITE' +} as const; +export type MapMode = (typeof MapMode)[keyof typeof MapMode]; + +/** + * GestureHandling for specifying how gesture events should be handled on the map. + * This mirrors google.maps.maps3d.GestureHandling but is available without waiting for the API to load. + */ +export const GestureHandling = { + /** + * This lets the map choose whether to use cooperative or greedy gesture handling. + * This is the default behavior if not specified. + */ + AUTO: 'AUTO', + /** + * This forces cooperative mode, where modifier keys or two-finger gestures + * are required to scroll the map. + */ + COOPERATIVE: 'COOPERATIVE', + /** + * This forces greedy mode, where the host page cannot be scrolled from user + * events on the map element. + */ + GREEDY: 'GREEDY' +} as const; +export type GestureHandling = (typeof GestureHandling)[keyof typeof GestureHandling]; + /** * Extended Map3DElement type with animation methods that may not be in @types/google.maps yet. * These methods are part of the Maps JavaScript API but type definitions may lag behind. @@ -80,7 +114,10 @@ export interface Map3DRef { * Props for the Map3D component. */ export type Map3DProps = PropsWithChildren< - Omit & + Omit< + google.maps.maps3d.Map3DElementOptions, + 'center' | 'mode' | 'gestureHandling' + > & Map3DEventProps & { /** * An id for the map, this is required when multiple maps are present @@ -103,6 +140,18 @@ export type Map3DProps = PropsWithChildren< */ center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + /** + * Specifies a mode the map should be rendered in. + * Import MapMode from '@vis.gl/react-google-maps' to use this. + */ + mode?: MapMode; + + /** + * Specifies how gesture events should be handled on the map. + * Import GestureHandling from '@vis.gl/react-google-maps' to use this. + */ + gestureHandling?: GestureHandling; + // Default values for uncontrolled usage defaultCenter?: google.maps.LatLngAltitudeLiteral; defaultHeading?: number; diff --git a/src/components/map-3d/use-tracked-camera-state-ref-3d.ts b/src/components/map-3d/use-tracked-camera-state-ref-3d.ts index 051de524..6599fdb8 100644 --- a/src/components/map-3d/use-tracked-camera-state-ref-3d.ts +++ b/src/components/map-3d/use-tracked-camera-state-ref-3d.ts @@ -1,4 +1,4 @@ -import {MutableRefObject, RefObject, useEffect, useRef} from 'react'; +import {RefObject, useEffect, useRef} from 'react'; import {useForceUpdate} from '../../hooks/use-force-update'; diff --git a/types/google.maps.d.ts b/types/google.maps.d.ts index af44cf0d..0a87c8a8 100644 --- a/types/google.maps.d.ts +++ b/types/google.maps.d.ts @@ -68,6 +68,12 @@ declare namespace google.maps { */ defaultUIHidden?: boolean; + /** + * Specifies how gesture events should be handled on the map element. + * @default GestureHandling.AUTO + */ + gestureHandling?: GestureHandling; + /** * The compass heading of the map, in degrees, where due north is zero. */ @@ -205,6 +211,31 @@ declare namespace google.maps { SATELLITE = 'SATELLITE' } + /** + * Specifies how gesture events should be handled on the map element. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#GestureHandling + */ + enum GestureHandling { + /** + * This lets the map choose whether to use cooperative or greedy gesture handling. + * This is the default behavior if not specified. + * This will cause the map to enter cooperative mode if the map is dominating its + * scroll parent (usually the host page) to where the user cannot scroll away from + * the map to other content. + */ + AUTO = 'AUTO', + /** + * This forces cooperative mode, where modifier keys or two-finger gestures + * are required to scroll the map. + */ + COOPERATIVE = 'COOPERATIVE', + /** + * This forces greedy mode, where the host page cannot be scrolled from user + * events on the map element. + */ + GREEDY = 'GREEDY' + } + /** * Customization options for the FlyCameraAround Animation. * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#FlyAroundAnimationOptions @@ -888,6 +919,7 @@ declare namespace google.maps { Polyline3DElement: typeof maps3d.Polyline3DElement; Polygon3DElement: typeof maps3d.Polygon3DElement; AltitudeMode: typeof maps3d.AltitudeMode; + GestureHandling: typeof maps3d.GestureHandling; MapMode: typeof maps3d.MapMode; } } From c7a892d243d4c353e2fe6003de149c57f91f91c6 Mon Sep 17 00:00:00 2001 From: Malte Modrow Date: Tue, 9 Dec 2025 11:35:56 +0100 Subject: [PATCH 08/21] feat: add initial Marker3D component --- src/components/marker-3d.tsx | 299 +++++++++++++++++++++++++++++++++++ src/components/pin.tsx | 58 ++++++- src/index.ts | 1 + 3 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 src/components/marker-3d.tsx diff --git a/src/components/marker-3d.tsx b/src/components/marker-3d.tsx new file mode 100644 index 00000000..426ab5f6 --- /dev/null +++ b/src/components/marker-3d.tsx @@ -0,0 +1,299 @@ +import type {PropsWithChildren} from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react'; +import {createPortal} from 'react-dom'; + +import {useMap3D} from '../hooks/use-map-3d'; +import {useMapsLibrary} from '../hooks/use-maps-library'; +import {useDomEventListener} from '../hooks/use-dom-event-listener'; +import {CollisionBehavior} from './advanced-marker'; + +// Re-export CollisionBehavior for convenience +export {CollisionBehavior}; + +/** + * Context for Marker3D component, providing access to the marker element. + */ +export interface Marker3DContextValue { + marker: + | google.maps.maps3d.Marker3DElement + | google.maps.maps3d.Marker3DInteractiveElement + | null; + /** Set to true by child components (like Pin) that handle their own content */ + setContentHandledExternally: (handled: boolean) => void; +} + +export const Marker3DContext = createContext(null); + +/** + * Hook to access the Marker3D context. + */ +export function useMarker3D() { + return useContext(Marker3DContext); +} + +/** + * AltitudeMode for specifying how altitude is interpreted for 3D elements. + * This mirrors google.maps.maps3d.AltitudeMode but is available without waiting for the API to load. + */ +export const AltitudeMode = { + /** Allows to express objects relative to the average mean sea level. */ + ABSOLUTE: 'ABSOLUTE', + /** Allows to express objects placed on the ground. */ + CLAMP_TO_GROUND: 'CLAMP_TO_GROUND', + /** Allows to express objects relative to the ground surface. */ + RELATIVE_TO_GROUND: 'RELATIVE_TO_GROUND', + /** Allows to express objects relative to the highest of ground+building+water surface. */ + RELATIVE_TO_MESH: 'RELATIVE_TO_MESH' +} as const; +export type AltitudeMode = (typeof AltitudeMode)[keyof typeof AltitudeMode]; + +/** + * Event props for Marker3D component. + */ +type Marker3DEventProps = { + /** Click handler. When provided, the interactive variant (Marker3DInteractiveElement) is used. */ + onClick?: (e: Event) => void; +}; + +/** + * Props for the Marker3D component. + */ +export type Marker3DProps = PropsWithChildren< + Omit< + google.maps.maps3d.Marker3DElementOptions, + 'collisionBehavior' | 'altitudeMode' + > & + Marker3DEventProps & { + /** + * Specifies how the altitude component of the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * An enumeration specifying how a Marker3DElement should behave when it + * collides with another Marker3DElement or with the basemap labels. + * @default CollisionBehavior.REQUIRED + */ + collisionBehavior?: CollisionBehavior; + + /** + * Rollover text (only used when onClick is provided). + */ + title?: string; + } +>; + +/** + * Marker3D component for displaying markers on a Map3D. + * + * Automatically uses Marker3DInteractiveElement when onClick is provided, + * otherwise uses Marker3DElement. + * + * Children can include: + * - `` elements (automatically wrapped in