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;