diff --git a/examples/website/google-map-3d-overlay-prototype/app.js b/examples/website/google-map-3d-overlay-prototype/app.js
new file mode 100644
index 00000000000..d16a5781d74
--- /dev/null
+++ b/examples/website/google-map-3d-overlay-prototype/app.js
@@ -0,0 +1,485 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+/* global document, window */
+import {COORDINATE_SYSTEM} from '@deck.gl/core';
+import {GoogleMapsOverlay} from '@deck.gl/google-maps';
+import {PathLayer, ScatterplotLayer, TextLayer} from '@deck.gl/layers';
+import {createNativeMap3DEditor} from './map3d-native-editor';
+
+const GOOGLE_MAPS_API_KEY = process.env.GoogleMapsAPIKey; // eslint-disable-line
+const GOOGLE_MAP_ID = process.env.GoogleMapsMapId; // eslint-disable-line
+
+const INITIAL_PATH = [
+ {lng: -73.98513, lat: 40.7589, altitude: 0},
+ {lng: -73.98468, lat: 40.76032, altitude: 0},
+ {lng: -73.98385, lat: 40.76179, altitude: 0},
+ {lng: -73.98258, lat: 40.76292, altitude: 0},
+ {lng: -73.98123, lat: 40.76407, altitude: 0},
+ {lng: -73.98004, lat: 40.76542, altitude: 0},
+ {lng: -73.97868, lat: 40.76671, altitude: 0},
+ {lng: -73.9772, lat: 40.76802, altitude: 0}
+];
+
+const INITIAL_POLYGON = [
+ {lng: -73.98466, lat: 40.76233, altitude: 0},
+ {lng: -73.98265, lat: 40.7636, altitude: 0},
+ {lng: -73.98172, lat: 40.76251, altitude: 0},
+ {lng: -73.98343, lat: 40.7614, altitude: 0}
+];
+
+const INITIAL_POINTS = [
+ {lng: -73.98513, lat: 40.7589, altitude: 0},
+ {lng: -73.9772, lat: 40.76802, altitude: 0}
+];
+
+const demoState = {
+ deckDepthMode: 'screen',
+ deckFallbackMode: 'geospatial',
+ editorState: {
+ mode: 'path',
+ path: INITIAL_PATH,
+ points: INITIAL_POINTS,
+ polygon: INITIAL_POLYGON,
+ selected: null
+ },
+ message: '',
+ showDeckDebug: true
+};
+const DECK_DEBUG_COLORS = {
+ mesh: {
+ fill: [255, 245, 0, 245],
+ path: [255, 245, 0, 220],
+ textBackground: [92, 58, 0, 230]
+ },
+ screen: {
+ fill: [24, 255, 116, 245],
+ path: [24, 255, 116, 220],
+ textBackground: [12, 80, 38, 230]
+ }
+};
+
+export async function renderToDOM(container) {
+ if (!GOOGLE_MAPS_API_KEY) {
+ renderError(container, 'Missing GoogleMapsAPIKey. Set it before starting Vite.');
+ return {remove: () => {}};
+ }
+
+ window.gm_authFailure = () => {
+ renderError(
+ container,
+ `Google Maps rejected this API key for the standalone dev origin. Allow ${window.location.origin}/* in the key referrers, then reload.`
+ );
+ };
+
+ container.style.position = 'relative';
+ const panel = createPanel();
+ container.appendChild(panel);
+
+ const overlay = new GoogleMapsOverlay({
+ interleaved: true,
+ map3DDepthMode: demoState.deckDepthMode,
+ map3DFallbackMode: demoState.deckFallbackMode,
+ layers: []
+ });
+
+ const maps3d = await loadMaps3D();
+ const {Map3DElement, MapMode} = maps3d;
+
+ const map = new Map3DElement({
+ center: {lat: 40.7631, lng: -73.9817, altitude: 0},
+ range: 1450,
+ tilt: 66,
+ heading: 32,
+ fov: 45,
+ mode: MapMode.SATELLITE,
+ defaultUIHidden: false,
+ ...(GOOGLE_MAP_ID && {mapId: GOOGLE_MAP_ID})
+ });
+
+ map.addEventListener('gmp-error', event => {
+ renderError(container, `Map3D error: ${event.error?.message || event.type}`);
+ });
+ map.addEventListener('gmp-map-id-error', event => {
+ updatePanel(panel, map, overlay, `Map ID warning: ${event.type}`);
+ });
+
+ container.appendChild(map);
+ overlay.setMap(map);
+ setDeckLayers(overlay, map);
+
+ const editor = createNativeMap3DEditor({
+ map,
+ maps3d,
+ path: INITIAL_PATH,
+ points: INITIAL_POINTS,
+ polygon: INITIAL_POLYGON,
+ onChange: editorState => {
+ demoState.editorState = editorState;
+ setDeckLayers(overlay, map);
+ updatePanel(panel, map, overlay);
+ }
+ });
+
+ const onCameraChange = () => updatePanel(panel, map, overlay);
+ for (const eventName of [
+ 'gmp-centerchange',
+ 'gmp-rangechange',
+ 'gmp-headingchange',
+ 'gmp-tiltchange',
+ 'gmp-fovchange',
+ 'gmp-steadychange'
+ ]) {
+ map.addEventListener(eventName, onCameraChange);
+ }
+ updatePanel(panel, map, overlay);
+
+ const removePanelActions = bindPanelActions(panel, map, overlay, editor);
+
+ return {
+ remove: () => {
+ removePanelActions();
+ editor.destroy();
+ overlay.finalize();
+ }
+ };
+}
+
+function bindPanelActions(panel, map, overlay, editor) {
+ panel.querySelector('[data-action="spin"]').addEventListener('click', () => {
+ map.heading = ((map.heading || 0) + 45) % 360;
+ });
+ panel.querySelector('[data-action="lower"]').addEventListener('click', () => {
+ map.range = Math.max(350, (map.range || 1450) * 0.75);
+ });
+ panel.querySelector('[data-action="raise"]').addEventListener('click', () => {
+ map.range = Math.min(4000, (map.range || 1450) * 1.3);
+ });
+ panel.querySelector('[data-action="toggle-deck"]').addEventListener('click', () => {
+ demoState.showDeckDebug = !demoState.showDeckDebug;
+ setDeckLayers(overlay, map);
+ updateDeckButtons(panel);
+ updatePanel(panel, map, overlay);
+ });
+ panel.querySelector('[data-action="toggle-deck-depth"]').addEventListener('click', () => {
+ demoState.deckDepthMode = demoState.deckDepthMode === 'screen' ? 'mesh' : 'screen';
+ overlay.setProps({
+ map3DDepthMode: demoState.deckDepthMode,
+ layers: makeLayers(demoState, map, overlay)
+ });
+ updateDeckButtons(panel);
+ updatePanel(panel, map, overlay);
+ });
+ for (const modeButton of panel.querySelectorAll('[data-mode]')) {
+ modeButton.addEventListener('click', () => {
+ editor.setMode(modeButton.dataset.mode);
+ updateModeButtons(panel);
+ });
+ }
+ panel.querySelector('[data-action="delete"]').addEventListener('click', () => {
+ demoState.message = editor.deleteSelected()
+ ? 'Deleted selected vertex'
+ : 'Select a handle first';
+ updatePanel(panel, map, overlay);
+ });
+ panel.querySelector('[data-action="move"]').addEventListener('click', () => {
+ demoState.message = editor.moveSelectedToNextClick()
+ ? 'Click the map to move selected vertex'
+ : 'Select a handle first';
+ updatePanel(panel, map, overlay);
+ });
+ panel.querySelector('[data-action="undo"]').addEventListener('click', () => {
+ demoState.message = editor.undoLast() ? 'Removed last active-mode point' : 'Nothing to undo';
+ updatePanel(panel, map, overlay);
+ });
+ panel.querySelector('[data-action="reset"]').addEventListener('click', () => {
+ editor.reset();
+ demoState.message = 'Editor reset';
+ updatePanel(panel, map, overlay);
+ });
+ panel.querySelector('[data-action="copy"]').addEventListener('click', async () => {
+ const geojson = await editor.copyGeoJSON();
+ demoState.message = `GeoJSON ready (${geojson.length} chars)`;
+ updatePanel(panel, map, overlay);
+ });
+
+ const onKeyDown = event => {
+ if (event.key === 'Delete' || event.key === 'Backspace') {
+ if (editor.deleteSelected()) {
+ event.preventDefault();
+ }
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ updateModeButtons(panel);
+ updateDeckButtons(panel);
+
+ return () => window.removeEventListener('keydown', onKeyDown);
+}
+
+function makeLayers({deckDepthMode, deckFallbackMode, editorState, showDeckDebug}, map, overlay) {
+ if (!showDeckDebug) {
+ return [];
+ }
+
+ const screenFallback = deckFallbackMode === 'screen' && !overlay?._map3DGL;
+ const colors = DECK_DEBUG_COLORS[deckDepthMode];
+ const parameters = getDeckDebugParameters(deckDepthMode);
+ if (screenFallback) {
+ return makeScreenDiagnosticLayers(map, colors, parameters);
+ }
+
+ const path = editorState.path.map(toDeckPosition);
+ const points = [
+ {name: 'Deck Start', position: path[0]},
+ {name: 'Deck Finish', position: path[path.length - 1]}
+ ].filter(point => point.position);
+
+ return [
+ new PathLayer({
+ id: 'deck-route',
+ data: [{path}],
+ parameters,
+ getPath: d => d.path,
+ getColor: colors.path,
+ getWidth: 9,
+ widthUnits: 'pixels',
+ capRounded: true,
+ jointRounded: true,
+ pickable: true
+ }),
+ new ScatterplotLayer({
+ id: 'deck-route-points',
+ data: points,
+ parameters,
+ getPosition: d => d.position,
+ getRadius: 20,
+ radiusUnits: 'pixels',
+ getFillColor: colors.fill,
+ getLineColor: [15, 23, 42, 255],
+ getLineWidth: 4,
+ lineWidthUnits: 'pixels',
+ stroked: true,
+ pickable: true
+ }),
+ new TextLayer({
+ id: 'deck-labels',
+ data: points,
+ parameters,
+ getPosition: d => d.position,
+ getText: d => d.name,
+ getSize: 14,
+ getColor: [255, 255, 255, 255],
+ getPixelOffset: [0, -30],
+ getTextAnchor: 'middle',
+ getAlignmentBaseline: 'bottom',
+ background: true,
+ getBackgroundColor: colors.textBackground,
+ backgroundPadding: [5, 3]
+ })
+ ];
+}
+
+function makeScreenDiagnosticLayers(map, colors, parameters) {
+ const path = getScreenDiagnosticPath(map);
+ const points = [{position: path[0]}, {position: path[path.length - 1]}];
+ const label = [
+ {
+ name: 'Deck canvas',
+ position: [(path[0][0] + path[1][0]) / 2, path[0][1], 0]
+ }
+ ];
+
+ return [
+ new PathLayer({
+ id: 'deck-screen-diagnostic-line',
+ data: [{path}],
+ parameters,
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ getPath: d => d.path,
+ getColor: colors.path,
+ getWidth: 5,
+ widthUnits: 'pixels',
+ capRounded: true
+ }),
+ new ScatterplotLayer({
+ id: 'deck-screen-diagnostic-points',
+ data: points,
+ parameters,
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ getPosition: d => d.position,
+ getRadius: 7,
+ radiusUnits: 'pixels',
+ getFillColor: colors.fill,
+ getLineColor: [15, 23, 42, 255],
+ getLineWidth: 2,
+ lineWidthUnits: 'pixels',
+ stroked: true
+ }),
+ new TextLayer({
+ id: 'deck-screen-diagnostic-labels',
+ data: label,
+ parameters,
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ getPosition: d => d.position,
+ getText: d => d.name,
+ getSize: 12,
+ getColor: [255, 255, 255, 255],
+ getPixelOffset: [0, -18],
+ getTextAnchor: 'middle',
+ getAlignmentBaseline: 'bottom',
+ background: true,
+ getBackgroundColor: colors.textBackground,
+ backgroundPadding: [4, 2]
+ })
+ ];
+}
+
+function createPanel() {
+ const panel = document.createElement('div');
+ panel.className = 'panel';
+ panel.innerHTML = `
+ Map3D + GoogleMapsOverlay prototype
+
Booting...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ return panel;
+}
+
+function updatePanel(panel, map, overlay, extra = '') {
+ const center = normalizeCenter(map.center);
+ const {deckDepthMode, deckFallbackMode, editorState, showDeckDebug} = demoState;
+ const renderMode = overlay._map3DGL ? 'shared WebGL captured' : 'DOM overlay fallback';
+ const selected = editorState.selected
+ ? `${editorState.selected.type} ${editorState.selected.index + 1}`
+ : 'none';
+ const moveStatus = editorState.moveSelectedOnNextClick ? ', move armed' : '';
+ panel.querySelector('[data-status]').innerHTML = `
+ ${renderMode}
+ ${getGeometryStatus(deckDepthMode, deckFallbackMode, overlay, showDeckDebug)}
+ mode ${editorState.mode}, path ${editorState.path.length}, polygon ${editorState.polygon.length}, points ${editorState.points.length}
+ selected ${selected}${moveStatus}
+ lat ${center.lat.toFixed(5)}, lng ${center.lng.toFixed(5)}
+ range ${Math.round(map.range || 0)}m, heading ${Math.round(map.heading || 0)} deg, tilt ${Math.round(map.tilt || 0)} deg
+ ${demoState.message ? `${demoState.message}
` : ''}
+ If Google shows "Oops", allow this origin in the Maps key referrers.
+ ${extra ? `${extra}
` : ''}
+ `;
+}
+
+function updateModeButtons(panel) {
+ for (const modeButton of panel.querySelectorAll('[data-mode]')) {
+ modeButton.classList.toggle('active', modeButton.dataset.mode === demoState.editorState.mode);
+ }
+}
+
+function updateDeckButtons(panel) {
+ const toggleDeckButton = panel.querySelector('[data-action="toggle-deck"]');
+ const toggleDepthButton = panel.querySelector('[data-action="toggle-deck-depth"]');
+ toggleDeckButton.textContent = demoState.showDeckDebug
+ ? 'Hide Deck Diagnostic'
+ : 'Show Deck Diagnostic';
+ toggleDepthButton.textContent = `Deck: ${getDeckDepthLabel(demoState.deckDepthMode)}`;
+ toggleDepthButton.classList.toggle('active', demoState.deckDepthMode === 'mesh');
+}
+
+function getDeckDebugParameters(deckDepthMode) {
+ return deckDepthMode === 'mesh'
+ ? {depthMask: false, depthTest: true}
+ : {depthMask: false, depthTest: false};
+}
+
+function getDeckDepthLabel(deckDepthMode) {
+ return deckDepthMode === 'mesh' ? 'Mesh Depth' : 'Screen';
+}
+
+function getDeckDepthStatus(deckDepthMode, deckFallbackMode, overlay) {
+ if (deckDepthMode === 'mesh') {
+ return overlay._map3DGL
+ ? 'deck mesh-depth debug'
+ : `deck mesh-depth requested, ${deckFallbackMode} diagnostic fallback`;
+ }
+ return overlay._map3DGL ? 'deck screen debug' : `${deckFallbackMode} deck diagnostic fallback`;
+}
+
+function getGeometryStatus(deckDepthMode, deckFallbackMode, overlay, showDeckDebug) {
+ if (showDeckDebug) {
+ return `native editor + ${getDeckDepthStatus(deckDepthMode, deckFallbackMode, overlay)}`;
+ }
+
+ return `native editor locked to Map3D surface${
+ overlay._map3DGL ? '' : ', Deck diagnostic hidden'
+ }`;
+}
+
+function setDeckLayers(overlay, map) {
+ overlay.setProps({layers: makeLayers(demoState, map, overlay)});
+}
+
+function normalizeCenter(center) {
+ if (!center) {
+ return {lat: 0, lng: 0};
+ }
+ const value = typeof center.toJSON === 'function' ? center.toJSON() : center;
+ return {
+ lat: Number(value.lat || 0),
+ lng: Number(value.lng || 0)
+ };
+}
+
+function toDeckPosition({lng, lat, altitude = 0}) {
+ return [lng, lat, altitude];
+}
+
+function getScreenDiagnosticPath(map) {
+ const rect = map?.getBoundingClientRect?.() || {width: 800, height: 600};
+ const width = rect.width || map?.clientWidth || 800;
+ const height = rect.height || map?.clientHeight || 600;
+ const margin = 24;
+ const length = Math.min(140, width * 0.28);
+ const endX = Math.max(margin + length, width - margin);
+ const startX = endX - length;
+ const y = Math.max(margin, Math.min(Math.max(430, height * 0.72), height - 120));
+
+ return [
+ [startX, y, 0],
+ [endX, y, 0]
+ ];
+}
+
+async function loadMaps3D() {
+ if (!window.google?.maps?.importLibrary) {
+ await new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&v=alpha&libraries=maps3d`;
+ script.async = true;
+ script.onerror = () => reject(new Error('Failed to load Google Maps JavaScript API'));
+ script.onload = resolve;
+ document.head.appendChild(script);
+ });
+ }
+ return window.google.maps.importLibrary('maps3d');
+}
+
+function renderError(container, message) {
+ container.innerHTML = `${message}
`;
+}
diff --git a/examples/website/google-map-3d-overlay-prototype/index.html b/examples/website/google-map-3d-overlay-prototype/index.html
new file mode 100644
index 00000000000..014423697d8
--- /dev/null
+++ b/examples/website/google-map-3d-overlay-prototype/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+ Google Map3D deck.gl overlay prototype
+
+
+
+
+
+
+
diff --git a/examples/website/google-map-3d-overlay-prototype/map3d-native-editor.js b/examples/website/google-map-3d-overlay-prototype/map3d-native-editor.js
new file mode 100644
index 00000000000..7faa0cd9df3
--- /dev/null
+++ b/examples/website/google-map-3d-overlay-prototype/map3d-native-editor.js
@@ -0,0 +1,270 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+/* global navigator */
+
+import {createMap3DEditorState, normalizeMap3DCoordinate} from '@deck.gl/google-maps';
+
+const PATH_STYLE = {
+ drawsOccludedSegments: true,
+ strokeColor: '#ff7a00',
+ strokeWidth: 8
+};
+
+const POLYGON_STYLE = {
+ drawsOccludedSegments: true,
+ fillColor: '#0ea5e955',
+ strokeColor: '#38bdf8',
+ strokeWidth: 4
+};
+
+export function createNativeMap3DEditor({map, maps3d, path, polygon, points, onChange}) {
+ const constructors = getMap3DConstructors(maps3d);
+ const editorState = createMap3DEditorState({path, points, polygon});
+ const {routeElement, polygonElement} = createGeometryElements(constructors);
+ const handles = [];
+ let moveSelectedOnNextClick = false;
+
+ addGeometryEventListeners(routeElement, polygonElement, insertPathVertex, insertPolygonVertex);
+ const mapClick = installMapElements(map, routeElement, polygonElement, handleMapPosition);
+
+ render();
+
+ return {
+ get mode() {
+ return getSnapshot().mode;
+ },
+ setMode,
+ deleteSelected,
+ destroy,
+ moveSelectedToNextClick,
+ reset,
+ undoLast,
+ getSnapshot,
+ copyGeoJSON
+ };
+
+ function setMode(mode) {
+ moveSelectedOnNextClick = false;
+ editorState.setMode(mode);
+ render();
+ }
+
+ function handleMapPosition(position) {
+ if (moveSelectedOnNextClick) {
+ const {changed} = editorState.moveSelected(position);
+ moveSelectedOnNextClick = false;
+ render();
+ return changed;
+ }
+ appendPosition(position);
+ return true;
+ }
+
+ function appendPosition(position) {
+ editorState.appendPosition(position);
+ render();
+ }
+
+ function insertPathVertex(position) {
+ editorState.insertPathVertex(position);
+ render();
+ }
+
+ function insertPolygonVertex(position) {
+ editorState.insertPolygonVertex(position);
+ render();
+ }
+
+ function deleteSelected() {
+ const {changed} = editorState.deleteSelected();
+ moveSelectedOnNextClick = false;
+ render();
+ return changed;
+ }
+
+ function undoLast() {
+ const {changed} = editorState.undoLast();
+ render();
+ return changed;
+ }
+
+ function moveSelectedToNextClick() {
+ const hasSelection = Boolean(getSnapshot().selected);
+ moveSelectedOnNextClick = hasSelection;
+ render();
+ return hasSelection;
+ }
+
+ function reset() {
+ moveSelectedOnNextClick = false;
+ editorState.reset();
+ render();
+ }
+
+ function destroy() {
+ map.removeEventListener('gmp-click', mapClick);
+ routeElement.remove();
+ polygonElement.remove();
+ clearHandles();
+ }
+
+ async function copyGeoJSON() {
+ const text = JSON.stringify(getSnapshot().geojson, null, 2);
+ try {
+ await navigator.clipboard?.writeText(text);
+ } catch {
+ // Clipboard is best-effort on local HTTP/dev origins.
+ }
+ return text;
+ }
+
+ function getSnapshot() {
+ return {...editorState.getSnapshot(), moveSelectedOnNextClick};
+ }
+
+ function render() {
+ const snapshot = getSnapshot();
+ setElementPath(routeElement, snapshot.path);
+ setElementPath(polygonElement, snapshot.polygon.length >= 3 ? snapshot.polygon : []);
+ renderHandles();
+ onChange?.(snapshot);
+ }
+
+ function renderHandles() {
+ const snapshot = getSnapshot();
+ clearHandles();
+ for (const [type, coordinates] of [
+ ['path', snapshot.path],
+ ['polygon', snapshot.polygon],
+ ['point', snapshot.points]
+ ]) {
+ const isActiveMode = snapshot.mode === type;
+ coordinates.forEach((position, index) => {
+ const selected = snapshot.selected?.type === type && snapshot.selected.index === index;
+ const marker = new constructors.MarkerElement({
+ altitudeMode: constructors.AltitudeMode.CLAMP_TO_GROUND,
+ collisionPriority: selected ? 1000 : 10,
+ drawsWhenOccluded: true,
+ label: getHandleLabel(type, index, selected),
+ position,
+ sizePreserved: true,
+ zIndex: isActiveMode || selected ? 20 : 5
+ });
+ marker.addEventListener('gmp-click', event => {
+ stopEvent(event);
+ editorState.select({type, index});
+ render();
+ });
+ map.appendChild(marker);
+ handles.push(marker);
+ });
+ }
+ }
+
+ function clearHandles() {
+ while (handles.length) {
+ handles.pop().remove();
+ }
+ }
+}
+
+function installMapElements(map, routeElement, polygonElement, handleMapPosition) {
+ const mapClick = createMapClickHandler(handleMapPosition);
+ map.addEventListener('gmp-click', mapClick);
+ map.append(routeElement, polygonElement);
+ return mapClick;
+}
+
+function addGeometryEventListeners(
+ routeElement,
+ polygonElement,
+ insertPathVertex,
+ insertPolygonVertex
+) {
+ routeElement.addEventListener('gmp-click', event => {
+ const position = getEventPosition(event);
+ stopEvent(event);
+ if (position) {
+ insertPathVertex(position);
+ }
+ });
+ polygonElement.addEventListener('gmp-click', event => {
+ const position = getEventPosition(event);
+ stopEvent(event);
+ if (position) {
+ insertPolygonVertex(position);
+ }
+ });
+}
+
+function createMapClickHandler(appendPosition) {
+ return event => {
+ const position = getEventPosition(event);
+ if (!position) {
+ return;
+ }
+ event.preventDefault?.();
+ appendPosition(position);
+ };
+}
+
+function getMap3DConstructors(maps3d) {
+ const {
+ AltitudeMode,
+ Marker3DInteractiveElement,
+ Marker3DElement,
+ Polyline3DInteractiveElement,
+ Polyline3DElement,
+ Polygon3DInteractiveElement,
+ Polygon3DElement
+ } = maps3d;
+
+ return {
+ AltitudeMode,
+ MarkerElement: Marker3DInteractiveElement || Marker3DElement,
+ PolylineElement: Polyline3DInteractiveElement || Polyline3DElement,
+ PolygonElement: Polygon3DInteractiveElement || Polygon3DElement
+ };
+}
+
+function createGeometryElements({AltitudeMode, PolylineElement, PolygonElement}) {
+ return {
+ routeElement: new PolylineElement({
+ altitudeMode: AltitudeMode.CLAMP_TO_GROUND,
+ ...PATH_STYLE
+ }),
+ polygonElement: new PolygonElement({
+ altitudeMode: AltitudeMode.CLAMP_TO_GROUND,
+ ...POLYGON_STYLE
+ })
+ };
+}
+
+function setElementPath(element, coordinates) {
+ element.path = coordinates.map(toLatLngAltitudeLiteral);
+ element.setAttribute('path', coordinates.map(toPathToken).join(' '));
+}
+
+function getEventPosition(event) {
+ return normalizeMap3DCoordinate(event.position);
+}
+
+function getHandleLabel(type, index, selected) {
+ const prefix = type === 'point' ? 'P' : String(index + 1);
+ return selected ? `*${prefix}` : prefix;
+}
+
+function stopEvent(event) {
+ event.preventDefault?.();
+ event.stopPropagation?.();
+}
+
+function toLatLngAltitudeLiteral({lat, lng, altitude = 0}) {
+ return {lat, lng, altitude};
+}
+
+function toPathToken({lat, lng, altitude = 0}) {
+ return `${lat},${lng},${altitude}`;
+}
diff --git a/examples/website/google-map-3d-overlay-prototype/package.json b/examples/website/google-map-3d-overlay-prototype/package.json
new file mode 100644
index 00000000000..ca4221e85e8
--- /dev/null
+++ b/examples/website/google-map-3d-overlay-prototype/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "deckgl-example-google-map-3d-overlay-prototype",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "scripts": {
+ "start": "vite --open",
+ "start-local": "vite --config ../../vite.config.local.mjs",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@deck.gl/core": "^9.0.0",
+ "@deck.gl/google-maps": "^9.0.0",
+ "@deck.gl/layers": "^9.0.0",
+ "@deck.gl/widgets": "^9.0.0",
+ "vite": "^7.3.3"
+ },
+ "devDependencies": {}
+}
diff --git a/examples/website/google-map-3d-overlay-prototype/vite.config.js b/examples/website/google-map-3d-overlay-prototype/vite.config.js
new file mode 100644
index 00000000000..0499a6b613b
--- /dev/null
+++ b/examples/website/google-map-3d-overlay-prototype/vite.config.js
@@ -0,0 +1,21 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {fileURLToPath, URL} from 'node:url';
+
+const fromRoot = path => fileURLToPath(new URL(`../../../${path}`, import.meta.url));
+
+export default {
+ define: {
+ 'process.env.GoogleMapsAPIKey': JSON.stringify(process.env.GoogleMapsAPIKey),
+ 'process.env.GoogleMapsMapId': JSON.stringify(process.env.GoogleMapsMapId)
+ },
+ resolve: {
+ alias: [
+ {find: /^@deck\.gl\/core$/, replacement: fromRoot('modules/core/src/index.ts')},
+ {find: /^@deck\.gl\/google-maps$/, replacement: fromRoot('modules/google-maps/src/index.ts')},
+ {find: /^@deck\.gl\/layers$/, replacement: fromRoot('modules/layers/src/index.ts')}
+ ]
+ }
+};
diff --git a/modules/google-maps/src/google-maps-overlay.ts b/modules/google-maps/src/google-maps-overlay.ts
index bd2f357b1ad..8eea558674f 100644
--- a/modules/google-maps/src/google-maps-overlay.ts
+++ b/modules/google-maps/src/google-maps-overlay.ts
@@ -7,32 +7,68 @@ import type {GLParameters} from '@luma.gl/webgl/constants';
import {GL} from '@luma.gl/webgl/constants';
import {WebGLDevice} from '@luma.gl/webgl';
import {
+ addMap3DCameraChangeListener,
+ captureMap3DWebGLContext,
createDeckInstance,
+ createDeckInstanceForMap3D,
destroyDeckInstance,
+ getScreenViewPropsFromMap3D,
+ getViewPropsFromMap3D,
getViewPropsFromOverlay,
getViewPropsFromCoordinateTransformer,
+ installMap3DWebGLContextCapture,
+ isMap3DElement,
POSITIONING_CONTAINER_ID
} from './utils';
-import {Deck} from '@deck.gl/core';
+import {Deck, log} from '@deck.gl/core';
import type {DeckProps, MapViewState} from '@deck.gl/core';
import type {Device, Framebuffer} from '@luma.gl/core';
+import type {GoogleMapsMap3DElement} from './utils';
const HIDE_ALL_LAYERS = () => false;
-const GL_STATE: GLParameters = {
+const VECTOR_GL_STATE: GLParameters = {
depthMask: true,
depthTest: true,
blend: true,
blendFunc: [GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ONE, GL.ONE_MINUS_SRC_ALPHA],
blendEquation: GL.FUNC_ADD
};
+const MAP3D_SCREEN_GL_STATE: GLParameters = {
+ depthMask: false,
+ depthTest: false,
+ blend: true,
+ blendFunc: [GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ONE, GL.ONE_MINUS_SRC_ALPHA],
+ blendEquation: GL.FUNC_ADD
+};
+const MAP3D_MESH_GL_STATE: GLParameters = {
+ depthMask: false,
+ depthTest: true,
+ depthFunc: GL.LEQUAL,
+ blend: true,
+ blendFunc: [GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ONE, GL.ONE_MINUS_SRC_ALPHA],
+ blendEquation: GL.FUNC_ADD
+};
// eslint-disable-next-line @typescript-eslint/no-empty-function
function noop() {}
+function getDeckAnimationLoop(deck: Deck): DeckAnimationLoop | null {
+ return (deck as unknown as DeckInternal).animationLoop || null;
+}
+
+function getDeckDevice(deck: Deck): Device | null {
+ return (deck as unknown as DeckInternal).device || null;
+}
+
const defaultProps = {
- interleaved: true
+ interleaved: true,
+ map3DDepthMode: 'screen' as GoogleMapsMap3DDepthMode,
+ map3DFallbackMode: 'geospatial' as GoogleMapsMap3DFallbackMode
};
+export type GoogleMapsMap3DDepthMode = 'mesh' | 'screen';
+export type GoogleMapsMap3DFallbackMode = 'geospatial' | 'screen';
+
export type GoogleMapsOverlayProps = Omit<
DeckProps,
| 'width'
@@ -47,34 +83,72 @@ export type GoogleMapsOverlayProps = Omit<
| 'controller'
> & {
interleaved?: boolean;
+ /**
+ * Experimental Map3D-only draw mode.
+ * `screen` draws Deck as a compositor overlay. `mesh` depth-tests Deck against
+ * Google Map3D's depth buffer when a shared WebGL context is available.
+ */
+ map3DDepthMode?: GoogleMapsMap3DDepthMode;
+ /**
+ * Experimental Map3D-only fallback mode used when a shared Google Map3D WebGL
+ * context cannot be captured.
+ * `geospatial` applies approximate Map3D camera math to geospatial Deck layers.
+ * `screen` uses a stable screen-coordinate Deck overlay for diagnostics/widgets.
+ */
+ map3DFallbackMode?: GoogleMapsMap3DFallbackMode;
+};
+
+type GoogleMapsOverlayMap = google.maps.Map | GoogleMapsMap3DElement;
+type ListenerHandle = {
+ remove: () => void;
+};
+type DeckAnimationLoop = {
+ _renderFrame: () => void;
+ animationProps?: unknown;
+ props: {
+ onRender: (props: unknown) => void;
+ };
+};
+type DeckInternal = {
+ animationLoop?: DeckAnimationLoop;
+ device: Device | null;
};
export default class GoogleMapsOverlay {
private props: GoogleMapsOverlayProps = {};
- private _map: google.maps.Map | null = null;
+ private _map: GoogleMapsOverlayMap | null = null;
private _deck: Deck | null = null;
private _overlay: google.maps.WebGLOverlayView | google.maps.OverlayView | null = null;
private _positioningOverlay: google.maps.OverlayView | null = null;
+ private _map3DCameraListener: ListenerHandle | null = null;
+ private _map3DRenderFrame = 0;
+ private _map3DGL: WebGL2RenderingContext | WebGLRenderingContext | null = null;
private _externalFramebuffer: {
handle: WebGLFramebuffer;
wrapper: import('@luma.gl/core').Framebuffer;
} | null = null;
constructor(props: GoogleMapsOverlayProps) {
+ installMap3DWebGLContextCapture();
this.setProps({...defaultProps, ...props});
}
/* Public API */
/** Add/remove the overlay from a map. */
- setMap(map: google.maps.Map | null): void {
+ setMap(map: GoogleMapsOverlayMap | null): void {
if (map === this._map) {
return;
}
- const {VECTOR, UNINITIALIZED} = google.maps.RenderingType;
if (this._map) {
- if (!map && this._map.getRenderingType() === VECTOR && this.props.interleaved) {
+ if (isMap3DElement(this._map)) {
+ this._removeOverlayMap3D();
+ } else if (
+ !map &&
+ this._map.getRenderingType() === google.maps.RenderingType.VECTOR &&
+ this.props.interleaved
+ ) {
(this._overlay as google.maps.WebGLOverlayView).requestRedraw();
}
this._overlay?.setMap(null);
@@ -83,6 +157,12 @@ export default class GoogleMapsOverlay {
}
if (map) {
this._map = map;
+ if (isMap3DElement(map)) {
+ this._createOverlayMap3D(map);
+ return;
+ }
+
+ const {UNINITIALIZED} = google.maps.RenderingType;
const renderingType = map.getRenderingType();
if (renderingType !== UNINITIALIZED) {
this._createOverlay(map);
@@ -107,6 +187,9 @@ export default class GoogleMapsOverlay {
props.style = null;
}
this._deck.setProps(props);
+ if ('map3DDepthMode' in props && this._map && isMap3DElement(this._map)) {
+ this._requestMap3DRedraw();
+ }
}
}
@@ -150,6 +233,10 @@ export default class GoogleMapsOverlay {
}
}
+ _getGoogleMap(): google.maps.Map | null {
+ return this._map && !isMap3DElement(this._map) ? this._map : null;
+ }
+
/**
* Create overlays for vector maps.
* Uses OverlayView for DOM positioning (correct z-index) and
@@ -211,7 +298,7 @@ export default class GoogleMapsOverlay {
_updateContainerSize() {
// Update positioning container size and position to match map
- if (!this._map) return;
+ if (!this._map || isMap3DElement(this._map)) return;
const container = this._map
.getDiv()
@@ -232,7 +319,7 @@ export default class GoogleMapsOverlay {
}
_onContextRestored({gl}) {
- if (!this._map || !this._overlay) {
+ if (!this._map || isMap3DElement(this._map) || !this._overlay) {
return;
}
const _customRender = () => {
@@ -250,15 +337,18 @@ export default class GoogleMapsOverlay {
// By default, animationLoop._renderFrame invokes
// animationLoop.onRender. We override this to wrap
// in withParameters so we don't modify the GL state
- // @ts-ignore accessing protected member
- const animationLoop = deck.animationLoop!;
+ const animationLoop = getDeckAnimationLoop(deck);
+ if (!animationLoop) {
+ return;
+ }
animationLoop._renderFrame = () => {
const ab = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
- // @ts-expect-error accessing protected member
- const device: Device = deck.device;
- device.withParametersWebGL({}, () => {
- animationLoop.props.onRender(animationLoop.animationProps!);
- });
+ const device = getDeckDevice(deck);
+ if (device) {
+ device.withParametersWebGL({}, () => {
+ animationLoop.props.onRender(animationLoop.animationProps);
+ });
+ }
gl.bindBuffer(gl.ARRAY_BUFFER, ab);
};
}
@@ -276,7 +366,7 @@ export default class GoogleMapsOverlay {
}
_onDrawRaster() {
- if (!this._deck || !this._map) {
+ if (!this._deck || !this._map || isMap3DElement(this._map)) {
return;
}
const deck = this._deck;
@@ -298,15 +388,16 @@ export default class GoogleMapsOverlay {
deck.setProps({
width,
height,
- // @ts-expect-error altitude is accepted by WebMercatorViewport but not exposed by type
- viewState: {altitude, ...rest} as MapViewState
+ // altitude is accepted by WebMercatorViewport but not exposed by type
+ viewState: {altitude, ...rest} as unknown as MapViewState
});
// Deck is initialized
deck.redraw();
}
_onDrawVector({gl, transformer}) {
- if (!this._deck || !this._map) {
+ const map = this._getGoogleMap();
+ if (!this._deck || !map) {
return;
}
@@ -314,14 +405,13 @@ export default class GoogleMapsOverlay {
const {interleaved} = this.props;
deck.setProps({
- ...getViewPropsFromCoordinateTransformer(this._map, transformer),
+ ...getViewPropsFromCoordinateTransformer(map, transformer),
// Using external gl context - do not set css size
...(interleaved && {width: null, height: null})
});
if (interleaved && deck.isInitialized) {
- // @ts-expect-error
- const device: Device = deck.device;
+ const device = getDeckDevice(deck);
// As an optimization, some renders are to an separate framebuffer
// which we need to pass onto deck. Wrap external handle so luma.gl
@@ -358,7 +448,7 @@ export default class GoogleMapsOverlay {
stencilFunc: [gl.ALWAYS, 0, 255, gl.ALWAYS, 0, 255]
});
- device.withParametersWebGL(GL_STATE, () => {
+ device.withParametersWebGL(VECTOR_GL_STATE, () => {
deck._drawLayers('google-vector', {
clearCanvas: false
});
@@ -368,4 +458,147 @@ export default class GoogleMapsOverlay {
deck.redraw();
}
}
+
+ _createOverlayMap3D(map: GoogleMapsMap3DElement) {
+ const interleaved = this.props.interleaved ?? defaultProps.interleaved;
+ let gl: WebGL2RenderingContext | WebGLRenderingContext | null = null;
+
+ if (interleaved) {
+ gl = captureMap3DWebGLContext(map);
+ if (!gl) {
+ const meshDepthMessage =
+ this.props.map3DDepthMode === 'mesh'
+ ? ' Mesh-depth mode was requested, but it requires a captured shared Map3D WebGL context.'
+ : '';
+ log.warn(
+ 'deck.gl: GoogleMapsOverlay could not capture the Map3D WebGL canvas. ' +
+ 'Rendering with a non-interleaved Deck overlay instead; this path is approximate ' +
+ `and should not be used for terrain-locked Map3D geometry.${meshDepthMessage}`
+ )();
+ }
+ }
+
+ this._map3DGL = gl;
+ this._deck = createDeckInstanceForMap3D(map, this._deck, {
+ ...(gl && {
+ gl,
+ _customRender: this._requestMap3DRedraw.bind(this)
+ }),
+ ...this.props
+ });
+ if (gl) {
+ this._overrideMap3DRenderFrame(gl);
+ }
+
+ this._map3DCameraListener = addMap3DCameraChangeListener(
+ map,
+ this._requestMap3DRedraw.bind(this),
+ {redrawWhileMoving: Boolean(gl)}
+ );
+ this._onDrawMap3D();
+ }
+
+ _removeOverlayMap3D() {
+ this._map3DCameraListener?.remove();
+ this._map3DCameraListener = null;
+ if (this._map3DRenderFrame && globalThis.cancelAnimationFrame) {
+ globalThis.cancelAnimationFrame(this._map3DRenderFrame);
+ }
+ this._map3DRenderFrame = 0;
+ this._map3DGL = null;
+ this._onRemove();
+ }
+
+ _requestMap3DRedraw() {
+ if (!globalThis.requestAnimationFrame) {
+ this._onDrawMap3D();
+ return;
+ }
+ if (this._map3DRenderFrame) {
+ return;
+ }
+ this._map3DRenderFrame = globalThis.requestAnimationFrame(() => {
+ this._map3DRenderFrame = 0;
+ this._onDrawMap3D();
+ });
+ }
+
+ _overrideMap3DRenderFrame(gl: WebGL2RenderingContext | WebGLRenderingContext) {
+ const deck = this._deck;
+ const animationLoop = deck && getDeckAnimationLoop(deck);
+ if (!deck || !animationLoop) {
+ return;
+ }
+
+ // Match the vector overlay path: do not leave Deck's GL state in Google's renderer.
+ animationLoop._renderFrame = () => {
+ const ab = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
+ const device = getDeckDevice(deck);
+ if (device) {
+ device.withParametersWebGL({}, () => {
+ animationLoop.props.onRender(animationLoop.animationProps);
+ });
+ }
+ gl.bindBuffer(gl.ARRAY_BUFFER, ab);
+ };
+ }
+
+ _onDrawMap3D() {
+ if (!this._deck || !this._map || !isMap3DElement(this._map)) {
+ return;
+ }
+
+ const deck = this._deck;
+ const gl = this._map3DGL;
+ const interleaved = Boolean(gl);
+ const viewProps =
+ !gl && this.props.map3DFallbackMode === 'screen'
+ ? getScreenViewPropsFromMap3D(this._map)
+ : getViewPropsFromMap3D(this._map, {zoomSource: gl ? 'camera' : 'range'});
+ deck.setProps({
+ ...viewProps,
+ ...(interleaved && {width: null, height: null})
+ } as DeckProps);
+
+ if (gl && deck.isInitialized) {
+ const device = getDeckDevice(deck);
+
+ if (device instanceof WebGLDevice) {
+ const externalFbo = device.getParametersWebGL(GL.FRAMEBUFFER_BINDING);
+ let _framebuffer: Framebuffer | null = null;
+ if (externalFbo) {
+ if (this._externalFramebuffer?.handle !== externalFbo) {
+ this._externalFramebuffer?.wrapper.destroy();
+ const wrapper = device.createFramebuffer({
+ handle: externalFbo,
+ width: gl.canvas.width,
+ height: gl.canvas.height
+ });
+ this._externalFramebuffer = {handle: externalFbo, wrapper};
+ }
+ _framebuffer = this._externalFramebuffer!.wrapper;
+ }
+ deck.setProps({_framebuffer});
+
+ deck.needsRedraw({clearRedrawFlags: true});
+ device.setParametersWebGL({
+ viewport: [0, 0, gl.canvas.width, gl.canvas.height],
+ scissor: [0, 0, gl.canvas.width, gl.canvas.height],
+ stencilFunc: [gl.ALWAYS, 0, 255, gl.ALWAYS, 0, 255]
+ });
+
+ device.withParametersWebGL(getMap3DGLState(this.props.map3DDepthMode), () => {
+ deck._drawLayers('google-map-3d', {
+ clearCanvas: false
+ });
+ });
+ }
+ } else {
+ deck.redraw();
+ }
+ }
+}
+
+function getMap3DGLState(depthMode: GoogleMapsMap3DDepthMode | undefined): GLParameters {
+ return depthMode === 'mesh' ? MAP3D_MESH_GL_STATE : MAP3D_SCREEN_GL_STATE;
}
diff --git a/modules/google-maps/src/index.ts b/modules/google-maps/src/index.ts
index 4ac35333ac6..281f35489ac 100644
--- a/modules/google-maps/src/index.ts
+++ b/modules/google-maps/src/index.ts
@@ -3,4 +3,28 @@
// Copyright (c) vis.gl contributors
export {default as GoogleMapsOverlay} from './google-maps-overlay';
-export type {GoogleMapsOverlayProps} from './google-maps-overlay';
+export {
+ createMap3DEditorState,
+ createMap3DGeometryState,
+ getMap3DEditorInsertIndex,
+ normalizeMap3DCoordinate,
+ toMap3DEditorGeoJSON
+} from './map-3d-editor-state';
+export type {
+ GoogleMapsMap3DDepthMode,
+ GoogleMapsMap3DFallbackMode,
+ GoogleMapsOverlayProps
+} from './google-maps-overlay';
+export type {
+ Map3DEditorCoordinate,
+ Map3DEditorCoordinateLike,
+ Map3DEditorFeature,
+ Map3DEditorGeoJSON,
+ Map3DEditorGeometry,
+ Map3DEditorMode,
+ Map3DEditorMutationResult,
+ Map3DEditorSelection,
+ Map3DEditorSnapshot,
+ Map3DEditorState
+} from './map-3d-editor-state';
+export type {GoogleMapsMap3DElement} from './utils';
diff --git a/modules/google-maps/src/map-3d-editor-state.ts b/modules/google-maps/src/map-3d-editor-state.ts
new file mode 100644
index 00000000000..ec299117149
--- /dev/null
+++ b/modules/google-maps/src/map-3d-editor-state.ts
@@ -0,0 +1,370 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+export type Map3DEditorMode = 'path' | 'polygon' | 'point';
+
+export type Map3DEditorCoordinate = {
+ altitude: number;
+ lat: number;
+ lng: number;
+};
+
+export type Map3DEditorCoordinateLike =
+ | Map3DEditorCoordinate
+ | {
+ altitude?: number;
+ lat?: number | (() => number);
+ lng?: number | (() => number);
+ toJSON?: () => {altitude?: number; lat: number; lng: number};
+ };
+
+export type Map3DEditorGeometry = {
+ path?: Map3DEditorCoordinateLike[];
+ points?: Map3DEditorCoordinateLike[];
+ polygon?: Map3DEditorCoordinateLike[];
+};
+
+export type Map3DEditorSelection = {
+ index: number;
+ type: Map3DEditorMode;
+};
+
+export type Map3DEditorFeature =
+ | {
+ type: 'Feature';
+ properties: {mode: 'path'};
+ geometry: {type: 'LineString'; coordinates: number[][]};
+ }
+ | {
+ type: 'Feature';
+ properties: {mode: 'polygon'};
+ geometry: {type: 'Polygon'; coordinates: number[][][]};
+ }
+ | {
+ type: 'Feature';
+ properties: {mode: 'point'};
+ geometry: {type: 'Point'; coordinates: number[]};
+ };
+
+export type Map3DEditorGeoJSON = {
+ type: 'FeatureCollection';
+ features: Map3DEditorFeature[];
+};
+
+export type Map3DEditorSnapshot = {
+ geojson: Map3DEditorGeoJSON;
+ mode: Map3DEditorMode;
+ path: Map3DEditorCoordinate[];
+ points: Map3DEditorCoordinate[];
+ polygon: Map3DEditorCoordinate[];
+ selected: Map3DEditorSelection | null;
+};
+
+export type Map3DEditorMutationResult = {
+ changed: boolean;
+ snapshot: Map3DEditorSnapshot;
+};
+
+export type Map3DEditorState = {
+ appendPosition: (position: Map3DEditorCoordinateLike) => Map3DEditorSnapshot;
+ deleteSelected: () => Map3DEditorMutationResult;
+ getSnapshot: () => Map3DEditorSnapshot;
+ insertPathVertex: (position: Map3DEditorCoordinateLike) => Map3DEditorSnapshot;
+ insertPolygonVertex: (position: Map3DEditorCoordinateLike) => Map3DEditorSnapshot;
+ moveSelected: (position: Map3DEditorCoordinateLike) => Map3DEditorMutationResult;
+ reset: () => Map3DEditorSnapshot;
+ select: (selection: Map3DEditorSelection | null) => Map3DEditorSnapshot;
+ setMode: (mode: Map3DEditorMode) => Map3DEditorSnapshot;
+ undoLast: () => Map3DEditorMutationResult;
+};
+
+const MODES = new Set(['path', 'polygon', 'point']);
+
+export function createMap3DEditorState({
+ path = [],
+ polygon = [],
+ points = []
+}: Map3DEditorGeometry): Map3DEditorState {
+ const initialGeometry = createMap3DGeometryState({path, polygon, points});
+ let state: Omit = {
+ mode: 'path',
+ ...createMap3DGeometryState(initialGeometry),
+ selected: null
+ };
+
+ return {
+ appendPosition,
+ deleteSelected,
+ getSnapshot,
+ insertPathVertex,
+ insertPolygonVertex,
+ moveSelected,
+ reset,
+ select,
+ setMode,
+ undoLast
+ };
+
+ function appendPosition(position: Map3DEditorCoordinateLike): Map3DEditorSnapshot {
+ const normalized = normalizeMap3DCoordinate(position);
+ if (!normalized) {
+ return getSnapshot();
+ }
+
+ const coordinates = getActiveCoordinates(state.mode);
+ coordinates.push(normalized);
+ state.selected = {type: state.mode, index: coordinates.length - 1};
+ return getSnapshot();
+ }
+
+ function deleteSelected(): Map3DEditorMutationResult {
+ if (!state.selected) {
+ return {changed: false, snapshot: getSnapshot()};
+ }
+
+ const coordinates = getActiveCoordinates(state.selected.type);
+ coordinates.splice(state.selected.index, 1);
+ state.selected = null;
+ return {changed: true, snapshot: getSnapshot()};
+ }
+
+ function getSnapshot(): Map3DEditorSnapshot {
+ return {
+ mode: state.mode,
+ path: cloneCoordinates(state.path),
+ points: cloneCoordinates(state.points),
+ polygon: cloneCoordinates(state.polygon),
+ selected: state.selected && {...state.selected},
+ geojson: toMap3DEditorGeoJSON(state)
+ };
+ }
+
+ function insertPathVertex(position: Map3DEditorCoordinateLike): Map3DEditorSnapshot {
+ return insertVertex('path', position);
+ }
+
+ function insertPolygonVertex(position: Map3DEditorCoordinateLike): Map3DEditorSnapshot {
+ return insertVertex('polygon', position);
+ }
+
+ function moveSelected(position: Map3DEditorCoordinateLike): Map3DEditorMutationResult {
+ const normalized = normalizeMap3DCoordinate(position);
+ if (!state.selected || !normalized) {
+ return {changed: false, snapshot: getSnapshot()};
+ }
+
+ const coordinates = getActiveCoordinates(state.selected.type);
+ coordinates[state.selected.index] = normalized;
+ return {changed: true, snapshot: getSnapshot()};
+ }
+
+ function reset(): Map3DEditorSnapshot {
+ state = {
+ mode: state.mode,
+ ...createMap3DGeometryState(initialGeometry),
+ selected: null
+ };
+ return getSnapshot();
+ }
+
+ function select(selection: Map3DEditorSelection | null): Map3DEditorSnapshot {
+ if (!selection || !MODES.has(selection.type)) {
+ state.selected = null;
+ return getSnapshot();
+ }
+
+ const coordinates = getActiveCoordinates(selection.type);
+ state.selected =
+ selection.index >= 0 && selection.index < coordinates.length
+ ? {type: selection.type, index: selection.index}
+ : null;
+ return getSnapshot();
+ }
+
+ function setMode(mode: Map3DEditorMode): Map3DEditorSnapshot {
+ if (!MODES.has(mode)) {
+ return getSnapshot();
+ }
+
+ state.mode = mode;
+ state.selected = null;
+ return getSnapshot();
+ }
+
+ function undoLast(): Map3DEditorMutationResult {
+ const coordinates = getActiveCoordinates(state.mode);
+ if (!coordinates.length) {
+ return {changed: false, snapshot: getSnapshot()};
+ }
+
+ coordinates.pop();
+ state.selected = null;
+ return {changed: true, snapshot: getSnapshot()};
+ }
+
+ function insertVertex(
+ type: Exclude,
+ position: Map3DEditorCoordinateLike
+ ): Map3DEditorSnapshot {
+ const normalized = normalizeMap3DCoordinate(position);
+ if (!normalized) {
+ return getSnapshot();
+ }
+
+ const coordinates = getActiveCoordinates(type);
+ const index = getMap3DEditorInsertIndex(coordinates, normalized);
+ coordinates.splice(index, 0, normalized);
+ state.selected = {type, index};
+ return getSnapshot();
+ }
+
+ function getActiveCoordinates(type: Map3DEditorMode): Map3DEditorCoordinate[] {
+ if (type === 'point') {
+ return state.points;
+ }
+ if (type === 'polygon') {
+ return state.polygon;
+ }
+ return state.path;
+ }
+}
+
+export function createMap3DGeometryState({
+ path = [],
+ polygon = [],
+ points = []
+}: Map3DEditorGeometry): Required> {
+ return {
+ path: normalizeCoordinates(path),
+ points: normalizeCoordinates(points),
+ polygon: normalizeCoordinates(polygon)
+ };
+}
+
+export function getMap3DEditorInsertIndex(
+ path: Map3DEditorCoordinateLike[],
+ position: Map3DEditorCoordinateLike
+): number {
+ const normalizedPosition = normalizeMap3DCoordinate(position);
+ const normalizedPath = normalizeCoordinates(path);
+ if (!normalizedPosition || normalizedPath.length < 2) {
+ return normalizedPath.length;
+ }
+
+ let bestIndex = normalizedPath.length;
+ let bestDistance = Infinity;
+ for (let index = 0; index < normalizedPath.length - 1; index++) {
+ const distance = distanceToSegment(
+ normalizedPosition,
+ normalizedPath[index],
+ normalizedPath[index + 1]
+ );
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ bestIndex = index + 1;
+ }
+ }
+ return bestIndex;
+}
+
+export function normalizeMap3DCoordinate(
+ position?: Map3DEditorCoordinateLike | null
+): Map3DEditorCoordinate | null {
+ if (!position) {
+ return null;
+ }
+ const value = 'toJSON' in position && position.toJSON ? position.toJSON() : position;
+ const lat = Number(typeof value.lat === 'function' ? value.lat() : value.lat);
+ const lng = Number(typeof value.lng === 'function' ? value.lng() : value.lng);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+ return null;
+ }
+ return {
+ altitude: Number(value.altitude || 0),
+ lat,
+ lng
+ };
+}
+
+export function toMap3DEditorGeoJSON({
+ path,
+ polygon,
+ points
+}: Required>): Map3DEditorGeoJSON {
+ const features: Map3DEditorFeature[] = [];
+ if (path.length >= 2) {
+ features.push({
+ type: 'Feature',
+ properties: {mode: 'path'},
+ geometry: {type: 'LineString', coordinates: path.map(toGeoJSONCoordinate)}
+ });
+ }
+ if (polygon.length >= 3) {
+ features.push({
+ type: 'Feature',
+ properties: {mode: 'polygon'},
+ geometry: {type: 'Polygon', coordinates: [closeRing(polygon).map(toGeoJSONCoordinate)]}
+ });
+ }
+ for (const point of points) {
+ features.push({
+ type: 'Feature',
+ properties: {mode: 'point'},
+ geometry: {type: 'Point', coordinates: toGeoJSONCoordinate(point)}
+ });
+ }
+ return {type: 'FeatureCollection', features};
+}
+
+function closeRing(coordinates: Map3DEditorCoordinate[]): Map3DEditorCoordinate[] {
+ const first = coordinates[0];
+ const last = coordinates[coordinates.length - 1];
+ if (first.lat === last.lat && first.lng === last.lng && first.altitude === last.altitude) {
+ return coordinates;
+ }
+ return [...coordinates, first];
+}
+
+function cloneCoordinates(coordinates: Map3DEditorCoordinate[]): Map3DEditorCoordinate[] {
+ return coordinates.map(coordinate => ({...coordinate}));
+}
+
+function distanceToSegment(
+ position: Map3DEditorCoordinate,
+ start: Map3DEditorCoordinate,
+ end: Map3DEditorCoordinate
+): number {
+ const p = projectCoordinate(position, position.lat);
+ const a = projectCoordinate(start, position.lat);
+ const b = projectCoordinate(end, position.lat);
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const lengthSquared = dx * dx + dy * dy || 1;
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared));
+ const x = a.x + t * dx;
+ const y = a.y + t * dy;
+ return Math.hypot(p.x - x, p.y - y);
+}
+
+function normalizeCoordinates(coordinates: Map3DEditorCoordinateLike[]): Map3DEditorCoordinate[] {
+ return coordinates.flatMap(coordinate => {
+ const normalized = normalizeMap3DCoordinate(coordinate);
+ return normalized ? [normalized] : [];
+ });
+}
+
+function projectCoordinate(
+ position: Map3DEditorCoordinate,
+ referenceLatitude: number
+): {x: number; y: number} {
+ const latitudeRadians = (referenceLatitude * Math.PI) / 180;
+ return {
+ x: position.lng * Math.cos(latitudeRadians),
+ y: position.lat
+ };
+}
+
+function toGeoJSONCoordinate({lat, lng, altitude = 0}: Map3DEditorCoordinate): number[] {
+ return [lng, lat, altitude];
+}
diff --git a/modules/google-maps/src/utils.ts b/modules/google-maps/src/utils.ts
index ed999ddf84f..80a92678e78 100644
--- a/modules/google-maps/src/utils.ts
+++ b/modules/google-maps/src/utils.ts
@@ -3,19 +3,52 @@
// Copyright (c) vis.gl contributors
/* global google, document */
-import {Deck, MapView} from '@deck.gl/core';
+import {Deck, MapView, OrthographicView} from '@deck.gl/core';
import {Matrix4, Vector2} from '@math.gl/core';
import type {MjolnirGestureEvent, MjolnirPointerEvent} from 'mjolnir.js';
export const POSITIONING_CONTAINER_ID = 'deck-gl-google-maps-container';
+export const MAP3D_CONTAINER_ID = 'deck-gl-google-maps-3d-container';
// https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas
const MAX_LATITUDE = 85.05113;
+const EARTH_CIRCUMFERENCE_METERS = 40075016.68557849;
+const DEFAULT_MAP3D_FOV = 25;
+
+type WebGLContext = WebGL2RenderingContext | WebGLRenderingContext;
type UserData = {
- _googleMap: google.maps.Map;
- _eventListeners: Record;
+ _googleMap?: google.maps.Map;
+ _googleMap3D?: GoogleMapsMap3DElement;
+ _eventListeners: Record;
+};
+
+type GoogleMapsEventListener = {
+ remove: () => void;
};
+type GoogleMapsLatLngLike =
+ | google.maps.LatLng
+ | {
+ lat?: number | (() => number);
+ lng?: number | (() => number);
+ altitude?: number;
+ toJSON?: () => {lat: number; lng: number; altitude?: number};
+ };
+
+export type GoogleMapsMap3DElement = HTMLElement & {
+ cameraPosition?: GoogleMapsLatLngLike;
+ center?: GoogleMapsLatLngLike;
+ range?: number;
+ heading?: number;
+ tilt?: number;
+ roll?: number;
+ fov?: number;
+};
+
+let isMap3DContextCaptureInstalled = false;
+const map3DCanvasContexts = new WeakMap();
+const map3DHostContexts = new WeakMap();
+
/**
* Get a new deck instance
* @param map (google.maps.Map) - The parent Map instance
@@ -74,6 +107,67 @@ export function createDeckInstance(
return newDeck;
}
+/**
+ * Get a new deck instance for a Maps 3D web component.
+ * This is intentionally separate from the 2D Google Maps path because Map3D
+ * exposes DOM camera events instead of OverlayView/WebGLOverlayView hooks.
+ */
+export function createDeckInstanceForMap3D(
+ map: GoogleMapsMap3DElement,
+ deck: Deck | null | undefined,
+ props
+): Deck {
+ if (deck) {
+ if (deck.userData._googleMap3D === map) {
+ return deck;
+ }
+ // deck instance was created for a different map
+ destroyDeckInstance(deck);
+ }
+
+ const deckProps = {...props};
+ delete deckProps.map3DDepthMode;
+ delete deckProps.map3DFallbackMode;
+ const useScreenFallback = !props.gl && props.map3DFallbackMode === 'screen';
+
+ const newDeck = new Deck({
+ ...deckProps,
+ useDevicePixels: deckProps.useDevicePixels ?? true,
+ style: deckProps.gl ? null : {pointerEvents: 'none'},
+ parent: getMap3DContainer(map, deckProps.style),
+ views: useScreenFallback ? new OrthographicView({flipY: true}) : new MapView({repeat: true}),
+ initialViewState: useScreenFallback
+ ? {target: [0, 0, 0], zoom: 0}
+ : {
+ longitude: 0,
+ latitude: 0,
+ zoom: 1
+ },
+ controller: false
+ });
+
+ const eventListeners = {
+ click: addDOMEventListener(map, 'click', evt => handleMap3DEvent(newDeck, 'click', evt, map)),
+ dblclick: addDOMEventListener(map, 'dblclick', evt =>
+ handleMap3DEvent(newDeck, 'dblclick', evt, map)
+ ),
+ contextmenu: addDOMEventListener(map, 'contextmenu', evt =>
+ handleMap3DEvent(newDeck, 'rightclick', evt, map)
+ ),
+ pointermove: addDOMEventListener(map, 'pointermove', evt =>
+ handleMap3DEvent(newDeck, 'mousemove', evt, map)
+ ),
+ pointerleave: addDOMEventListener(map, 'pointerleave', evt =>
+ handleMap3DEvent(newDeck, 'mouseout', evt, map)
+ )
+ };
+
+ (newDeck.userData as UserData)._googleMap3D = map;
+ (newDeck.userData as UserData)._eventListeners = eventListeners;
+
+ return newDeck;
+}
+
// Create a container that will host the deck canvas and tooltip
function getContainer(
overlay: google.maps.OverlayView | google.maps.WebGLOverlayView,
@@ -98,12 +192,39 @@ function getContainer(
return container;
}
+function getMap3DContainer(
+ map: GoogleMapsMap3DElement,
+ style?: Partial
+): HTMLElement {
+ const container = document.createElement('div');
+ container.id = MAP3D_CONTAINER_ID;
+ container.style.position = 'absolute';
+ container.style.left = '0';
+ container.style.top = '0';
+ container.style.width = '100%';
+ container.style.height = '100%';
+ container.style.pointerEvents = 'none';
+ Object.assign(container.style, style);
+
+ const parent = map.parentElement;
+ if (parent) {
+ const parentStyle = parent.style;
+ if (!parentStyle.position) {
+ parentStyle.position = 'relative';
+ }
+ parent.appendChild(container);
+ } else {
+ map.appendChild(container);
+ }
+ return container;
+}
+
/**
* Safely remove a deck instance
* @param deck (Deck) - a previously created instances
*/
export function destroyDeckInstance(deck: Deck) {
- const {_eventListeners: eventListeners} = deck.userData;
+ const {_eventListeners: eventListeners = {}} = deck.userData;
// Unregister event listeners
for (const eventType in eventListeners) {
@@ -116,6 +237,208 @@ export function destroyDeckInstance(deck: Deck) {
deck.finalize();
}
+export function isMap3DElement(map: unknown): map is GoogleMapsMap3DElement {
+ if (!map || typeof map !== 'object') {
+ return false;
+ }
+
+ const candidate = map as Partial & {
+ localName?: string;
+ tagName?: string;
+ constructor?: {name?: string};
+ };
+ const tagName = (candidate.localName || candidate.tagName || '').toLowerCase();
+
+ return (
+ tagName === 'gmp-map-3d' ||
+ candidate.constructor?.name === 'Map3DElement' ||
+ ('range' in candidate && 'center' in candidate && !('getRenderingType' in candidate))
+ );
+}
+
+export function captureMap3DWebGLContext(map: GoogleMapsMap3DElement): WebGLContext | null {
+ const hostContext = map3DHostContexts.get(map);
+ if (hostContext) {
+ return hostContext;
+ }
+
+ const roots = [
+ map,
+ (map as {shadowRoot?: ShadowRoot | null}).shadowRoot,
+ (map as {renderRoot?: ParentNode | null}).renderRoot
+ ].filter(Boolean) as ParentNode[];
+
+ for (const root of roots) {
+ const canvas = root.querySelector?.('canvas');
+ const gl = canvas && map3DCanvasContexts.get(canvas);
+ if (gl) {
+ return gl;
+ }
+ }
+
+ return null;
+}
+
+export function installMap3DWebGLContextCapture() {
+ if (isMap3DContextCaptureInstalled || !globalThis.HTMLCanvasElement) {
+ return;
+ }
+
+ isMap3DContextCaptureInstalled = true;
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const getContext = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = function patchedGetContext(
+ this: HTMLCanvasElement,
+ type: string,
+ ...args: any[]
+ ) {
+ const context = getContext.call(this, type as any, ...args);
+
+ if (context && (type === 'webgl2' || type === 'webgl' || type === 'experimental-webgl')) {
+ const host = getMap3DCanvasHost(this);
+ if (host) {
+ const gl = context as WebGLContext;
+ map3DCanvasContexts.set(this, gl);
+ map3DHostContexts.set(host, gl);
+ }
+ }
+
+ return context;
+ } as typeof HTMLCanvasElement.prototype.getContext;
+}
+
+/**
+ * Get the current view state from a Google Maps 3D web component.
+ */
+export function getViewPropsFromMap3D(
+ map: GoogleMapsMap3DElement,
+ options: {zoomSource?: 'camera' | 'range'} = {}
+) {
+ const {width, height} = getMap3DSize(map);
+ const center = normalizeLatLng(map.center);
+ const fovy = map.fov || DEFAULT_MAP3D_FOV;
+ const aspect = height ? width / height : 1;
+ const near = 0.75;
+ const far = 300000000000000;
+ const projectionMatrix = new Matrix4().perspective({
+ fovy: (fovy * Math.PI) / 180,
+ aspect,
+ near,
+ far
+ });
+ const focalDistance = 0.5 * projectionMatrix[5];
+
+ return {
+ width,
+ height,
+ viewState: {
+ altitude: focalDistance,
+ bearing: map.heading || 0,
+ latitude: center.lat,
+ longitude: center.lng,
+ pitch: map.tilt || 0,
+ // Map3D terrain/target altitude is not a stable Deck viewport origin during pan.
+ position: [0, 0, 0],
+ projectionMatrix,
+ repeat: true,
+ zoom:
+ options.zoomSource === 'range'
+ ? getZoomFromMap3DRange(map, center.lat, height, fovy)
+ : getZoomFromMap3DCamera(map, center, height, fovy)
+ }
+ };
+}
+
+export function getScreenViewPropsFromMap3D(map: GoogleMapsMap3DElement) {
+ const {width, height} = getMap3DSize(map);
+ return {
+ width,
+ height,
+ viewState: {
+ target: [width / 2, height / 2, 0],
+ zoom: 0
+ }
+ };
+}
+
+export function addMap3DCameraChangeListener(
+ map: GoogleMapsMap3DElement,
+ callback: () => void,
+ options: {redrawWhileMoving?: boolean} = {}
+): GoogleMapsEventListener {
+ let cameraRedrawFrame = 0;
+ let isCameraMoving = false;
+ const redrawWhileMoving = options.redrawWhileMoving ?? true;
+
+ const stopContinuousRedraw = () => {
+ if (cameraRedrawFrame && globalThis.cancelAnimationFrame) {
+ globalThis.cancelAnimationFrame(cameraRedrawFrame);
+ }
+ cameraRedrawFrame = 0;
+ };
+ const startContinuousRedraw = () => {
+ if (cameraRedrawFrame || !globalThis.requestAnimationFrame) {
+ return;
+ }
+ const redraw = () => {
+ callback();
+ cameraRedrawFrame = globalThis.requestAnimationFrame(redraw);
+ };
+ cameraRedrawFrame = globalThis.requestAnimationFrame(redraw);
+ };
+ const handleCameraChange = () => {
+ if (!redrawWhileMoving && isCameraMoving) {
+ return;
+ }
+ callback();
+ };
+ const handleSteadyChange = (event: Event) => {
+ const isSteady = getMap3DSteadyState(event);
+ if (isSteady === false) {
+ isCameraMoving = true;
+ if (redrawWhileMoving) {
+ callback();
+ startContinuousRedraw();
+ }
+ } else if (isSteady === true) {
+ isCameraMoving = false;
+ stopContinuousRedraw();
+ callback();
+ } else if (redrawWhileMoving) {
+ callback();
+ }
+ };
+
+ const listeners = [
+ 'gmp-centerchange',
+ 'gmp-rangechange',
+ 'gmp-headingchange',
+ 'gmp-tiltchange',
+ 'gmp-rollchange',
+ 'gmp-fovchange',
+ 'gmp-animationend'
+ ].map(eventType => addDOMEventListener(map, eventType, handleCameraChange));
+ listeners.push(addDOMEventListener(map, 'gmp-steadychange', handleSteadyChange));
+
+ return {
+ remove: () => {
+ stopContinuousRedraw();
+ for (const listener of listeners) {
+ listener.remove();
+ }
+ }
+ };
+}
+
+function getMap3DSteadyState(event: Event): boolean | undefined {
+ const candidate = event as Event & {
+ detail?: {isSteady?: boolean};
+ isSteady?: boolean;
+ };
+ const isSteady = candidate.detail?.isSteady ?? candidate.isSteady;
+ return typeof isSteady === 'boolean' ? isSteady : undefined;
+}
+
/* eslint-disable max-statements */
/**
* Get the current view state
@@ -273,6 +596,97 @@ function getMapSize(map: google.maps.Map): {width: number; height: number} {
};
}
+function getMap3DSize(map: GoogleMapsMap3DElement): {width: number; height: number} {
+ const rect = map.getBoundingClientRect();
+ return {
+ width: map.clientWidth || rect.width,
+ height: map.clientHeight || rect.height
+ };
+}
+
+function getMap3DCanvasHost(canvas: HTMLCanvasElement): Element | null {
+ const lightDOMHost = canvas.closest?.('gmp-map-3d');
+ if (lightDOMHost) {
+ return lightDOMHost;
+ }
+
+ let node: Element | null = canvas;
+ while (node) {
+ const root = node.getRootNode?.();
+ const shadowHost = root && 'host' in root ? (root as ShadowRoot).host : null;
+ if (!shadowHost) {
+ return null;
+ }
+ if (shadowHost.localName === 'gmp-map-3d') {
+ return shadowHost;
+ }
+ node = shadowHost;
+ }
+
+ return null;
+}
+
+function getZoomFromMap3DCamera(
+ map: GoogleMapsMap3DElement,
+ center: {lat: number; altitude: number},
+ height: number,
+ fovy: number
+): number {
+ const cameraPosition = normalizeLatLng(map.cameraPosition);
+ const cameraHeight = cameraPosition.altitude - center.altitude;
+ const pitch = map.tilt || 0;
+ const pitchCosine = Math.cos((pitch * Math.PI) / 180);
+ if (cameraHeight > 0 && height && pitchCosine > 0) {
+ const focalDistance = 0.5 / Math.tan((fovy * Math.PI) / 360);
+ const metersPerWorldUnit =
+ (EARTH_CIRCUMFERENCE_METERS * Math.cos((center.lat * Math.PI) / 180)) / 512;
+ const scale = (focalDistance * height * metersPerWorldUnit * pitchCosine) / cameraHeight;
+ return Math.log2(scale);
+ }
+
+ return getZoomFromMap3DRange(map, center.lat, height, fovy);
+}
+
+function getZoomFromMap3DRange(
+ map: GoogleMapsMap3DElement,
+ latitude: number,
+ height: number,
+ fovy: number
+): number {
+ const range = map.range || 0;
+ if (!range || !height) {
+ return 1;
+ }
+
+ const visibleMeters = 2 * range * Math.tan((fovy * Math.PI) / 360);
+ const metersPerPixel = visibleMeters / height;
+ const metersPerPixelAtZoom0 =
+ (EARTH_CIRCUMFERENCE_METERS * Math.cos((latitude * Math.PI) / 180)) / 512;
+
+ return Math.log2(metersPerPixelAtZoom0 / metersPerPixel);
+}
+
+function normalizeLatLng(center?: GoogleMapsLatLngLike): {
+ lat: number;
+ lng: number;
+ altitude: number;
+} {
+ if (!center) {
+ return {lat: 0, lng: 0, altitude: 0};
+ }
+
+ const value = 'toJSON' in center && center.toJSON ? center.toJSON() : center;
+ const lat = typeof value.lat === 'function' ? value.lat() : value.lat;
+ const lng = typeof value.lng === 'function' ? value.lng() : value.lng;
+ const altitude = 'altitude' in value && typeof value.altitude === 'number' ? value.altitude : 0;
+
+ return {
+ lat: lat || 0,
+ lng: lng || 0,
+ altitude
+ };
+}
+
function pixelToLngLat(
projection: google.maps.MapCanvasProjection,
x: number,
@@ -284,6 +698,22 @@ function pixelToLngLat(
return [latLng.lng(), latLng.lat()];
}
+function getDOMEventPixel(
+ event: Event | google.maps.MapMouseEvent,
+ map: GoogleMapsMap3DElement
+): {x: number; y: number} {
+ if ('pixel' in event && event.pixel) {
+ return event.pixel as {x: number; y: number};
+ }
+
+ const srcEvent = ('srcEvent' in event ? event.srcEvent : event) as MouseEvent;
+ const rect = map.getBoundingClientRect();
+ return {
+ x: srcEvent.clientX - rect.left,
+ y: srcEvent.clientY - rect.top
+ };
+}
+
function getEventPixel(event, deck: Deck): {x: number; y: number} {
if (event.pixel) {
return event.pixel;
@@ -297,6 +727,17 @@ function getEventPixel(event, deck: Deck): {x: number; y: number} {
};
}
+function addDOMEventListener(
+ target: EventTarget,
+ eventType: string,
+ callback: (event: Event) => void
+): GoogleMapsEventListener {
+ target.addEventListener(eventType, callback);
+ return {
+ remove: () => target.removeEventListener(eventType, callback)
+ };
+}
+
// Triggers picking on a mouse event
function handleMouseEvent(deck: Deck, type: string, event) {
if (!deck.isInitialized) {
@@ -339,3 +780,49 @@ function handleMouseEvent(deck: Deck, type: string, event) {
return;
}
}
+
+function handleMap3DEvent(
+ deck: Deck,
+ type: string,
+ event: Event | google.maps.MapMouseEvent,
+ map: GoogleMapsMap3DElement
+) {
+ if (!deck.isInitialized) {
+ return;
+ }
+
+ const mockEvent: Record = {
+ type,
+ offsetCenter: getDOMEventPixel(event, map),
+ srcEvent: event
+ };
+
+ switch (type) {
+ case 'click':
+ case 'rightclick':
+ mockEvent.type = 'click';
+ mockEvent.tapCount = 1;
+ deck._onPointerDown(mockEvent as MjolnirPointerEvent);
+ deck._onEvent(mockEvent as MjolnirGestureEvent);
+ break;
+
+ case 'dblclick':
+ mockEvent.type = 'click';
+ mockEvent.tapCount = 2;
+ deck._onEvent(mockEvent as MjolnirGestureEvent);
+ break;
+
+ case 'mousemove':
+ mockEvent.type = 'pointermove';
+ deck._onPointerMove(mockEvent as MjolnirPointerEvent);
+ break;
+
+ case 'mouseout':
+ mockEvent.type = 'pointerleave';
+ deck._onPointerMove(mockEvent as MjolnirPointerEvent);
+ break;
+
+ default:
+ return;
+ }
+}
diff --git a/test/modules/google-maps/google-maps-overlay.spec.ts b/test/modules/google-maps/google-maps-overlay.spec.ts
index 61e2649a037..e83ae7b4262 100644
--- a/test/modules/google-maps/google-maps-overlay.spec.ts
+++ b/test/modules/google-maps/google-maps-overlay.spec.ts
@@ -6,16 +6,50 @@
import {test, expect, vi} from 'vitest';
import {GoogleMapsOverlay} from '@deck.gl/google-maps';
+import {log} from '@deck.gl/core';
import {ScatterplotLayer} from '@deck.gl/layers';
import {device} from '@deck.gl/test-utils/vitest';
import {equals} from '@math.gl/core';
+import {
+ addMap3DCameraChangeListener,
+ captureMap3DWebGLContext,
+ getScreenViewPropsFromMap3D,
+ getViewPropsFromMap3D,
+ installMap3DWebGLContextCapture,
+ isMap3DElement
+} from '../../../modules/google-maps/src/utils';
import * as mapsApi from './mock-maps-api';
globalThis.google = {maps: mapsApi};
const withDevice = props => ({device, ...props});
+test('GoogleMapsOverlay#Map3D captures nested renderer canvas context', () => {
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
+ const gl = {canvas: null};
+ HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement) {
+ gl.canvas = this;
+ return gl;
+ } as typeof HTMLCanvasElement.prototype.getContext;
+
+ installMap3DWebGLContextCapture();
+
+ const map = document.createElement('gmp-map-3d') as mapsApi.Map3DElement;
+ const internalHost = document.createElement('gmp-internal-renderer');
+ const shadowRoot = map.attachShadow({mode: 'open'});
+ const internalRoot = internalHost.attachShadow({mode: 'open'});
+ const canvas = document.createElement('canvas');
+ shadowRoot.appendChild(internalHost);
+ internalRoot.appendChild(canvas);
+
+ canvas.getContext('webgl2');
+
+ expect(captureMap3DWebGLContext(map), 'nested Map3D WebGL context is captured').toBe(gl);
+
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
+});
+
test('GoogleMapsOverlay#constructor', () => {
const map = new mapsApi.Map({
width: 1,
@@ -35,6 +69,10 @@ test('GoogleMapsOverlay#constructor', () => {
const deck = overlay._deck;
expect(deck, 'Deck instance is created').toBeTruthy();
expect(overlay.props.interleaved, 'interleaved defaults to true').toBeTruthy();
+ expect(overlay.props.map3DDepthMode, 'Map3D depth mode defaults to screen').toBe('screen');
+ expect(overlay.props.map3DFallbackMode, 'Map3D fallback mode defaults to geospatial').toBe(
+ 'geospatial'
+ );
overlay.setMap(map);
expect(overlay._deck, 'Deck instance is the same').toBe(deck);
@@ -56,6 +94,255 @@ test('GoogleMapsOverlay#interleaved prop', () => {
expect(!overlay.props.interleaved, 'interleaved set to false').toBeTruthy();
});
+test('GoogleMapsOverlay#Map3D camera view state', () => {
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30},
+ range: 1200,
+ heading: 123,
+ tilt: 67,
+ fov: 35
+ });
+
+ const {width, height, viewState} = getViewPropsFromMap3D(map);
+
+ expect(isMap3DElement(map), 'Map3D element is recognized').toBeTruthy();
+ expect(width, 'width is set').toBe(800);
+ expect(height, 'height is set').toBe(400);
+ expect(equals(viewState.longitude, -122.45), 'longitude is set').toBeTruthy();
+ expect(equals(viewState.latitude, 37.78), 'latitude is set').toBeTruthy();
+ expect(equals(viewState.bearing, 123), 'bearing is set').toBeTruthy();
+ expect(equals(viewState.pitch, 67), 'pitch is set').toBeTruthy();
+ expect(equals(viewState.position, [0, 0, 0]), 'deck viewport altitude stays stable').toBeTruthy();
+ expect(equals(viewState.zoom, 14.997043852729847), 'range-derived zoom is set').toBeTruthy();
+ expect(viewState.projectionMatrix, 'projection matrix is set').toBeTruthy();
+});
+
+test('GoogleMapsOverlay#Map3D screen fallback view state', () => {
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30}
+ });
+
+ const {width, height, viewState} = getScreenViewPropsFromMap3D(map);
+
+ expect(width, 'width is set').toBe(800);
+ expect(height, 'height is set').toBe(400);
+ expect(equals(viewState.target, [400, 200, 0]), 'screen target is centered').toBeTruthy();
+ expect(viewState.zoom, 'screen zoom is one pixel per unit').toBe(0);
+});
+
+test('GoogleMapsOverlay#Map3D cameraPosition zoom', () => {
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30},
+ cameraPosition: {lat: 37.77, lng: -122.46, altitude: 600},
+ range: 1200,
+ heading: 123,
+ tilt: 67,
+ fov: 35
+ });
+
+ const {viewState} = getViewPropsFromMap3D(map);
+
+ expect(
+ equals(viewState.zoom, 14.715292534987455),
+ 'cameraPosition-derived zoom is set'
+ ).toBeTruthy();
+});
+
+test('GoogleMapsOverlay#Map3D range zoom ignores camera altitude', () => {
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30},
+ cameraPosition: {lat: 37.77, lng: -122.46, altitude: 600},
+ range: 1200,
+ heading: 123,
+ tilt: 67,
+ fov: 35
+ });
+
+ const {viewState} = getViewPropsFromMap3D(map, {zoomSource: 'range'});
+
+ expect(equals(viewState.zoom, 14.997043852729847), 'range-derived zoom is set').toBeTruthy();
+
+ map.cameraPosition = {lat: 37.77, lng: -122.46, altitude: 900};
+ expect(
+ equals(getViewPropsFromMap3D(map, {zoomSource: 'range'}).viewState.zoom, viewState.zoom),
+ 'range-derived zoom ignores camera altitude changes'
+ ).toBeTruthy();
+});
+
+test('GoogleMapsOverlay#Map3D redraws continuously while camera is not steady', () => {
+ const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
+ const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
+ const frames = new Map();
+ let frameId = 0;
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
+ frameId++;
+ frames.set(frameId, callback);
+ return frameId;
+ }) as typeof globalThis.requestAnimationFrame;
+ globalThis.cancelAnimationFrame = ((id: number) => {
+ frames.delete(id);
+ }) as typeof globalThis.cancelAnimationFrame;
+
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30}
+ });
+ const callback = vi.fn();
+ const listener = addMap3DCameraChangeListener(map, callback);
+
+ map.dispatchEvent(new CustomEvent('gmp-steadychange', {detail: {isSteady: false}}));
+ expect(callback, 'steady false redraws immediately').toHaveBeenCalledTimes(1);
+ expect(frames.size, 'continuous redraw loop is scheduled').toBe(1);
+
+ frames.get(1)?.(0);
+ expect(callback, 'animation frame redraws while moving').toHaveBeenCalledTimes(2);
+ expect(frames.has(2), 'continuous redraw loop schedules next frame').toBeTruthy();
+
+ map.dispatchEvent(new CustomEvent('gmp-steadychange', {detail: {isSteady: true}}));
+ expect(callback, 'steady true redraws final pose').toHaveBeenCalledTimes(3);
+ expect(frames.has(2), 'continuous redraw loop is cancelled').toBeFalsy();
+
+ listener.remove();
+ globalThis.requestAnimationFrame = originalRequestAnimationFrame;
+ globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
+});
+
+test('GoogleMapsOverlay#Map3D fallback waits for steady camera before redraw', () => {
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30}
+ });
+ const callback = vi.fn();
+ const listener = addMap3DCameraChangeListener(map, callback, {redrawWhileMoving: false});
+
+ map.dispatchEvent(new CustomEvent('gmp-steadychange', {detail: {isSteady: false}}));
+ expect(callback, 'steady false does not redraw fallback immediately').toHaveBeenCalledTimes(0);
+
+ map.dispatchEvent(new Event('gmp-centerchange'));
+ expect(callback, 'camera changes are skipped while fallback is moving').toHaveBeenCalledTimes(0);
+
+ map.dispatchEvent(new CustomEvent('gmp-steadychange', {detail: {isSteady: true}}));
+ expect(callback, 'steady true redraws fallback final pose').toHaveBeenCalledTimes(1);
+
+ map.dispatchEvent(new Event('gmp-centerchange'));
+ expect(callback, 'camera changes redraw fallback after camera is steady').toHaveBeenCalledTimes(
+ 2
+ );
+
+ listener.remove();
+});
+
+test('GoogleMapsOverlay#Map3D fallback does not redraw continuously while pitching', () => {
+ const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
+ const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
+ const frames = new Map();
+ let frameId = 0;
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
+ frameId++;
+ frames.set(frameId, callback);
+ return frameId;
+ }) as typeof globalThis.requestAnimationFrame;
+ globalThis.cancelAnimationFrame = ((id: number) => {
+ frames.delete(id);
+ }) as typeof globalThis.cancelAnimationFrame;
+
+ const warnSpy = vi.spyOn(log, 'warn').mockReturnValue(() => {});
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30}
+ });
+ const overlay = new GoogleMapsOverlay({
+ device,
+ layers: [],
+ map3DFallbackMode: 'screen'
+ });
+
+ overlay.setMap(map);
+ expect(frames.size, 'initial Map3D fallback draw is not scheduled through rAF').toBe(0);
+
+ map.dispatchEvent(new CustomEvent('gmp-steadychange', {detail: {isSteady: false}}));
+ expect(frames.size, 'moving fallback does not start a continuous redraw loop').toBe(0);
+
+ map.dispatchEvent(new Event('gmp-tiltchange'));
+ expect(frames.size, 'pitch changes are skipped while the fallback camera is moving').toBe(0);
+
+ map.dispatchEvent(new CustomEvent('gmp-steadychange', {detail: {isSteady: true}}));
+ expect(frames.size, 'settled fallback schedules one final redraw').toBe(1);
+
+ overlay.finalize();
+ warnSpy.mockRestore();
+ globalThis.requestAnimationFrame = originalRequestAnimationFrame;
+ globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
+});
+
+test('GoogleMapsOverlay#Map3D lifecycle without captured internals', () => {
+ const warnSpy = vi.spyOn(log, 'warn').mockReturnValue(() => {});
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30},
+ range: 1200,
+ heading: 123,
+ tilt: 67,
+ fov: 35
+ });
+ const overlay = new GoogleMapsOverlay({
+ device,
+ layers: []
+ });
+
+ overlay.setMap(map);
+ expect(
+ warnSpy.mock.calls.some(call => String(call[0]).includes('could not capture the Map3D WebGL')),
+ 'missing Map3D internals warns clearly'
+ ).toBeTruthy();
+ expect(overlay._deck, 'Deck instance is created').toBeTruthy();
+ expect(overlay._deck.props.viewState.longitude, 'Map3D longitude is set').toBe(-122.45);
+
+ overlay.setMap(null);
+ overlay.finalize();
+ warnSpy.mockRestore();
+});
+
+test('GoogleMapsOverlay#Map3D mesh depth mode warns without captured internals', () => {
+ const warnSpy = vi.spyOn(log, 'warn').mockReturnValue(() => {});
+ const map = new mapsApi.Map3DElement({
+ width: 800,
+ height: 400,
+ center: {lat: 37.78, lng: -122.45, altitude: 30},
+ range: 1200,
+ heading: 123,
+ tilt: 67,
+ fov: 35
+ });
+ const overlay = new GoogleMapsOverlay({
+ device,
+ layers: [],
+ map3DDepthMode: 'mesh'
+ });
+
+ overlay.setMap(map);
+ expect(overlay.props.map3DDepthMode, 'Map3D depth mode is set').toBe('mesh');
+ expect(
+ warnSpy.mock.calls.some(call => String(call[0]).includes('Mesh-depth mode was requested')),
+ 'mesh depth fallback warns clearly'
+ ).toBeTruthy();
+
+ overlay.finalize();
+ warnSpy.mockRestore();
+});
+
test('GoogleMapsOverlay#useDevicePixels prop', () => {
const map = new mapsApi.Map({width: 1, height: 1, longitude: 0, latitude: 0, zoom: 1});
diff --git a/test/modules/google-maps/map3d-editor-state.spec.ts b/test/modules/google-maps/map3d-editor-state.spec.ts
new file mode 100644
index 00000000000..183b189f872
--- /dev/null
+++ b/test/modules/google-maps/map3d-editor-state.spec.ts
@@ -0,0 +1,93 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {test, expect} from 'vitest';
+
+import {
+ createMap3DEditorState,
+ getMap3DEditorInsertIndex,
+ normalizeMap3DCoordinate
+} from '../../../modules/google-maps/src/map-3d-editor-state';
+
+const PATH = [
+ {lng: -73.985, lat: 40.758, altitude: 0},
+ {lng: -73.98, lat: 40.764, altitude: 0},
+ {lng: -73.975, lat: 40.766, altitude: 0}
+];
+
+test('Map3D editor state appends and deletes active geometry', () => {
+ const editor = createMap3DEditorState({path: PATH, points: [], polygon: []});
+
+ editor.setMode('point');
+ let snapshot = editor.appendPosition({lng: -73.99, lat: 40.75, altitude: 12});
+
+ expect(snapshot.points).toEqual([{lng: -73.99, lat: 40.75, altitude: 12}]);
+ expect(snapshot.selected).toEqual({type: 'point', index: 0});
+
+ const deleted = editor.deleteSelected();
+ expect(deleted.changed).toBe(true);
+ expect(deleted.snapshot.points).toEqual([]);
+});
+
+test('Map3D editor state inserts path vertices at nearest segment', () => {
+ const editor = createMap3DEditorState({path: PATH, points: [], polygon: []});
+ const inserted = editor.insertPathVertex({lng: -73.982, lat: 40.762, altitude: 0});
+
+ expect(inserted.path).toHaveLength(4);
+ expect(inserted.path[1]).toEqual({lng: -73.982, lat: 40.762, altitude: 0});
+ expect(inserted.selected).toEqual({type: 'path', index: 1});
+});
+
+test('Map3D editor state moves selected vertices', () => {
+ const editor = createMap3DEditorState({path: PATH, points: [], polygon: []});
+
+ expect(editor.moveSelected({lng: -73.99, lat: 40.75, altitude: 0}).changed).toBe(false);
+
+ editor.select({type: 'path', index: 1});
+ const moved = editor.moveSelected({lng: -73.99, lat: 40.75, altitude: 4});
+
+ expect(moved.changed).toBe(true);
+ expect(moved.snapshot.path[1]).toEqual({lng: -73.99, lat: 40.75, altitude: 4});
+ expect(moved.snapshot.selected).toEqual({type: 'path', index: 1});
+});
+
+test('Map3D editor state serializes GeoJSON features', () => {
+ const editor = createMap3DEditorState({
+ path: PATH,
+ points: [{lng: -73.99, lat: 40.75, altitude: 2}],
+ polygon: [
+ {lng: -73.98, lat: 40.76, altitude: 0},
+ {lng: -73.97, lat: 40.76, altitude: 0},
+ {lng: -73.97, lat: 40.77, altitude: 0}
+ ]
+ });
+
+ const {geojson} = editor.getSnapshot();
+ expect(geojson.features.map(feature => feature.geometry.type)).toEqual([
+ 'LineString',
+ 'Polygon',
+ 'Point'
+ ]);
+ expect(geojson.features[1].geometry.coordinates[0][0]).toEqual([-73.98, 40.76, 0]);
+ expect(geojson.features[1].geometry.coordinates[0].at(-1)).toEqual([-73.98, 40.76, 0]);
+});
+
+test('Map3D editor state normalizes LatLngAltitude-like values', () => {
+ expect(
+ normalizeMap3DCoordinate({
+ toJSON: () => ({lat: 40.7, lng: -73.9, altitude: 15})
+ })
+ ).toEqual({lat: 40.7, lng: -73.9, altitude: 15});
+ expect(normalizeMap3DCoordinate({lat: () => 40.8, lng: () => -74})).toEqual({
+ lat: 40.8,
+ lng: -74,
+ altitude: 0
+ });
+ expect(normalizeMap3DCoordinate({lat: Number.NaN, lng: -74})).toBeNull();
+});
+
+test('Map3D editor state computes insert index for short paths', () => {
+ expect(getMap3DEditorInsertIndex([], {lng: 0, lat: 0, altitude: 0})).toBe(0);
+ expect(getMap3DEditorInsertIndex([PATH[0]], {lng: 0, lat: 0, altitude: 0})).toBe(1);
+});
diff --git a/test/modules/google-maps/mock-maps-api.ts b/test/modules/google-maps/mock-maps-api.ts
index 2b18c531d68..3d496adb98a 100644
--- a/test/modules/google-maps/mock-maps-api.ts
+++ b/test/modules/google-maps/mock-maps-api.ts
@@ -186,6 +186,47 @@ export class Map {
}
}
+export class Map3DElement {
+ constructor(opts) {
+ /* global document */
+ const element = document.createElement('gmp-map-3d');
+ const center = opts.center || {
+ lat: opts.latitude || 0,
+ lng: opts.longitude || 0,
+ altitude: opts.altitude || 0
+ };
+
+ Object.assign(element, {
+ cameraPosition: opts.cameraPosition,
+ center,
+ range: opts.range || 1000,
+ heading: opts.heading || 0,
+ tilt: opts.tilt || opts.pitch || 0,
+ roll: opts.roll || 0,
+ fov: opts.fov || 25,
+ emit: event => element.dispatchEvent(new Event(event.type))
+ });
+ Object.defineProperty(element, 'clientWidth', {value: opts.width, configurable: true});
+ Object.defineProperty(element, 'clientHeight', {value: opts.height, configurable: true});
+ Object.defineProperty(element, 'offsetWidth', {value: opts.width, configurable: true});
+ Object.defineProperty(element, 'offsetHeight', {value: opts.height, configurable: true});
+ element.getBoundingClientRect = () => ({
+ left: 0,
+ top: 0,
+ right: opts.width,
+ bottom: opts.height,
+ width: opts.width,
+ height: opts.height
+ });
+
+ return element;
+ }
+}
+
+export const maps3d = {
+ Map3DElement
+};
+
export class OverlayView {
constructor() {
this.map = null;