diff --git a/docs/api-reference/components/map-3d.md b/docs/api-reference/components/map-3d.md new file mode 100644 index 00000000..164adb66 --- /dev/null +++ b/docs/api-reference/components/map-3d.md @@ -0,0 +1,348 @@ +# `` Component + +React component to render a [3D Map][gmp-map-3d] using the Google Maps JavaScript API. +It must be placed as a child inside an [``][api-provider] container. + +The main props to control the camera are `center`, `range`, `heading`, `tilt`, +and `roll`. At minimum, a center position with altitude should be specified +for the map to display properly. + +```tsx +import {APIProvider, Map3D} from '@vis.gl/react-google-maps'; + +const App = () => ( + + + +); +``` + +:::note + +By default, the Map3D component uses a default style of `width: 100%; height: 100%;` +for the container element, assuming that the parent element will establish a size +for the map. If that doesn't work in your case, you can adjust the styling using +the [`style`](#style-reactcssproperties) and [`className`](#classname-string) props. + +::: + +## Controlled and Uncontrolled Props + +The props controlling the camera parameters (center, range, heading, tilt, and roll) +can all be specified via controlled or uncontrolled values. For example, the center +of the map can be specified via either `center` or `defaultCenter`. This can even +be mixed for different parameters. + +As is the case with React form elements, the default values will only be applied +when the map is first initialized, while the regular parameters will keep the map +synchronized with the specified values. + +```tsx +const UncontrolledMap3D = () => { + return ( + + ); +}; +``` + +When controlled props are used, the map will reflect the values specified for +the camera parameters. When interactions occur, the new camera parameters will +be published with a `cameraChanged` event and the application can use them to +update the props. + +```tsx +import {Map3DCameraChangedEvent} from '@vis.gl/react-google-maps'; + +const INITIAL_CAMERA = { + center: {lat: 37.7749, lng: -122.4194, altitude: 500}, + range: 2000, + heading: 0, + tilt: 60 +}; + +const ControlledMap3D = () => { + const [center, setCenter] = useState(INITIAL_CAMERA.center); + const [range, setRange] = useState(INITIAL_CAMERA.range); + const [heading, setHeading] = useState(INITIAL_CAMERA.heading); + const [tilt, setTilt] = useState(INITIAL_CAMERA.tilt); + + const handleCameraChange = useCallback((ev: Map3DCameraChangedEvent) => { + setCenter(ev.detail.center); + setRange(ev.detail.range); + setHeading(ev.detail.heading); + setTilt(ev.detail.tilt); + }, []); + + return ( + + ); +}; +``` + +## Camera Animations + +The Map3D component exposes imperative methods for camera animations via a ref. +These include `flyCameraAround` for orbiting animations and `flyCameraTo` for +flying to a destination. + +```tsx +import {Map3D, Map3DRef} from '@vis.gl/react-google-maps'; + +const AnimatedMap = () => { + const map3dRef = useRef(null); + + const handleFlyAround = () => { + map3dRef.current?.flyCameraAround({ + camera: { + center: {lat: 37.7749, lng: -122.4194, altitude: 0}, + range: 1000, + heading: 0, + tilt: 60 + }, + durationMillis: 10000, + repeatCount: 1 + }); + }; + + const handleFlyTo = () => { + map3dRef.current?.flyCameraTo({ + endCamera: { + center: {lat: 37.8199, lng: -122.4783, altitude: 100}, + range: 1000, + heading: 45, + tilt: 65 + }, + durationMillis: 5000 + }); + }; + + return ( + <> + + + + + ); +}; +``` + +## Props + +The `Map3DProps` type extends [`google.maps.maps3d.Map3DElementOptions`][gmp-map-3d-options] +and includes all possible options available for a 3D map as props. + +### General Props + +#### `id`: string + +A string that identifies the map component. This is required when multiple +maps are present in the same APIProvider context to be able to access them +using the [`useMap3D()`](../hooks/use-map-3d.md) hook. + +#### `mode`: MapMode + +Specifies how the 3D map should be rendered. Can be `'HYBRID'` or `'SATELLITE'`, +or use the `MapMode` constants. + +```tsx +import {Map3D, MapMode} from '@vis.gl/react-google-maps'; + +; +``` + +- **`HYBRID`**: Displays a transparent layer of major streets on satellite imagery. +- **`SATELLITE`**: Displays satellite or photorealistic imagery. + +#### `gestureHandling`: GestureHandling + +Specifies how gesture events should be handled on the map. + +```tsx +import {Map3D, GestureHandling} from '@vis.gl/react-google-maps'; + +; +``` + +- **`AUTO`**: Lets the map choose the gesture handling mode (default). +- **`COOPERATIVE`**: Requires modifier keys or two-finger gestures to navigate. +- **`GREEDY`**: The map captures all gestures, preventing page scrolling. + +#### `style`: React.CSSProperties + +Additional style rules to apply to the map container element. By default, this +will contain `{width: '100%', height: '100%'}`. + +#### `className`: string + +Additional CSS class name to apply to the element containing the map. +When a className is specified, the default width and height from the style prop +are no longer applied. + +### Camera Control + +#### `center`: google.maps.LatLngAltitudeLiteral + +Coordinates for the center of the map, including altitude. + +```tsx + +``` + +#### `range`: number + +The distance from the camera to the center point in meters. + +#### `heading`: number + +The heading of the camera in degrees, measured clockwise from north. + +#### `tilt`: number + +The angle of the camera in degrees from the nadir (straight down). +A value of 0 looks straight down, while 90 would look at the horizon. + +#### `roll`: number + +The roll of the camera in degrees. + +#### `defaultCenter`, `defaultRange`, `defaultHeading`, `defaultTilt`, `defaultRoll` + +The initial state of the camera. These can be used to leave the map component +in uncontrolled mode. When both a default value and a controlled value are +present for a parameter, the controlled value takes precedence. + +### Map Options + +#### `bounds`: google.maps.LatLngBoundsLiteral + +The bounds to constrain the camera within. + +#### `defaultLabelsDisabled`: boolean + +Whether to disable default labels on the map. + +#### `maxAltitude`: number + +The maximum altitude the camera can reach in meters. + +#### `minAltitude`: number + +The minimum altitude the camera can reach in meters. + +#### `maxHeading`: number + +The maximum heading value allowed. + +#### `minHeading`: number + +The minimum heading value allowed. + +#### `maxTilt`: number + +The maximum tilt value allowed. + +#### `minTilt`: number + +The minimum tilt value allowed. + +### Events + +The Map3D component supports events emitted by the `google.maps.maps3d.Map3DElement`. + +```tsx +const MapWithEventHandler = () => { + const handleCameraChange = useCallback((ev: Map3DCameraChangedEvent) => { + console.log('camera changed:', ev.detail); + }, []); + + return ; +}; +``` + +#### Event Props + +| Event Prop | Description | Event Type | +| ------------------ | --------------------------------------------- | ------------------------- | +| `onCameraChanged` | Fired when any camera parameter changes | `Map3DCameraChangedEvent` | +| `onCenterChanged` | Fired when the center changes | `Map3DCameraChangedEvent` | +| `onHeadingChanged` | Fired when the heading changes | `Map3DCameraChangedEvent` | +| `onTiltChanged` | Fired when the tilt changes | `Map3DCameraChangedEvent` | +| `onRangeChanged` | Fired when the range changes | `Map3DCameraChangedEvent` | +| `onRollChanged` | Fired when the roll changes | `Map3DCameraChangedEvent` | +| `onClick` | Fired when the map is clicked | `Map3DClickEvent` | +| `onSteadyChange` | Fired when the map becomes steady or unsteady | `Map3DSteadyChangeEvent` | +| `onAnimationEnd` | Fired when a camera animation ends | `Map3DEvent` | +| `onError` | Fired when an error occurs | `Map3DEvent` | + +#### Event Details + +**`Map3DCameraChangedEvent`** contains: + +- **`center`**: The current center position with altitude +- **`range`**: The current range in meters +- **`heading`**: The current heading in degrees +- **`tilt`**: The current tilt in degrees +- **`roll`**: The current roll in degrees + +**`Map3DClickEvent`** contains: + +- **`position`**: The geographic position of the click with altitude + +## Ref Handle + +The Map3D component exposes a ref handle with the following properties and methods: + +```tsx +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; +} +``` + +## Context + +The Map3D component creates a `GoogleMaps3DContext` to be used by child components +like [``](./marker-3d.md), containing a reference to the +`google.maps.maps3d.Map3DElement` instance. + +## Hooks + +You can use the [`useMap3D()`](../hooks/use-map-3d.md) hook in child components +to get access to the `google.maps.maps3d.Map3DElement` rendered by the `` +component. + +## Source + +[`./src/components/map-3d`][map-3d-source] + +[gmp-map-3d]: https://developers.google.com/maps/documentation/javascript/3d-maps-overview +[gmp-map-3d-options]: https://developers.google.com/maps/documentation/javascript/reference/3d-map#Map3DElementOptions +[api-provider]: ./api-provider.md +[map-3d-source]: https://github.com/visgl/react-google-maps/tree/main/src/components/map-3d diff --git a/docs/api-reference/components/marker-3d.md b/docs/api-reference/components/marker-3d.md new file mode 100644 index 00000000..faf717ae --- /dev/null +++ b/docs/api-reference/components/marker-3d.md @@ -0,0 +1,256 @@ +# `` Component + +A component to add a [`Marker3DElement`][gmp-marker-3d] or +[`Marker3DInteractiveElement`][gmp-marker-3d-interactive] to a 3D map. +By default, a Marker3D will appear as a red balloon-shaped pin at the +specified position. The appearance can be customized using the [``](./pin.md) +component or by providing custom HTML content like images or SVGs. + +## Usage + +The `Marker3D` component must be used as a child of a [``](./map-3d.md) component. + +```tsx +import {APIProvider, Map3D, Marker3D} from '@vis.gl/react-google-maps'; + +const App = () => ( + + + + + +); +``` + +### Interactive Markers + +When an `onClick` handler is provided, the component automatically uses +`Marker3DInteractiveElement` instead of `Marker3DElement`, enabling click interactions. + +```tsx + console.log('Marker clicked!')} + title="Click me" +/> +``` + +### Custom Marker Content + +The marker appearance can be customized in several ways: + +#### Using the Pin Component + +```tsx +import {Marker3D, Pin} from '@vis.gl/react-google-maps'; + + + +; +``` + +#### Using Custom Images + +```tsx + + marker + +``` + +#### Using Custom SVG + +```tsx + + + + + A + + + +``` + +:::note + +When using `` or `` elements as children, they are automatically +wrapped in a `
+

San Francisco

+

Welcome to the city by the bay!

+
+ +
+
+ ); +}; +``` + +### Popover Anchored to a Marker + +A more typical use-case is to have a popover shown when clicking on a marker. +You can anchor the popover to a `Marker3DInteractiveElement` using the `anchor` prop: + +```tsx +import { + APIProvider, + Map3D, + Marker3D, + Popover3D +} from '@vis.gl/react-google-maps'; + +const MarkerWithPopover = ({position}) => { + const [markerElement, setMarkerElement] = useState(null); + const [popoverOpen, setPopoverOpen] = useState(false); + + return ( + <> + setPopoverOpen(true)} + title="Click for more info" + /> + + {markerElement && ( + setPopoverOpen(false)}> +
+

Location Info

+

This popover is anchored to the marker.

+
+
+ )} + + ); +}; +``` + +### Light Dismiss Behavior + +By default, popovers can be closed by clicking outside of them ("light dismiss"). +You can disable this behavior with the `lightDismissDisabled` prop: + +```tsx + +
This popover won't close when clicking outside
+
+``` + +:::note + +When `lightDismissDisabled` is true, you must provide another way for users +to close the popover, such as a close button inside the content. + +::: + +### Popover with Altitude + +Position a popover at a specific altitude above the ground: + +```tsx +import {Popover3D, AltitudeMode} from '@vis.gl/react-google-maps'; + + +
Floating 100m above ground!
+
; +``` + +## Props + +The `Popover3DProps` type extends [`google.maps.maps3d.PopoverElementOptions`][gmp-popover-options] +with additional React-specific props. + +### Required + +There are no strictly required props, but either `position` or `anchor` must be +set for the popover to appear on the map. + +### Positioning Props + +#### `position`: google.maps.LatLngLiteral | google.maps.LatLngAltitudeLiteral + +The position at which to display this popover. Can include an optional `altitude` property. + +```tsx +// 2D position + + Content here + + +// 3D position with altitude + + Content here + +``` + +:::note + +When an `anchor` is specified, the `position` prop will be ignored. + +::: + +#### `anchor`: google.maps.maps3d.Marker3DInteractiveElement + +A `Marker3DInteractiveElement` instance to anchor the popover to. When specified, +the popover will be positioned relative to the marker. + +```tsx + setOpen(true)} +/> + + + Anchored content + +``` + +#### `anchorId`: string + +A string ID referencing a `Marker3DInteractiveElement` to anchor the popover to. +This is an alternative to using the `anchor` prop when you have the marker's ID. + +#### `altitudeMode`: AltitudeMode + +Specifies how the altitude component of the position is interpreted. + +```tsx +import {Popover3D, AltitudeMode} from '@vis.gl/react-google-maps'; + + + Content here +; +``` + +Available values: + +- **`ABSOLUTE`**: Altitude relative to mean sea level. +- **`CLAMP_TO_GROUND`**: Popover is placed on the ground (default). +- **`RELATIVE_TO_GROUND`**: Altitude relative to the ground surface. +- **`RELATIVE_TO_MESH`**: Altitude relative to the highest surface (ground, buildings, or water). + +### Visibility Props + +#### `open`: boolean + +Whether the popover is currently visible. Defaults to `false`. + +```tsx +const [isOpen, setIsOpen] = useState(false); + + + Content here +; +``` + +#### `lightDismissDisabled`: boolean + +When `true`, prevents the popover from being closed when clicking outside of it. +Defaults to `false`. + +```tsx + + This popover won't close on outside click + +``` + +### Events + +#### `onClose`: (event: Event) => void + +Called when the popover is closed via light dismiss (clicking outside the popover). +Use this to keep your state in sync with the popover's visibility. + +```tsx +const [isOpen, setIsOpen] = useState(true); + + setIsOpen(false)}> + Content here +; +``` + +:::note + +The `onClose` event only fires when the popover is closed via light dismiss. +It will not fire when you programmatically set `open={false}`. + +::: + +## Ref + +The Popover3D component supports a ref that exposes the underlying +`google.maps.maps3d.PopoverElement` instance: + +```tsx +import {useRef} from 'react'; +import {Popover3D} from '@vis.gl/react-google-maps'; + +const MyComponent = () => { + const popoverRef = useRef(null); + + const handleToggle = () => { + if (popoverRef.current) { + popoverRef.current.open = !popoverRef.current.open; + } + }; + + return ( + + Content here + + ); +}; +``` + +[gmp-popover]: https://developers.google.com/maps/documentation/javascript/reference/3d-map-draw#PopoverElement +[gmp-popover-options]: https://developers.google.com//maps/documentation/javascript/reference/3d-map-draw#PopoverElementOptions diff --git a/docs/api-reference/hooks/use-map-3d.md b/docs/api-reference/hooks/use-map-3d.md new file mode 100644 index 00000000..72d7fddc --- /dev/null +++ b/docs/api-reference/hooks/use-map-3d.md @@ -0,0 +1,185 @@ +# `useMap3D` Hook + +The `useMap3D()` hook can be used to directly access the +[`google.maps.maps3d.Map3DElement`][gmp-map-3d-ref] instance created by a +[`Map3D`](../components/map-3d.md) component within the +[`APIProvider`](../components/api-provider.md) in your application. + +## Usage + +When there is only a single map within the `APIProvider`, the `useMap3D()` +hook can be called without any arguments and the `Map3D` doesn't need an id. + +The same is true for components that are added as a child to the `Map3D` +component. + +```tsx +const MyComponent = () => { + const map3d = useMap3D(); + + useEffect(() => { + if (!map3d) return; + + // do something with the map3d instance + }, [map3d]); + + return <>...; +}; + +const App = () => { + return ( + + + + + + ); +}; +``` + +When there are multiple `Map3D` components in the `APIProvider`, they are only +retrievable using the `useMap3D()` hook when the hook is either called from a +child-component of a `Map3D` or when an explicit id is specified on both the +map and as a parameter of the `useMap3D()` hook. + +```tsx +const MyComponent = () => { + const mainMap = useMap3D('main-map'); + const miniMap = useMap3D('mini-map'); + + useEffect(() => { + if (!mainMap || !miniMap) return; + + // do something with both map instances + }, [mainMap, miniMap]); + + return <>...; +}; + +const App = () => { + return ( + + + + + + ); +}; +``` + +### Using with Camera Animations + +A common use case is triggering camera animations from child components: + +```tsx +const CameraControls = () => { + const map3d = useMap3D(); + + const handleFlyAround = () => { + if (!map3d) return; + + // Cast to access animation methods + const map3dWithAnimations = map3d as google.maps.maps3d.Map3DElement & { + flyCameraAround: ( + options: google.maps.maps3d.FlyAroundAnimationOptions + ) => void; + flyCameraTo: (options: google.maps.maps3d.FlyToAnimationOptions) => void; + stopCameraAnimation: () => void; + }; + + map3dWithAnimations.flyCameraAround({ + camera: { + center: {lat: 37.7749, lng: -122.4194, altitude: 0}, + range: 1000, + heading: 0, + tilt: 60 + }, + durationMillis: 10000, + repeatCount: 1 + }); + }; + + return ; +}; +``` + +:::tip + +For camera animations, consider using the [`Map3DRef`](../components/map-3d.md#ref-handle) +exposed via `useRef` on the `Map3D` component instead, which provides +typed access to animation methods without casting. + +::: + +### Accessing Map Properties + +You can read the current camera state and other map properties: + +```tsx +const CameraInfo = () => { + const map3d = useMap3D(); + const [cameraState, setCameraState] = useState(''); + + useEffect(() => { + if (!map3d) return; + + const updateState = () => { + setCameraState( + `Center: ${JSON.stringify(map3d.center)}, ` + + `Range: ${map3d.range}, ` + + `Heading: ${map3d.heading}` + ); + }; + + map3d.addEventListener('gmp-centerchange', updateState); + updateState(); + + return () => { + map3d.removeEventListener('gmp-centerchange', updateState); + }; + }, [map3d]); + + return
{cameraState}
; +}; +``` + +## Signature + +`useMap3D(id?: string): google.maps.maps3d.Map3DElement | null` + +Returns the `google.maps.maps3d.Map3DElement` instance or `null` if it can't +be found. + +The returned map3d instance is determined as follows: + +- If an `id` is specified, the map with that `id` is retrieved from the + `APIProviderContext`. + If that can't be found, return `null`. +- When no `id` is specified + - If there is a parent map3d instance, return it + - Otherwise, return the map3d with id `default` (maps without the `id` prop + are registered with id `default`). + +### Parameters + +#### `id`: string (optional) + +The id of the map3d instance to be returned. If not specified it will return +the parent map3d instance, or the default map3d instance if there is no parent. + +## Source + +[`src/hooks/use-map-3d.ts`][src] + +[src]: https://github.com/visgl/react-google-maps/blob/main/src/hooks/use-map-3d.ts +[gmp-map-3d-ref]: https://developers.google.com/maps/documentation/javascript/reference/3d-map#Map3DElement diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index 9a7e865f..3f2db283 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -41,7 +41,10 @@ "api-reference/components/marker", "api-reference/components/advanced-marker", "api-reference/components/pin", - "api-reference/components/static-map" + "api-reference/components/static-map", + "api-reference/components/map-3d", + "api-reference/components/marker-3d", + "api-reference/components/popover-3d" ] }, { @@ -52,6 +55,7 @@ "items": [ "api-reference/hooks/use-map", + "api-reference/hooks/use-map-3d", "api-reference/hooks/use-maps-library", "api-reference/hooks/use-api-is-loaded", "api-reference/hooks/use-api-loading-status" diff --git a/examples/map-3d/src/app.tsx b/examples/map-3d/src/app.tsx index 15811d14..6d2f9180 100644 --- a/examples/map-3d/src/app.tsx +++ b/examples/map-3d/src/app.tsx @@ -1,18 +1,29 @@ import React, {useCallback, useState} from 'react'; import {createRoot} from 'react-dom/client'; -import {APIProvider, MapMouseEvent} from '@vis.gl/react-google-maps'; +import { + APIProvider, + Map3D, + Map3DCameraChangedEvent, + MapMouseEvent +} from '@vis.gl/react-google-maps'; import ControlPanel from './control-panel'; import {MiniMap} from './minimap'; -import {Map3D, Map3DCameraProps} from './map-3d'; - import './style.css'; const API_KEY = globalThis.GOOGLE_MAPS_API_KEY ?? (process.env.GOOGLE_MAPS_API_KEY as string); -const INITIAL_VIEW_PROPS = { +export type Map3DCameraProps = { + center: google.maps.LatLngAltitudeLiteral; + range: number; + heading: number; + tilt: number; + roll: number; +}; + +const INITIAL_VIEW_PROPS: Map3DCameraProps = { center: {lat: 37.72809, lng: -119.64473, altitude: 1300}, range: 5000, heading: 61, @@ -23,8 +34,8 @@ const INITIAL_VIEW_PROPS = { const Map3DExample = () => { const [viewProps, setViewProps] = useState(INITIAL_VIEW_PROPS); - const handleCameraChange = useCallback((props: Map3DCameraProps) => { - setViewProps(oldProps => ({...oldProps, ...props})); + const handleCameraChange = useCallback((ev: Map3DCameraChangedEvent) => { + setViewProps(oldProps => ({...oldProps, ...ev.detail})); }, []); const handleMapClick = useCallback((ev: MapMouseEvent) => { @@ -38,18 +49,20 @@ const Map3DExample = () => { <> - + ); }; const App = () => { return ( - + diff --git a/examples/map-3d/src/control-panel.tsx b/examples/map-3d/src/control-panel.tsx index d42414ba..dd5be285 100644 --- a/examples/map-3d/src/control-panel.tsx +++ b/examples/map-3d/src/control-panel.tsx @@ -8,8 +8,8 @@ function ControlPanel() {

3D Maps

- This example implements a new Map3D component that renders - a 3D Globe based on the new{' '} + This example showcases the Map3D component that renders a + 3D globe based on the{' '} Map3DElement {' '} diff --git a/examples/map-3d/src/map-3d/index.ts b/examples/map-3d/src/map-3d/index.ts deleted file mode 100644 index a21d1287..00000000 --- a/examples/map-3d/src/map-3d/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './map-3d'; diff --git a/examples/map-3d/src/map-3d/map-3d.tsx b/examples/map-3d/src/map-3d/map-3d.tsx deleted file mode 100644 index ff816ce2..00000000 --- a/examples/map-3d/src/map-3d/map-3d.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import {useMapsLibrary} from '@vis.gl/react-google-maps'; -import React, { - ForwardedRef, - forwardRef, - useEffect, - useImperativeHandle, - useState -} from 'react'; -import {useMap3DCameraEvents} from './use-map-3d-camera-events'; -import {useCallbackRef, useDeepCompareEffect} from '../utility-hooks'; - -export type Map3DProps = google.maps.maps3d.Map3DElementOptions & { - onCameraChange?: (cameraProps: Map3DCameraProps) => void; -}; - -export type Map3DCameraProps = { - center: google.maps.LatLngAltitudeLiteral; - range: number; - heading: number; - tilt: number; - roll: number; -}; - -export const Map3D = forwardRef( - ( - props: Map3DProps, - forwardedRef: ForwardedRef - ) => { - useMapsLibrary('maps3d'); - - const [map3DElement, map3dRef] = - useCallbackRef(); - - useMap3DCameraEvents(map3DElement, p => { - if (!props.onCameraChange) return; - - props.onCameraChange(p); - }); - - const [customElementsReady, setCustomElementsReady] = useState(false); - useEffect(() => { - customElements.whenDefined('gmp-map-3d').then(() => { - setCustomElementsReady(true); - }); - }, []); - - const {center, heading, tilt, range, roll, ...map3dOptions} = props; - - useDeepCompareEffect(() => { - if (!map3DElement) return; - - // copy all values from map3dOptions to the map3D element itself - Object.assign(map3DElement, map3dOptions); - }, [map3DElement, map3dOptions]); - - useImperativeHandle< - google.maps.maps3d.Map3DElement | null, - google.maps.maps3d.Map3DElement | null - >(forwardedRef, () => map3DElement, [map3DElement]); - - if (!customElementsReady) return null; - - return ( - - ); - } -); - -Map3D.displayName = 'Map3D'; diff --git a/examples/map-3d/src/map-3d/use-map-3d-camera-events.ts b/examples/map-3d/src/map-3d/use-map-3d-camera-events.ts deleted file mode 100644 index 9744c354..00000000 --- a/examples/map-3d/src/map-3d/use-map-3d-camera-events.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {useEffect, useRef} from 'react'; -import {Map3DCameraProps} from './map-3d'; - -const cameraPropNames = ['center', 'range', 'heading', 'tilt', 'roll'] as const; - -const DEFAULT_CAMERA_PROPS: Map3DCameraProps = { - center: {lat: 0, lng: 0, altitude: 0}, - range: 0, - heading: 0, - tilt: 0, - roll: 0 -}; - -/** - * Binds event-listeners for all camera-related events to the Map3dElement. - * The values from the events are aggregated into a Map3DCameraProps object, - * and changes are dispatched via the onCameraChange callback. - */ -export function useMap3DCameraEvents( - mapEl?: google.maps.maps3d.Map3DElement | null, - onCameraChange?: (cameraProps: Map3DCameraProps) => void -) { - const cameraPropsRef = useRef(DEFAULT_CAMERA_PROPS); - - useEffect(() => { - if (!mapEl) return; - - const cleanupFns: (() => void)[] = []; - - let updateQueued = false; - - for (const p of cameraPropNames) { - const removeListener = addDomListener(mapEl, `gmp-${p}change`, () => { - const newValue = mapEl[p]; - - if (newValue == null) return; - - if (p === 'center') - // fixme: the typings say this should be a LatLngAltitudeLiteral, but in reality a - // LatLngAltitude object is returned, even when a LatLngAltitudeLiteral was written - // to the property. - cameraPropsRef.current.center = ( - newValue as google.maps.LatLngAltitude - ).toJSON(); - else cameraPropsRef.current[p] = newValue as number; - - if (onCameraChange && !updateQueued) { - updateQueued = true; - - // queue a microtask so all synchronously dispatched events are handled first - queueMicrotask(() => { - updateQueued = false; - onCameraChange(cameraPropsRef.current); - }); - } - }); - - cleanupFns.push(removeListener); - } - - return () => { - for (const removeListener of cleanupFns) removeListener(); - }; - }, [mapEl, onCameraChange]); -} - -/** - * Adds an event-listener and returns a function to remove it again. - */ -function addDomListener( - element: google.maps.maps3d.Map3DElement, - type: string, - listener: (this: google.maps.maps3d.Map3DElement, ev: unknown) => void -): () => void { - element.addEventListener(type, listener); - - return () => { - element.removeEventListener(type, listener); - }; -} diff --git a/examples/map-3d/src/minimap/minimap.tsx b/examples/map-3d/src/minimap/minimap.tsx index c32bba24..4bd335ac 100644 --- a/examples/map-3d/src/minimap/minimap.tsx +++ b/examples/map-3d/src/minimap/minimap.tsx @@ -6,7 +6,7 @@ import {estimateCameraPosition} from './estimate-camera-position'; import {CameraPositionMarker} from './camera-position-marker'; import {ViewCenterMarker} from './view-center-marker'; -import type {Map3DCameraProps} from '../map-3d'; +import type {Map3DCameraProps} from '../app'; type MiniMapProps = { camera3dProps: Map3DCameraProps; diff --git a/package-lock.json b/package-lock.json index 421f36f3..707f958e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "rollup": "^4.52.4", "rollup-plugin-dts": "^6.2.3", "ts-jest": "^29.0.5", - "tslib": "^2.6.2", + "tslib": "^2.8.1", "typescript": "^5.1.6", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/package.json b/package.json index bd9adfc5..3cb444cc 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "rollup": "^4.52.4", "rollup-plugin-dts": "^6.2.3", "ts-jest": "^29.0.5", - "tslib": "^2.6.2", + "tslib": "^2.8.1", "typescript": "^5.1.6", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/src/components/__tests__/__snapshots__/pin.test.tsx.snap b/src/components/__tests__/__snapshots__/pin.test.tsx.snap index bda93654..4fb11530 100644 --- a/src/components/__tests__/__snapshots__/pin.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/pin.test.tsx.snap @@ -2,6 +2,6 @@ exports[`pin view logs an error when used outside of AdvancedMarker 1`] = ` [ - "The component can only be used inside .", + "The component can only be used inside or .", ] `; diff --git a/src/components/__tests__/map-3d.test.tsx b/src/components/__tests__/map-3d.test.tsx new file mode 100644 index 00000000..89e625b5 --- /dev/null +++ b/src/components/__tests__/map-3d.test.tsx @@ -0,0 +1,128 @@ +import {initialize} from '@googlemaps/jest-mocks'; +import {render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import {Map3D} from '../map-3d'; +import {useMapsLibrary} from '../../hooks/use-maps-library'; +import {APIProviderContext} from '../api-provider'; +import {APILoadingStatus} from '../../libraries/api-loading-status'; + +jest.mock('../../hooks/use-maps-library'); + +let useMapsLibraryMock: jest.MockedFn; + +// Create a mock context value that satisfies APIProviderContext +const createMockContextValue = () => ({ + status: APILoadingStatus.LOADED, + loadedLibraries: {}, + importLibrary: jest.fn(), + addLoadedLibrary: jest.fn(), + mapInstances: {}, + addMapInstance: jest.fn(), + removeMapInstance: jest.fn(), + clearMapInstances: jest.fn(), + map3dInstances: {}, + addMap3DInstance: jest.fn(), + removeMap3DInstance: jest.fn(), + clearMap3DInstances: jest.fn(), + internalUsageAttributionIds: null +}); + +beforeEach(() => { + initialize(); + + useMapsLibraryMock = jest.mocked(useMapsLibrary); + // Return null to prevent element creation (library not loaded) + useMapsLibraryMock.mockReturnValue(null); + + // Mock customElements.whenDefined to never resolve + jest.spyOn(customElements, 'whenDefined').mockImplementation( + () => new Promise(() => {}) // Never resolves + ); +}); + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +describe('Map3D', () => { + test('renders container div with data-testid', () => { + render( + + + + ); + + expect(screen.getByTestId('map-3d')).toBeInTheDocument(); + }); + + test('applies id prop to container', () => { + render( + + + + ); + + expect(screen.getByTestId('map-3d')).toHaveAttribute('id', 'my-map'); + }); + + test('applies className prop to container', () => { + render( + + + + ); + + expect(screen.getByTestId('map-3d')).toHaveClass('custom-class'); + }); + + test('applies style prop to container', () => { + render( + + + + ); + + const container = screen.getByTestId('map-3d'); + // Should have both default and custom styles + expect(container).toHaveStyle({border: '1px solid red'}); + }); + + test('does not render children when map is not ready', () => { + render( + + +

Child content
+ + + ); + + // Children should NOT be rendered when map3d element isn't available + // (they need the Map3D context which requires the element) + expect(screen.queryByTestId('child-element')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/map.test.tsx b/src/components/__tests__/map.test.tsx index b2fa08d8..305be0b0 100644 --- a/src/components/__tests__/map.test.tsx +++ b/src/components/__tests__/map.test.tsx @@ -26,6 +26,10 @@ beforeEach(() => { addMapInstance: jest.fn(), removeMapInstance: jest.fn(), clearMapInstances: jest.fn(), + map3dInstances: {}, + addMap3DInstance: jest.fn(), + removeMap3DInstance: jest.fn(), + clearMap3DInstances: jest.fn(), internalUsageAttributionIds: null }; diff --git a/src/components/__tests__/marker-3d.test.tsx b/src/components/__tests__/marker-3d.test.tsx new file mode 100644 index 00000000..1339f7a1 --- /dev/null +++ b/src/components/__tests__/marker-3d.test.tsx @@ -0,0 +1,207 @@ +import {initialize} from '@googlemaps/jest-mocks'; +import {render, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import {Marker3D} from '../marker-3d'; +import {useMap3D} from '../../hooks/use-map-3d'; +import {useMapsLibrary} from '../../hooks/use-maps-library'; + +jest.mock('../../hooks/use-map-3d'); +jest.mock('../../hooks/use-maps-library'); + +let useMap3DMock: jest.MockedFn; +let useMapsLibraryMock: jest.MockedFn; +let createMarkerSpy: jest.Mock; +let createInteractiveMarkerSpy: jest.Mock; +let mockMap3D: {appendChild: jest.Mock; removeChild: jest.Mock}; +let Marker3DElement: unknown; +let Marker3DInteractiveElement: unknown; + +beforeEach(() => { + initialize(); + + createMarkerSpy = jest.fn(); + createInteractiveMarkerSpy = jest.fn(); + + mockMap3D = { + appendChild: jest.fn(), + removeChild: jest.fn() + }; + + // Create mock Marker3DElement + Marker3DElement = class extends HTMLElement { + position: google.maps.LatLngLiteral | null = null; + altitudeMode: string | null = null; + label: string | null = null; + collisionBehavior: string | null = null; + drawsWhenOccluded = false; + extruded = false; + sizePreserved = false; + zIndex: number | null = null; + + constructor() { + super(); + createMarkerSpy(this); + } + }; + + // Create mock Marker3DInteractiveElement + Marker3DInteractiveElement = class extends HTMLElement { + position: google.maps.LatLngLiteral | null = null; + altitudeMode: string | null = null; + label: string | null = null; + override title = ''; + collisionBehavior: string | null = null; + drawsWhenOccluded = false; + extruded = false; + sizePreserved = false; + zIndex: number | null = null; + + constructor() { + super(); + createInteractiveMarkerSpy(this); + } + }; + + // Register with random names to avoid conflicts + customElements.define( + `gmp-marker-3d-${Math.random().toString(36).slice(2)}`, + Marker3DElement as CustomElementConstructor + ); + customElements.define( + `gmp-marker-3d-interactive-${Math.random().toString(36).slice(2)}`, + Marker3DInteractiveElement as CustomElementConstructor + ); + + useMap3DMock = jest.mocked(useMap3D); + useMapsLibraryMock = jest.mocked(useMapsLibrary); + + useMap3DMock.mockReturnValue( + mockMap3D as unknown as google.maps.maps3d.Map3DElement + ); + useMapsLibraryMock.mockReturnValue({ + Marker3DElement, + Marker3DInteractiveElement + } as unknown as google.maps.Maps3DLibrary); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('Marker3D', () => { + test('creates Marker3DElement after map and library ready', async () => { + render(); + + await waitFor(() => { + expect(createMarkerSpy).toHaveBeenCalled(); + }); + + expect(mockMap3D.appendChild).toHaveBeenCalled(); + }); + + test('creates Marker3DInteractiveElement when onClick provided', async () => { + const onClick = jest.fn(); + + render( + + ); + + await waitFor(() => { + expect(createInteractiveMarkerSpy).toHaveBeenCalled(); + }); + + expect(createMarkerSpy).not.toHaveBeenCalled(); + }); + + test('does not render when map is not ready', () => { + useMap3DMock.mockReturnValue(null); + + render(); + + expect(createMarkerSpy).not.toHaveBeenCalled(); + }); + + test('does not render when library is not ready', () => { + useMapsLibraryMock.mockReturnValue(null); + + render(); + + expect(createMarkerSpy).not.toHaveBeenCalled(); + }); + + test('syncs position prop', async () => { + const {rerender} = render( + + ); + + await waitFor(() => { + expect(createMarkerSpy).toHaveBeenCalled(); + }); + + const marker = createMarkerSpy.mock.calls[0][0]; + + rerender(); + + await waitFor(() => { + expect(marker.position).toEqual({lat: 40.7128, lng: -74.006}); + }); + }); + + test('syncs label prop', async () => { + const {rerender} = render( + + ); + + await waitFor(() => { + expect(createMarkerSpy).toHaveBeenCalled(); + }); + + const marker = createMarkerSpy.mock.calls[0][0]; + + rerender( + + ); + + await waitFor(() => { + expect(marker.label).toBe('Updated'); + }); + }); + + test('exposes marker element via ref', async () => { + const refCallback = jest.fn(); + + render( + {}} + ref={refCallback} + /> + ); + + await waitFor(() => { + expect(createInteractiveMarkerSpy).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(refCallback).toHaveBeenCalled(); + }); + }); + + test('cleans up on unmount', async () => { + const {unmount} = render( + + ); + + await waitFor(() => { + expect(createMarkerSpy).toHaveBeenCalled(); + }); + + unmount(); + + // Marker should be removed from map + const marker = createMarkerSpy.mock.calls[0][0]; + expect(marker.parentElement).toBeNull(); + }); +}); diff --git a/src/components/__tests__/popover-3d.test.tsx b/src/components/__tests__/popover-3d.test.tsx new file mode 100644 index 00000000..b3e8be1a --- /dev/null +++ b/src/components/__tests__/popover-3d.test.tsx @@ -0,0 +1,265 @@ +import {initialize} from '@googlemaps/jest-mocks'; +import {render, waitFor, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import {Popover3D} from '../popover-3d'; +import {useMap3D} from '../../hooks/use-map-3d'; +import {useMapsLibrary} from '../../hooks/use-maps-library'; + +jest.mock('../../hooks/use-map-3d'); +jest.mock('../../hooks/use-maps-library'); + +let useMap3DMock: jest.MockedFn; +let useMapsLibraryMock: jest.MockedFn; +let createPopoverSpy: jest.Mock; +let mockMap3D: {appendChild: jest.Mock; removeChild: jest.Mock}; +let PopoverElement: unknown; + +beforeEach(() => { + initialize(); + + createPopoverSpy = jest.fn(); + + mockMap3D = { + appendChild: jest.fn(), + removeChild: jest.fn() + }; + + // Create mock PopoverElement + PopoverElement = class extends HTMLElement { + open = false; + positionAnchor: unknown = null; + altitudeMode: string | null = null; + lightDismissDisabled = false; + + constructor() { + super(); + createPopoverSpy(this); + } + }; + + // Register with random name + customElements.define( + `gmp-popover-${Math.random().toString(36).slice(2)}`, + PopoverElement as CustomElementConstructor + ); + + useMap3DMock = jest.mocked(useMap3D); + useMapsLibraryMock = jest.mocked(useMapsLibrary); + + useMap3DMock.mockReturnValue( + mockMap3D as unknown as google.maps.maps3d.Map3DElement + ); + useMapsLibraryMock.mockReturnValue({ + PopoverElement + } as unknown as google.maps.Maps3DLibrary); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('Popover3D', () => { + test('creates PopoverElement after map and library ready', async () => { + render( + +
Content
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + expect(mockMap3D.appendChild).toHaveBeenCalled(); + }); + + test('does not render when map is not ready', () => { + useMap3DMock.mockReturnValue(null); + + render( + +
Content
+
+ ); + + expect(createPopoverSpy).not.toHaveBeenCalled(); + }); + + test('does not render when library is not ready', () => { + useMapsLibraryMock.mockReturnValue(null); + + render( + +
Content
+
+ ); + + expect(createPopoverSpy).not.toHaveBeenCalled(); + }); + + test('syncs open prop', async () => { + const {rerender} = render( + +
Content
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + const popover = createPopoverSpy.mock.calls[0][0]; + expect(popover.open).toBe(false); + + rerender( + +
Content
+
+ ); + + await waitFor(() => { + expect(popover.open).toBe(true); + }); + }); + + test('syncs position as positionAnchor', async () => { + render( + +
Content
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + const popover = createPopoverSpy.mock.calls[0][0]; + + await waitFor(() => { + expect(popover.positionAnchor).toEqual({lat: 37.7749, lng: -122.4194}); + }); + }); + + test('syncs anchor element as positionAnchor', async () => { + const mockMarker = {} as google.maps.maps3d.Marker3DInteractiveElement; + + render( + +
Content
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + const popover = createPopoverSpy.mock.calls[0][0]; + + await waitFor(() => { + expect(popover.positionAnchor).toBe(mockMarker); + }); + }); + + test('syncs lightDismissDisabled prop', async () => { + const {rerender} = render( + +
Content
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + const popover = createPopoverSpy.mock.calls[0][0]; + + rerender( + +
Content
+
+ ); + + await waitFor(() => { + expect(popover.lightDismissDisabled).toBe(true); + }); + }); + + test('renders children content into the popover', async () => { + // Use a real appendChild so we can check the content + const appendedElements: HTMLElement[] = []; + mockMap3D.appendChild.mockImplementation((el: HTMLElement) => { + document.body.appendChild(el); + appendedElements.push(el); + }); + + render( + +
Hello World
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + // Content should be rendered (via portal into popover) + await waitFor(() => { + expect(screen.getByTestId('popover-content')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('popover-content').textContent).toBe( + 'Hello World' + ); + }); + + test('cleans up on unmount', async () => { + const {unmount} = render( + +
Content
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + unmount(); + + // Verify popover was removed + const popover = createPopoverSpy.mock.calls[0][0]; + expect(popover.parentElement).toBeNull(); + }); + + test('exposes popover element via ref', async () => { + const refCallback = jest.fn(); + + render( + +
Content
+
+ ); + + await waitFor(() => { + expect(createPopoverSpy).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(refCallback).toHaveBeenCalled(); + }); + + // Verify the ref received the popover element + const popover = createPopoverSpy.mock.calls[0][0]; + expect(refCallback).toHaveBeenCalledWith(popover); + }); +}); diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 21b6bbaf..b77f8c7e 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -6,7 +6,6 @@ // immutable state in React, it is a necessary evil to integrate with the // Google Maps API. The mutations are carefully managed within the `useEffect` // hooks to ensure that they only happen when the props change. - import type {PropsWithChildren, Ref} from 'react'; import React, { Children, diff --git a/src/components/api-provider.tsx b/src/components/api-provider.tsx index d92e8d59..9570b733 100644 --- a/src/components/api-provider.tsx +++ b/src/components/api-provider.tsx @@ -25,6 +25,13 @@ export interface APIProviderContextValue { addMapInstance: (map: google.maps.Map, id?: string) => void; removeMapInstance: (id?: string) => void; clearMapInstances: () => void; + map3dInstances: Record; + addMap3DInstance: ( + map3d: google.maps.maps3d.Map3DElement, + id?: string + ) => void; + removeMap3DInstance: (id?: string) => void; + clearMap3DInstances: () => void; internalUsageAttributionIds: string[] | null; } @@ -145,6 +152,37 @@ function useMapInstances() { return {mapInstances, addMapInstance, removeMapInstance, clearMapInstances}; } +/** + * local hook to set up the 3D map-instance management context. + */ +function useMap3DInstances() { + const [map3dInstances, setMap3DInstances] = useState< + Record + >({}); + + const addMap3DInstance = ( + map3dInstance: google.maps.maps3d.Map3DElement, + id = 'default' + ) => { + setMap3DInstances(instances => ({...instances, [id]: map3dInstance})); + }; + + const removeMap3DInstance = (id = 'default') => { + setMap3DInstances(({[id]: _, ...remaining}) => remaining); + }; + + const clearMap3DInstances = () => { + setMap3DInstances({}); + }; + + return { + map3dInstances, + addMap3DInstance, + removeMap3DInstance, + clearMap3DInstances + }; +} + /** * Local hook to handle the loading of the maps API. * @internal @@ -346,6 +384,12 @@ export const APIProvider: FunctionComponent = props => { const {children, ...loaderProps} = props; const {mapInstances, addMapInstance, removeMapInstance, clearMapInstances} = useMapInstances(); + const { + map3dInstances, + addMap3DInstance, + removeMap3DInstance, + clearMap3DInstances + } = useMap3DInstances(); const {status, loadedLibraries, importLibrary} = useGoogleMapsApiLoader(loaderProps); @@ -359,6 +403,10 @@ export const APIProvider: FunctionComponent = props => { addMapInstance, removeMapInstance, clearMapInstances, + map3dInstances, + addMap3DInstance, + removeMap3DInstance, + clearMap3DInstances, status, loadedLibraries, importLibrary, @@ -369,6 +417,10 @@ export const APIProvider: FunctionComponent = props => { addMapInstance, removeMapInstance, clearMapInstances, + map3dInstances, + addMap3DInstance, + removeMap3DInstance, + clearMap3DInstances, status, loadedLibraries, importLibrary, diff --git a/src/components/map-3d/index.tsx b/src/components/map-3d/index.tsx new file mode 100644 index 00000000..1f440a50 --- /dev/null +++ b/src/components/map-3d/index.tsx @@ -0,0 +1,271 @@ +import React, { + CSSProperties, + forwardRef, + PropsWithChildren, + useContext, + useEffect, + 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'; + +// Re-export event types for consumers +export type { + Map3DEvent, + Map3DCameraChangedEvent, + Map3DClickEvent, + Map3DSteadyChangeEvent, + 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. + */ +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< + google.maps.maps3d.Map3DElementOptions, + 'center' | 'mode' | 'gestureHandling' + > & + 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; + + /** + * 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; + 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.' + ); + } + + const {addMap3DInstance, removeMap3DInstance} = context; + + const [map3d, containerRef, map3dRef, cameraStateRef, isReady] = + useMap3DInstance(props); + + useMap3DCameraParams(map3d, cameraStateRef, props); + useMap3DEvents(map3d, props); + useMap3DOptions(map3d, props); + + useEffect(() => { + if (!map3d) return; + + const instanceId = id ?? 'default'; + addMap3DInstance(map3d, instanceId); + + return () => { + removeMap3DInstance(instanceId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map3d, id]); + + 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] + ); + + const combinedStyle = useMemo( + () => ({ + ...DEFAULT_CONTAINER_STYLE, + ...style + }), + [style] + ); + + const contextValue = useMemo( + () => ({map3d}), + [map3d] + ); + + 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..63165484 --- /dev/null +++ b/src/components/map-3d/use-map-3d-camera-params.ts @@ -0,0 +1,85 @@ +/* eslint-disable react-hooks/immutability -- Google Maps API objects are designed to be mutated */ +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; + + // Runs on every render to sync controlled camera props with the map element + useLayoutEffect(() => { + if (!map3d) return; + + const currentState = cameraStateRef.current; + + 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 + }; + } + + if (range !== null && currentState.range !== range) { + map3d.range = range; + } + + if (heading !== null && currentState.heading !== heading) { + map3d.heading = heading; + } + + if (tilt !== null && currentState.tilt !== tilt) { + map3d.tilt = tilt; + } + + if (roll !== null && currentState.roll !== roll) { + map3d.roll = roll; + } + }); +} 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..f6219f9f --- /dev/null +++ b/src/components/map-3d/use-map-3d-events.ts @@ -0,0 +1,267 @@ +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; +} + +/** + * 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; + + 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); + + // onCameraChanged aggregates all camera property change events into one handler + 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]); + + 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]); + + 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]); + + useMap3DEvent(map3d, 'gmp-animationend', onAnimationEnd, (map3d, type) => ({ + type, + map3d + })); + + 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..b2819d2d --- /dev/null +++ b/src/components/map-3d/use-map-3d-instance.ts @@ -0,0 +1,102 @@ +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 +] { + const maps3dLib = useMapsLibrary('maps3d'); + const [customElementReady, setCustomElementReady] = useState(false); + const [, containerRef] = useCallbackRef(); + const [map3d, map3dRef] = useCallbackRef(); + const cameraStateRef = useTrackedCameraStateRef3D(map3d); + + useEffect(() => { + customElements.whenDefined('gmp-map-3d').then(() => { + setCustomElementReady(true); + }); + }, []); + + // Apply initial options once when the element is first available + useEffect(() => { + if (!map3d) return; + + const { + center, + heading, + tilt, + range, + roll, + defaultCenter, + defaultHeading, + defaultTilt, + defaultRange, + defaultRoll, + // Non-element props to exclude + id, + style, + className, + children, + onCenterChanged, + onHeadingChanged, + onTiltChanged, + onRangeChanged, + onRollChanged, + onCameraChanged, + onClick, + onSteadyChange, + onAnimationEnd, + onError, + mode, + gestureHandling, + ...elementOptions + } = props; + + const initialCenter = center ?? defaultCenter; + const initialHeading = heading ?? defaultHeading; + const initialTilt = tilt ?? defaultTilt; + const initialRange = range ?? defaultRange; + const initialRoll = roll ?? defaultRoll; + + 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; + + Object.assign(map3d, initialOptions); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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..fac13f0d --- /dev/null +++ b/src/components/map-3d/use-map-3d-options.ts @@ -0,0 +1,60 @@ +import {useMemo} from 'react'; + +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([ + 'bounds', + 'defaultUIHidden', + 'gestureHandling', + 'internalUsageAttributionIds', + 'maxAltitude', + 'maxHeading', + 'maxTilt', + 'minAltitude', + 'minHeading', + 'minTilt', + 'mode' +] as const); + +/** + * 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, memoized to avoid + // creating a new object on every render + const options = useMemo(() => { + const result: Record = {}; + const keys = Object.keys(props); + + for (const key of keys) { + if ( + !MAP_3D_OPTION_KEYS.has( + key as typeof MAP_3D_OPTION_KEYS extends Set ? T : never + ) + ) + continue; + const value = (props as unknown as Record)[key]; + + if (value === undefined) continue; + + result[key] = value; + } + return result; + }, [props]); + + 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..9e0a3ef7 --- /dev/null +++ b/src/components/map-3d/use-tracked-camera-state-ref-3d.ts @@ -0,0 +1,93 @@ +import {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); + 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/components/marker-3d.tsx b/src/components/marker-3d.tsx new file mode 100644 index 00000000..dddcf669 --- /dev/null +++ b/src/components/marker-3d.tsx @@ -0,0 +1,301 @@ +/* eslint-disable react-hooks/immutability -- Google Maps API objects are designed to be mutated */ +import type {PropsWithChildren, Ref} from 'react'; +import React, { + createContext, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useState, + forwardRef +} 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