diff --git a/examples/map-3d-markers/README.md b/examples/map-3d-markers/README.md new file mode 100644 index 00000000..2e448061 --- /dev/null +++ b/examples/map-3d-markers/README.md @@ -0,0 +1,42 @@ +# 3D Maps with Markers Example + +This example implements a new `Map3D` component that renders +a 3D Globe based on the new experimental [`Map3DElement`][gmp-map3d-overview] +web-component. + +The map contains basic [`Marker3DElements`][gmp-map3d-marker-add] as well as markers with a custom pin and a 3D model. + +[gmp-map3d-overview]: https://developers.google.com/maps/documentation/javascript/3d-maps-overview +[gmp-map3d-marker-add]: https://developers.google.com/maps/documentation/javascript/3d/marker-add + +## Google Maps API key + +This example does not come with an API key. Running the examples locally requires a valid API key for the Google Maps Platform. +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 +``` + +The regular `npm start` task is only used for the standalone versions of the example (CodeSandbox for example) + +[get-api-key]: https://developers.google.com/maps/documentation/javascript/get-api-key diff --git a/examples/map-3d-markers/config/marker-config.json b/examples/map-3d-markers/config/marker-config.json new file mode 100644 index 00000000..cd7098b0 --- /dev/null +++ b/examples/map-3d-markers/config/marker-config.json @@ -0,0 +1,73 @@ +{ + "basic": { + "markerOptions": { + "position": { + "lat": 40.704876, + "lng": -73.995379, + "altitude": 50 + }, + "altitudeMode": "RELATIVE_TO_GROUND" + } + }, + "basicExtruded": { + "markerOptions": { + "position": { + "lat": 40.704118, + "lng": -73.994371, + "altitude": 150 + }, + "extruded": true, + "altitudeMode": "RELATIVE_TO_GROUND" + } + }, + "basicColored": { + "markerOptions": { + "position": { + "lat": 40.705666, + "lng": -73.996382, + "altitude": 50 + }, + "altitudeMode": "RELATIVE_TO_GROUND" + }, + "pinOptions": { + "borderColor": "#0D652D", + "background": "#34A853", + "glyphColor": "white" + } + }, + "customLogo": { + "markerOptions": { + "position": { + "lat": 40.706461, + "lng": -73.997409, + "altitude": 50 + }, + "altitudeMode": "RELATIVE_TO_GROUND" + }, + "pinOptions": { + "borderColor": "white", + "background": "white", + "glyph": "https://www.gstatic.com/images/branding/productlogos/maps/v7/192px.svg" + } + }, + "svg": { + "markerOptions": { + "position": { + "lat": 40.707275, + "lng": -73.998332, + "altitude": 80 + }, + "altitudeMode": "RELATIVE_TO_GROUND" + } + }, + "model3d": { + "markerOptions": { + "position": { + "lat": 40.70880455562221, + "lng": -74.00022947934575, + "altitude": 150 + }, + "altitudeMode": "RELATIVE_TO_GROUND" + } + } +} diff --git a/examples/map-3d-markers/data/balloon-red.glb b/examples/map-3d-markers/data/balloon-red.glb new file mode 100644 index 00000000..9d39850b Binary files /dev/null and b/examples/map-3d-markers/data/balloon-red.glb differ diff --git a/examples/map-3d-markers/index.html b/examples/map-3d-markers/index.html new file mode 100644 index 00000000..a9c7fcfd --- /dev/null +++ b/examples/map-3d-markers/index.html @@ -0,0 +1,31 @@ + + + + + + Example: Photorealistic 3D Map with Markers + + + + +
+ + + diff --git a/examples/map-3d-markers/package.json b/examples/map-3d-markers/package.json new file mode 100644 index 00000000..60c228cd --- /dev/null +++ b/examples/map-3d-markers/package.json @@ -0,0 +1,15 @@ +{ + "type": "module", + "dependencies": { + "@vis.gl/react-google-maps": "latest", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.4.5", + "vite": "^6.0.11" + }, + "scripts": { + "start": "vite", + "start-local": "vite --config ../vite.config.local.js", + "build": "vite build" + } +} diff --git a/examples/map-3d-markers/src/app.tsx b/examples/map-3d-markers/src/app.tsx new file mode 100644 index 00000000..99836dc4 --- /dev/null +++ b/examples/map-3d-markers/src/app.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {createRoot} from 'react-dom/client'; + +import {APIProvider} from '@vis.gl/react-google-maps'; + +import {Map3D} from './map-3d'; +import ControlPanel from './control-panel'; + +const API_KEY = + globalThis.GOOGLE_MAPS_API_KEY ?? (process.env.GOOGLE_MAPS_API_KEY as string); + +const INITIAL_VIEW_PROPS = { + center: {lat: 40.7093, lng: -73.9968, altitude: 32}, + range: 1733, + heading: 5, + tilt: 70, + roll: 0 +}; + +const App = () => { + return ( + + + + + ); +}; +export default App; + +export function renderToDom(container: HTMLElement) { + const root = createRoot(container); + + root.render( + + + + ); +} diff --git a/examples/map-3d-markers/src/control-panel.tsx b/examples/map-3d-markers/src/control-panel.tsx new file mode 100644 index 00000000..59420381 --- /dev/null +++ b/examples/map-3d-markers/src/control-panel.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +const GMP_3D_MAPS_OVERVIEW_URL = + 'https://developers.google.com/maps/documentation/javascript/3d-maps-overview'; + +const GMP_3D_MAPS_MARKER_ADD_URL = + 'https://developers.google.com/maps/documentation/javascript/3d/marker-add'; + +function ControlPanel() { + return ( +
+

3D Maps with Markers

+

+ This example implements a new Map3D component that renders + a 3D Globe based on the new experimental{' '} + + Map3DElement + {' '} + web-component. +

+ +

+ The map contains basic{' '} + + Marker3DElements + {' '} + as well as markers with a custom pin and a 3D model. +

+ +
+ + Try on CodeSandbox ↗ + + + + View Code ↗ + +
+
+ ); +} + +export default React.memo(ControlPanel); diff --git a/examples/map-3d-markers/src/map-3d/index.ts b/examples/map-3d-markers/src/map-3d/index.ts new file mode 100644 index 00000000..a21d1287 --- /dev/null +++ b/examples/map-3d-markers/src/map-3d/index.ts @@ -0,0 +1 @@ +export * from './map-3d'; diff --git a/examples/map-3d-markers/src/map-3d/map-3d-types.ts b/examples/map-3d-markers/src/map-3d/map-3d-types.ts new file mode 100644 index 00000000..570f783f --- /dev/null +++ b/examples/map-3d-markers/src/map-3d/map-3d-types.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-explicit-any */ + +import {DOMAttributes, RefAttributes} from 'react'; + +// add an overload signature for the useMapsLibrary hook, so typescript +// knows what the 'maps3d' library is. +declare module '@vis.gl/react-google-maps' { + export function useMapsLibrary( + name: 'maps3d' + ): typeof google.maps.maps3d | null; +} + +// temporary fix until @types/google.maps is updated with the latest changes +declare global { + namespace google.maps.maps3d { + interface Map3DElement extends HTMLElement { + mode?: 'HYBRID' | 'SATELLITE'; + } + } +} + +// add the custom-element to the JSX.IntrinsicElements +// interface, so it can be used in jsx +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + ['gmp-map-3d']: CustomElement< + google.maps.maps3d.Map3DElement, + google.maps.maps3d.Map3DElement + >; + } + + interface IntrinsicElements { + ['gmp-marker-3d']: CustomElement< + google.maps.maps3d.Marker3DElement, + google.maps.maps3d.Marker3DElement + >; + } + } +} + +// a helper type for CustomElement definitions +type CustomElement = Partial< + TAttr & + DOMAttributes & + RefAttributes & { + // for whatever reason, anything else doesn't work as children + // of a custom element, so we allow `any` here + children: any; + } +>; diff --git a/examples/map-3d-markers/src/map-3d/map-3d.tsx b/examples/map-3d-markers/src/map-3d/map-3d.tsx new file mode 100644 index 00000000..e8a13ae8 --- /dev/null +++ b/examples/map-3d-markers/src/map-3d/map-3d.tsx @@ -0,0 +1,107 @@ +import {useMapsLibrary} from '@vis.gl/react-google-maps'; +import React, { + ForwardedRef, + forwardRef, + useEffect, + useImperativeHandle, + useState +} from 'react'; +import {useCallbackRef, useDeepCompareEffect} from '../utility-hooks'; + +import './map-3d-types'; + +import MarkerCustomPin from '../markers/marker-custom-pin'; +import SvgMarker from '../markers/svg-marker'; +import Model3D from '../markers/model-3d'; + +import markerConfig from '../../config/marker-config.json'; + +type MarkerConfig = Record< + string, + { + markerOptions: google.maps.maps3d.Marker3DElementOptions; + pinOptions?: google.maps.marker.PinElementOptions; + } +>; + +const {basic, basicExtruded, basicColored, customLogo, model3d, svg} = + markerConfig as MarkerConfig; + +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(); + + 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 ( + + + + + + + + + ); + } +); diff --git a/examples/map-3d-markers/src/markers/marker-custom-pin.tsx b/examples/map-3d-markers/src/markers/marker-custom-pin.tsx new file mode 100644 index 00000000..d3096a5b --- /dev/null +++ b/examples/map-3d-markers/src/markers/marker-custom-pin.tsx @@ -0,0 +1,35 @@ +import React, {FunctionComponent, useRef, useEffect} from 'react'; + +interface Props { + markerOptions: google.maps.maps3d.Marker3DElementOptions; + pinOptions?: google.maps.marker.PinElementOptions; +} + +const MarkerCustomPin: FunctionComponent = ({ + markerOptions, + pinOptions +}) => { + const ref = useRef(null); + + useEffect(() => { + const parent = ref.current?.parentElement; + if (parent) { + const marker = new google.maps.maps3d.Marker3DElement(markerOptions); + + if (pinOptions) { + const pin = new google.maps.marker.PinElement({ + ...pinOptions, + glyph: pinOptions.glyph + ? new URL(pinOptions.glyph as string) + : undefined + }); + marker.append(pin); + } + parent.appendChild(marker); + } + }, [markerOptions, pinOptions]); + + return
; +}; + +export default MarkerCustomPin; diff --git a/examples/map-3d-markers/src/markers/model-3d.tsx b/examples/map-3d-markers/src/markers/model-3d.tsx new file mode 100644 index 00000000..26f55b1d --- /dev/null +++ b/examples/map-3d-markers/src/markers/model-3d.tsx @@ -0,0 +1,28 @@ +import React, {FunctionComponent, useEffect, useRef} from 'react'; + +interface Props { + markerOptions: google.maps.maps3d.Marker3DElementOptions; + url: URL; +} + +const Model3D: FunctionComponent = ({markerOptions, url}) => { + const ref = useRef(null); + + useEffect(() => { + const parent = ref.current?.parentElement; + if (parent) { + const model3dElement = new google.maps.maps3d.Model3DElement({ + src: url, + orientation: {heading: 0, tilt: 0, roll: 0}, + scale: 10, + ...markerOptions + }); + + parent.appendChild(model3dElement); + } + }); + + return
; +}; + +export default Model3D; diff --git a/examples/map-3d-markers/src/markers/svg-marker.tsx b/examples/map-3d-markers/src/markers/svg-marker.tsx new file mode 100644 index 00000000..e28be9ce --- /dev/null +++ b/examples/map-3d-markers/src/markers/svg-marker.tsx @@ -0,0 +1,30 @@ +import React, {FunctionComponent, useEffect, useRef} from 'react'; + +interface Props { + markerOptions: google.maps.maps3d.Marker3DElementOptions; + svgUrl: string; +} + +const SvgMarker: FunctionComponent = ({markerOptions, svgUrl}) => { + const ref = useRef(null); + + useEffect(() => { + const parent = ref.current?.parentElement; + if (parent) { + const marker = new google.maps.maps3d.Marker3DElement(markerOptions); + + const img = document.createElement('img'); + img.src = svgUrl; + + const template = document.createElement('template'); + template.content.append(img); + + marker.append(template); + parent.appendChild(marker); + } + }); + + return
; +}; + +export default SvgMarker; diff --git a/examples/map-3d-markers/src/utility-hooks.ts b/examples/map-3d-markers/src/utility-hooks.ts new file mode 100644 index 00000000..048e1c66 --- /dev/null +++ b/examples/map-3d-markers/src/utility-hooks.ts @@ -0,0 +1,53 @@ +import { + DependencyList, + EffectCallback, + Ref, + useCallback, + useEffect, + useRef, + useState +} from 'react'; +import isDeepEqual from 'fast-deep-equal'; + +export function useCallbackRef() { + const [el, setEl] = useState(null); + const ref = useCallback((value: T) => setEl(value), [setEl]); + + return [el, ref as Ref] as const; +} + +export function useDeepCompareEffect( + effect: EffectCallback, + deps: DependencyList +) { + const ref = useRef(undefined); + + if (!ref.current || !isDeepEqual(deps, ref.current)) { + ref.current = deps; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(effect, ref.current); +} + +export function useDebouncedEffect( + effect: EffectCallback, + timeout: number, + deps: DependencyList +) { + const timerRef = useRef(0); + + useEffect( + () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = 0; + } + + timerRef.current = setTimeout(() => effect(), timeout); + return () => clearTimeout(timerRef.current); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [timeout, ...deps] + ); +} diff --git a/examples/map-3d-markers/tsconfig.json b/examples/map-3d-markers/tsconfig.json new file mode 100644 index 00000000..ac61102d --- /dev/null +++ b/examples/map-3d-markers/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "typeRoots": ["./types", "../../types", "./node_modules/@types"], + "strict": true, + "sourceMap": true, + "noEmit": true, + "noImplicitAny": true, + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "target": "ES2020", + "lib": ["es2020", "dom"], + "jsx": "react", + "skipLibCheck": true + }, + "exclude": ["./dist", "./node_modules"], + "include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*.ts"] +} diff --git a/examples/map-3d-markers/types/global.d.ts b/examples/map-3d-markers/types/global.d.ts new file mode 100644 index 00000000..b65b2bbc --- /dev/null +++ b/examples/map-3d-markers/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/examples/map-3d-markers/types/google.maps.d.ts b/examples/map-3d-markers/types/google.maps.d.ts new file mode 100644 index 00000000..e1272e6f --- /dev/null +++ b/examples/map-3d-markers/types/google.maps.d.ts @@ -0,0 +1,59 @@ +declare namespace google.maps.maps3d { + // + // --- + // + + export class Model3DElement + extends HTMLElement + implements Model3DElementOptions + { + constructor(options?: Model3DElementOptions); + + position: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + src: string | URL; + + altitudeMode?: google.maps.maps3d.AltitudeMode; + orientation?: google.maps.Orientation3D | google.maps.Orientation3DLiteral; + scale?: number | google.maps.Vector3D | google.maps.Vector3DLiteral; + } + + export interface Model3DElementOptions { + position: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + src: string | URL; + + altitudeMode?: google.maps.maps3d.AltitudeMode; + orientation?: google.maps.Orientation3D | google.maps.Orientation3DLiteral; + scale?: number | google.maps.Vector3D | google.maps.Vector3DLiteral; + } + + // + // --- + // + + export class Marker3DElement + extends HTMLElement + implements Marker3DElementOptions + { + constructor(options?: Marker3DElementOptions); + + position: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + altitudeMode?: google.maps.maps3d.AltitudeMode; + collisionBehavior?: google.maps.CollisionBehavior; + extruded?: boolean; + drawsWhenOccluded?: boolean; + label?: string; + sizePreserved?: boolean; + zIndex?: number; + } + + export interface Marker3DElementOptions { + position: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + altitudeMode?: google.maps.maps3d.AltitudeMode; + collisionBehavior?: google.maps.CollisionBehavior; + extruded?: boolean; + drawsWhenOccluded?: boolean; + label?: string; + sizePreserved?: boolean; + zIndex?: number; + } +} diff --git a/examples/map-3d-markers/vite.config.js b/examples/map-3d-markers/vite.config.js new file mode 100644 index 00000000..522c6cb9 --- /dev/null +++ b/examples/map-3d-markers/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' + } + } + }; +});