• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / deck.gl / 28481167194

30 Jun 2026 10:55PM UTC coverage: 82.76% (-0.4%) from 83.113%
28481167194

Pull #10401

github

web-flow
Merge 61122096f into c4a807613
Pull Request #10401: WIP: experiment with Google Maps 3D overlay support

8252 of 10484 branches covered (78.71%)

Branch coverage included in aggregate %.

289 of 396 new or added lines in 3 files covered. (72.98%)

2 existing lines in 1 file now uncovered.

14671 of 17214 relevant lines covered (85.23%)

18655.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.73
/modules/google-maps/src/utils.ts
1
// deck.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
/* global google, document */
6
import {Deck, MapView, OrthographicView} from '@deck.gl/core';
7
import {Matrix4, Vector2} from '@math.gl/core';
8
import type {MjolnirGestureEvent, MjolnirPointerEvent} from 'mjolnir.js';
9
export const POSITIONING_CONTAINER_ID = 'deck-gl-google-maps-container';
2✔
10
export const MAP3D_CONTAINER_ID = 'deck-gl-google-maps-3d-container';
2✔
11

12
// https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas
13
const MAX_LATITUDE = 85.05113;
2✔
14
const EARTH_CIRCUMFERENCE_METERS = 40075016.68557849;
2✔
15
const DEFAULT_MAP3D_FOV = 25;
2✔
16

17
type WebGLContext = WebGL2RenderingContext | WebGLRenderingContext;
18

19
type UserData = {
20
  _googleMap?: google.maps.Map;
21
  _googleMap3D?: GoogleMapsMap3DElement;
22
  _eventListeners: Record<string, GoogleMapsEventListener | null>;
23
};
24

25
type GoogleMapsEventListener = {
26
  remove: () => void;
27
};
28

29
type GoogleMapsLatLngLike =
30
  | google.maps.LatLng
31
  | {
32
      lat?: number | (() => number);
33
      lng?: number | (() => number);
34
      altitude?: number;
35
      toJSON?: () => {lat: number; lng: number; altitude?: number};
36
    };
37

38
export type GoogleMapsMap3DElement = HTMLElement & {
39
  cameraPosition?: GoogleMapsLatLngLike;
40
  center?: GoogleMapsLatLngLike;
41
  range?: number;
42
  heading?: number;
43
  tilt?: number;
44
  roll?: number;
45
  fov?: number;
46
};
47

48
let isMap3DContextCaptureInstalled = false;
2✔
49
const map3DCanvasContexts = new WeakMap<HTMLCanvasElement, WebGLContext>();
2✔
50
const map3DHostContexts = new WeakMap<Element, WebGLContext>();
2✔
51

52
/**
53
 * Get a new deck instance
54
 * @param map (google.maps.Map) - The parent Map instance
55
 * @param overlay (google.maps.OverlayView) - A maps Overlay instance
56
 * @param [deck] (Deck) - a previously created instances
57
 */
58
export function createDeckInstance(
59
  map: google.maps.Map,
60
  overlay: google.maps.OverlayView | google.maps.WebGLOverlayView,
61
  deck: Deck | null | undefined,
62
  props
63
): Deck {
64
  if (deck) {
11✔
65
    if (deck.userData._googleMap === map) {
2!
66
      return deck;
2✔
67
    }
68
    // deck instance was created for a different map
69
    destroyDeckInstance(deck);
×
70
  }
71

72
  const eventListeners = {
9✔
73
    click: null,
74
    rightclick: null,
75
    dblclick: null,
76
    mousemove: null,
77
    mouseout: null
78
  };
79

80
  const newDeck = new Deck({
9✔
81
    ...props,
82
    // The basemap owns the shared canvas in interleaved mode; Deck only forwards the preferred DPR.
83
    // In non-interleaved mode this still feeds the luma canvas context that Deck creates.
84
    useDevicePixels: props.useDevicePixels ?? true,
16✔
85
    style: props.interleaved ? null : {pointerEvents: 'none'},
9✔
86
    parent: getContainer(overlay, props.style),
87
    views: new MapView({repeat: true}),
88
    initialViewState: {
89
      longitude: 0,
90
      latitude: 0,
91
      zoom: 1
92
    },
93
    controller: false
94
  });
95

96
  // Register event listeners
97
  for (const eventType in eventListeners) {
11✔
98
    eventListeners[eventType] = map.addListener(eventType, evt =>
45✔
99
      handleMouseEvent(newDeck, eventType, evt)
×
100
    );
101
  }
102

103
  // Attach userData directly to Deck instance
104
  (newDeck.userData as UserData)._googleMap = map;
9✔
105
  (newDeck.userData as UserData)._eventListeners = eventListeners;
9✔
106

107
  return newDeck;
9✔
108
}
109

110
/**
111
 * Get a new deck instance for a Maps 3D web component.
112
 * This is intentionally separate from the 2D Google Maps path because Map3D
113
 * exposes DOM camera events instead of OverlayView/WebGLOverlayView hooks.
114
 */
115
export function createDeckInstanceForMap3D(
116
  map: GoogleMapsMap3DElement,
117
  deck: Deck | null | undefined,
118
  props
119
): Deck {
120
  if (deck) {
3!
NEW
121
    if (deck.userData._googleMap3D === map) {
×
NEW
122
      return deck;
×
123
    }
124
    // deck instance was created for a different map
NEW
125
    destroyDeckInstance(deck);
×
126
  }
127

128
  const deckProps = {...props};
3✔
129
  delete deckProps.map3DDepthMode;
3✔
130
  delete deckProps.map3DFallbackMode;
3✔
131
  const useScreenFallback = !props.gl && props.map3DFallbackMode === 'screen';
3✔
132

133
  const newDeck = new Deck({
3✔
134
    ...deckProps,
135
    useDevicePixels: deckProps.useDevicePixels ?? true,
6✔
136
    style: deckProps.gl ? null : {pointerEvents: 'none'},
3!
137
    parent: getMap3DContainer(map, deckProps.style),
138
    views: useScreenFallback ? new OrthographicView({flipY: true}) : new MapView({repeat: true}),
3✔
139
    initialViewState: useScreenFallback
3✔
140
      ? {target: [0, 0, 0], zoom: 0}
141
      : {
142
          longitude: 0,
143
          latitude: 0,
144
          zoom: 1
145
        },
146
    controller: false
147
  });
148

149
  const eventListeners = {
3✔
NEW
150
    click: addDOMEventListener(map, 'click', evt => handleMap3DEvent(newDeck, 'click', evt, map)),
×
151
    dblclick: addDOMEventListener(map, 'dblclick', evt =>
NEW
152
      handleMap3DEvent(newDeck, 'dblclick', evt, map)
×
153
    ),
154
    contextmenu: addDOMEventListener(map, 'contextmenu', evt =>
NEW
155
      handleMap3DEvent(newDeck, 'rightclick', evt, map)
×
156
    ),
157
    pointermove: addDOMEventListener(map, 'pointermove', evt =>
NEW
158
      handleMap3DEvent(newDeck, 'mousemove', evt, map)
×
159
    ),
160
    pointerleave: addDOMEventListener(map, 'pointerleave', evt =>
NEW
161
      handleMap3DEvent(newDeck, 'mouseout', evt, map)
×
162
    )
163
  };
164

165
  (newDeck.userData as UserData)._googleMap3D = map;
3✔
166
  (newDeck.userData as UserData)._eventListeners = eventListeners;
3✔
167

168
  return newDeck;
3✔
169
}
170

171
// Create a container that will host the deck canvas and tooltip
172
function getContainer(
173
  overlay: google.maps.OverlayView | google.maps.WebGLOverlayView,
174
  style?: Partial<CSSStyleDeclaration>
175
): HTMLElement {
176
  const container = document.createElement('div');
9✔
177
  container.style.position = 'absolute';
9✔
178
  Object.assign(container.style, style);
9✔
179

180
  const googleMapsContainer = (overlay.getMap() as google.maps.Map).getDiv();
9✔
181

182
  // Check if there's a pre-created positioning container (for vector maps)
183
  const positioningContainer = googleMapsContainer.querySelector(`#${POSITIONING_CONTAINER_ID}`);
9✔
184

185
  if (positioningContainer) {
9✔
186
    // Vector maps (both interleaved and non-interleaved): Use positioning container
187
    positioningContainer.appendChild(container);
3✔
188
  } else if ('getPanes' in overlay) {
6!
189
    // Raster maps: Append to overlayLayer pane
190
    overlay.getPanes()?.overlayLayer.appendChild(container);
6✔
191
  }
192
  return container;
9✔
193
}
194

195
function getMap3DContainer(
196
  map: GoogleMapsMap3DElement,
197
  style?: Partial<CSSStyleDeclaration>
198
): HTMLElement {
199
  const container = document.createElement('div');
3✔
200
  container.id = MAP3D_CONTAINER_ID;
3✔
201
  container.style.position = 'absolute';
3✔
202
  container.style.left = '0';
3✔
203
  container.style.top = '0';
3✔
204
  container.style.width = '100%';
3✔
205
  container.style.height = '100%';
3✔
206
  container.style.pointerEvents = 'none';
3✔
207
  Object.assign(container.style, style);
3✔
208

209
  const parent = map.parentElement;
3✔
210
  if (parent) {
3!
NEW
211
    const parentStyle = parent.style;
×
NEW
212
    if (!parentStyle.position) {
×
NEW
213
      parentStyle.position = 'relative';
×
214
    }
NEW
215
    parent.appendChild(container);
×
216
  } else {
217
    map.appendChild(container);
3✔
218
  }
219
  return container;
3✔
220
}
221

222
/**
223
 * Safely remove a deck instance
224
 * @param deck (Deck) - a previously created instances
225
 */
226
export function destroyDeckInstance(deck: Deck) {
227
  const {_eventListeners: eventListeners = {}} = deck.userData;
10✔
228

229
  // Unregister event listeners
230
  for (const eventType in eventListeners) {
10✔
231
    // Check that event listener was set before trying to remove.
232
    if (eventListeners[eventType]) {
50!
233
      eventListeners[eventType].remove();
50✔
234
    }
235
  }
236

237
  deck.finalize();
10✔
238
}
239

240
export function isMap3DElement(map: unknown): map is GoogleMapsMap3DElement {
241
  if (!map || typeof map !== 'object') {
31!
NEW
242
    return false;
×
243
  }
244

245
  const candidate = map as Partial<GoogleMapsMap3DElement> & {
31✔
246
    localName?: string;
247
    tagName?: string;
248
    constructor?: {name?: string};
249
  };
250
  const tagName = (candidate.localName || candidate.tagName || '').toLowerCase();
31✔
251

252
  return (
31✔
253
    tagName === 'gmp-map-3d' ||
73!
254
    candidate.constructor?.name === 'Map3DElement' ||
255
    ('range' in candidate && 'center' in candidate && !('getRenderingType' in candidate))
256
  );
257
}
258

259
export function captureMap3DWebGLContext(map: GoogleMapsMap3DElement): WebGLContext | null {
260
  const hostContext = map3DHostContexts.get(map);
4✔
261
  if (hostContext) {
4✔
262
    return hostContext;
1✔
263
  }
264

265
  const roots = [
3✔
266
    map,
267
    (map as {shadowRoot?: ShadowRoot | null}).shadowRoot,
268
    (map as {renderRoot?: ParentNode | null}).renderRoot
269
  ].filter(Boolean) as ParentNode[];
270

271
  for (const root of roots) {
3✔
272
    const canvas = root.querySelector?.<HTMLCanvasElement>('canvas');
3✔
273
    const gl = canvas && map3DCanvasContexts.get(canvas);
3!
274
    if (gl) {
3!
NEW
275
      return gl;
×
276
    }
277
  }
278

279
  return null;
3✔
280
}
281

282
export function installMap3DWebGLContextCapture() {
283
  if (isMap3DContextCaptureInstalled || !globalThis.HTMLCanvasElement) {
14✔
284
    return;
13✔
285
  }
286

287
  isMap3DContextCaptureInstalled = true;
1✔
288
  // eslint-disable-next-line @typescript-eslint/unbound-method
289
  const getContext = HTMLCanvasElement.prototype.getContext;
1✔
290
  HTMLCanvasElement.prototype.getContext = function patchedGetContext(
1✔
291
    this: HTMLCanvasElement,
292
    type: string,
293
    ...args: any[]
294
  ) {
295
    const context = getContext.call(this, type as any, ...args);
1✔
296

297
    if (context && (type === 'webgl2' || type === 'webgl' || type === 'experimental-webgl')) {
1!
298
      const host = getMap3DCanvasHost(this);
1✔
299
      if (host) {
1!
300
        const gl = context as WebGLContext;
1✔
301
        map3DCanvasContexts.set(this, gl);
1✔
302
        map3DHostContexts.set(host, gl);
1✔
303
      }
304
    }
305

306
    return context;
1✔
307
  } as typeof HTMLCanvasElement.prototype.getContext;
308
}
309

310
/**
311
 * Get the current view state from a Google Maps 3D web component.
312
 */
313
export function getViewPropsFromMap3D(
314
  map: GoogleMapsMap3DElement,
315
  options: {zoomSource?: 'camera' | 'range'} = {}
6✔
316
) {
317
  const {width, height} = getMap3DSize(map);
6✔
318
  const center = normalizeLatLng(map.center);
6✔
319
  const fovy = map.fov || DEFAULT_MAP3D_FOV;
6!
320
  const aspect = height ? width / height : 1;
6!
321
  const near = 0.75;
6✔
322
  const far = 300000000000000;
6✔
323
  const projectionMatrix = new Matrix4().perspective({
6✔
324
    fovy: (fovy * Math.PI) / 180,
325
    aspect,
326
    near,
327
    far
328
  });
329
  const focalDistance = 0.5 * projectionMatrix[5];
6✔
330

331
  return {
6✔
332
    width,
333
    height,
334
    viewState: {
335
      altitude: focalDistance,
336
      bearing: map.heading || 0,
6!
337
      latitude: center.lat,
338
      longitude: center.lng,
339
      pitch: map.tilt || 0,
6!
340
      // Map3D terrain/target altitude is not a stable Deck viewport origin during pan.
341
      position: [0, 0, 0],
342
      projectionMatrix,
343
      repeat: true,
344
      zoom:
345
        options.zoomSource === 'range'
6✔
346
          ? getZoomFromMap3DRange(map, center.lat, height, fovy)
347
          : getZoomFromMap3DCamera(map, center, height, fovy)
348
    }
349
  };
350
}
351

352
export function getScreenViewPropsFromMap3D(map: GoogleMapsMap3DElement) {
353
  const {width, height} = getMap3DSize(map);
2✔
354
  return {
2✔
355
    width,
356
    height,
357
    viewState: {
358
      target: [width / 2, height / 2, 0],
359
      zoom: 0
360
    }
361
  };
362
}
363

364
export function addMap3DCameraChangeListener(
365
  map: GoogleMapsMap3DElement,
366
  callback: () => void,
367
  options: {redrawWhileMoving?: boolean} = {}
5✔
368
): GoogleMapsEventListener {
369
  let cameraRedrawFrame = 0;
5✔
370
  let isCameraMoving = false;
5✔
371
  const redrawWhileMoving = options.redrawWhileMoving ?? true;
5✔
372

373
  const stopContinuousRedraw = () => {
5✔
374
    if (cameraRedrawFrame && globalThis.cancelAnimationFrame) {
8✔
375
      globalThis.cancelAnimationFrame(cameraRedrawFrame);
1✔
376
    }
377
    cameraRedrawFrame = 0;
8✔
378
  };
379
  const startContinuousRedraw = () => {
5✔
380
    if (cameraRedrawFrame || !globalThis.requestAnimationFrame) {
1!
NEW
381
      return;
×
382
    }
383
    const redraw = () => {
1✔
384
      callback();
1✔
385
      cameraRedrawFrame = globalThis.requestAnimationFrame(redraw);
1✔
386
    };
387
    cameraRedrawFrame = globalThis.requestAnimationFrame(redraw);
1✔
388
  };
389
  const handleCameraChange = () => {
5✔
390
    if (!redrawWhileMoving && isCameraMoving) {
3✔
391
      return;
2✔
392
    }
393
    callback();
1✔
394
  };
395
  const handleSteadyChange = (event: Event) => {
5✔
396
    const isSteady = getMap3DSteadyState(event);
6✔
397
    if (isSteady === false) {
6✔
398
      isCameraMoving = true;
3✔
399
      if (redrawWhileMoving) {
3✔
400
        callback();
1✔
401
        startContinuousRedraw();
1✔
402
      }
403
    } else if (isSteady === true) {
3!
404
      isCameraMoving = false;
3✔
405
      stopContinuousRedraw();
3✔
406
      callback();
3✔
NEW
407
    } else if (redrawWhileMoving) {
×
NEW
408
      callback();
×
409
    }
410
  };
411

412
  const listeners = [
5✔
413
    'gmp-centerchange',
414
    'gmp-rangechange',
415
    'gmp-headingchange',
416
    'gmp-tiltchange',
417
    'gmp-rollchange',
418
    'gmp-fovchange',
419
    'gmp-animationend'
420
  ].map(eventType => addDOMEventListener(map, eventType, handleCameraChange));
35✔
421
  listeners.push(addDOMEventListener(map, 'gmp-steadychange', handleSteadyChange));
5✔
422

423
  return {
5✔
424
    remove: () => {
425
      stopContinuousRedraw();
5✔
426
      for (const listener of listeners) {
5✔
427
        listener.remove();
40✔
428
      }
429
    }
430
  };
431
}
432

433
function getMap3DSteadyState(event: Event): boolean | undefined {
434
  const candidate = event as Event & {
6✔
435
    detail?: {isSteady?: boolean};
436
    isSteady?: boolean;
437
  };
438
  const isSteady = candidate.detail?.isSteady ?? candidate.isSteady;
6!
439
  return typeof isSteady === 'boolean' ? isSteady : undefined;
6!
440
}
441

442
/* eslint-disable max-statements */
443
/**
444
 * Get the current view state
445
 * @param map (google.maps.Map) - The parent Map instance
446
 * @param overlay (google.maps.OverlayView) - A maps Overlay instance
447
 */
448
// eslint-disable-next-line complexity
449
export function getViewPropsFromOverlay(map: google.maps.Map, overlay: google.maps.OverlayView) {
450
  const {width, height} = getMapSize(map);
1✔
451

452
  // Canvas position relative to draggable map's container depends on
453
  // overlayView's projection, not the map's. Have to use the center of the
454
  // map for this, not the top left, for the same reason as above.
455
  const projection = overlay.getProjection();
1✔
456

457
  const bounds = map.getBounds();
1✔
458
  if (!bounds) {
1!
459
    return {width, height, left: 0, top: 0};
×
460
  }
461

462
  const ne = bounds.getNorthEast();
1✔
463
  const sw = bounds.getSouthWest();
1✔
464
  const topRight = projection.fromLatLngToDivPixel(ne);
1✔
465
  const bottomLeft = projection.fromLatLngToDivPixel(sw);
1✔
466

467
  // google maps places overlays in a container anchored at the map center.
468
  // the container CSS is manipulated during dragging.
469
  // We need to update left/top of the deck canvas to match the base map.
470
  const centerLngLat = pixelToLngLat(projection, width / 2, height / 2);
1✔
471
  const centerH = new google.maps.LatLng(0, centerLngLat[0]);
1✔
472
  const centerContainerPx = projection.fromLatLngToContainerPixel(centerH);
1✔
473
  const centerDivPx = projection.fromLatLngToDivPixel(centerH);
1✔
474

475
  if (!topRight || !bottomLeft || !centerDivPx || !centerContainerPx) {
1!
476
    return {width, height, left: 0, top: 0};
×
477
  }
478
  const leftOffset = Math.round(centerDivPx.x - centerContainerPx.x);
1✔
479
  let topOffset = centerDivPx.y - centerContainerPx.y;
1✔
480

481
  const topLngLat = pixelToLngLat(projection, width / 2, 0);
1✔
482
  const bottomLngLat = pixelToLngLat(projection, width / 2, height);
1✔
483

484
  // Compute fractional center.
485
  let latitude = centerLngLat[1];
1✔
486
  const longitude = centerLngLat[0];
1✔
487

488
  // Adjust vertical offset - limit latitude
489
  if (Math.abs(latitude) > MAX_LATITUDE) {
1!
490
    latitude = latitude > 0 ? MAX_LATITUDE : -MAX_LATITUDE;
×
491
    const center = new google.maps.LatLng(latitude, longitude);
×
492
    const centerPx = projection.fromLatLngToContainerPixel(center);
×
493
    // @ts-ignore (TS2531) Object is possibly 'null'
494
    topOffset += centerPx.y - height / 2;
×
495
  }
496
  topOffset = Math.round(topOffset);
1✔
497

498
  // Compute fractional bearing
499
  const delta = new Vector2(topLngLat).sub(bottomLngLat);
1✔
500
  let bearing = (180 * delta.verticalAngle()) / Math.PI;
1✔
501
  if (bearing < 0) bearing += 360;
1!
502

503
  // Maps sometimes returns undefined instead of 0
504
  const heading = map.getHeading() || 0;
1✔
505

506
  let zoom = (map.getZoom() as number) - 1;
1✔
507

508
  let scale;
509

510
  if (bearing === 0) {
1!
511
    // At full world view (always unrotated) simply compare height, as diagonal
512
    // is incorrect due to multiple world copies
513
    scale = height ? (bottomLeft.y - topRight.y) / height : 1;
1!
514
  } else if (bearing === heading) {
×
515
    // Fractional zoom calculation only correct when bearing is not animating
516
    const viewDiagonal = new Vector2([topRight.x, topRight.y])
×
517
      .sub([bottomLeft.x, bottomLeft.y])
518
      .len();
519
    const mapDiagonal = new Vector2([width, -height]).len();
×
520
    scale = mapDiagonal ? viewDiagonal / mapDiagonal : 1;
×
521
  }
522

523
  // When resizing aggressively, occasionally ne and sw are the same points
524
  // See https://github.com/visgl/deck.gl/issues/4218
525
  zoom += Math.log2(scale || 1);
1!
526

527
  return {
1✔
528
    width,
529
    height,
530
    left: leftOffset,
531
    top: topOffset,
532
    zoom,
533
    bearing,
534
    pitch: map.getTilt(),
535
    latitude,
536
    longitude
537
  };
538
}
539

540
/* eslint-enable max-statements */
541

542
/**
543
 * Get the current view state
544
 * @param map (google.maps.Map) - The parent Map instance
545
 * @param transformer (google.maps.CoordinateTransformer) - A CoordinateTransformer instance
546
 */
547
export function getViewPropsFromCoordinateTransformer(
548
  map: google.maps.Map,
549
  transformer: google.maps.CoordinateTransformer
550
) {
551
  const {width, height} = getMapSize(map);
1✔
552
  const {center, heading: bearing, tilt: pitch, zoom} = transformer.getCameraParams();
1✔
553

554
  // Match Google projection matrix
555
  const fovy = 25;
1✔
556
  const aspect = height ? width / height : 1;
1!
557

558
  // Match depth range (crucial for correct z-sorting)
559
  const near = 0.75;
1✔
560
  const far = 300000000000000;
1✔
561
  // const far = Infinity;
562

563
  const projectionMatrix = new Matrix4().perspective({
1✔
564
    fovy: (fovy * Math.PI) / 180,
565
    aspect,
566
    near,
567
    far
568
  });
569
  const focalDistance = 0.5 * projectionMatrix[5];
1✔
570

571
  return {
1✔
572
    width,
573
    height,
574
    viewState: {
575
      altitude: focalDistance,
576
      bearing,
577
      latitude: center.lat(),
578
      longitude: center.lng(),
579
      pitch,
580
      projectionMatrix,
581
      repeat: true,
582
      zoom: zoom - 1
583
    }
584
  };
585
}
586

587
function getMapSize(map: google.maps.Map): {width: number; height: number} {
588
  // The map fills the container div unless it's in fullscreen mode
589
  // at which point the first child of the container is promoted
590
  const container = map.getDiv().firstChild as HTMLElement | null;
2✔
591
  return {
2✔
592
    // @ts-ignore (TS2531) Object is possibly 'null'
593
    width: container.offsetWidth,
594
    // @ts-ignore (TS2531) Object is possibly 'null'
595
    height: container.offsetHeight
596
  };
597
}
598

599
function getMap3DSize(map: GoogleMapsMap3DElement): {width: number; height: number} {
600
  const rect = map.getBoundingClientRect();
8✔
601
  return {
8✔
602
    width: map.clientWidth || rect.width,
8!
603
    height: map.clientHeight || rect.height
8!
604
  };
605
}
606

607
function getMap3DCanvasHost(canvas: HTMLCanvasElement): Element | null {
608
  const lightDOMHost = canvas.closest?.('gmp-map-3d');
1✔
609
  if (lightDOMHost) {
1!
NEW
610
    return lightDOMHost;
×
611
  }
612

613
  let node: Element | null = canvas;
1✔
614
  while (node) {
1✔
615
    const root = node.getRootNode?.();
2✔
616
    const shadowHost = root && 'host' in root ? (root as ShadowRoot).host : null;
2!
617
    if (!shadowHost) {
2!
NEW
618
      return null;
×
619
    }
620
    if (shadowHost.localName === 'gmp-map-3d') {
2✔
621
      return shadowHost;
1✔
622
    }
623
    node = shadowHost;
1✔
624
  }
625

NEW
626
  return null;
×
627
}
628

629
function getZoomFromMap3DCamera(
630
  map: GoogleMapsMap3DElement,
631
  center: {lat: number; altitude: number},
632
  height: number,
633
  fovy: number
634
): number {
635
  const cameraPosition = normalizeLatLng(map.cameraPosition);
2✔
636
  const cameraHeight = cameraPosition.altitude - center.altitude;
2✔
637
  const pitch = map.tilt || 0;
2!
638
  const pitchCosine = Math.cos((pitch * Math.PI) / 180);
2✔
639
  if (cameraHeight > 0 && height && pitchCosine > 0) {
2✔
640
    const focalDistance = 0.5 / Math.tan((fovy * Math.PI) / 360);
1✔
641
    const metersPerWorldUnit =
642
      (EARTH_CIRCUMFERENCE_METERS * Math.cos((center.lat * Math.PI) / 180)) / 512;
1✔
643
    const scale = (focalDistance * height * metersPerWorldUnit * pitchCosine) / cameraHeight;
1✔
644
    return Math.log2(scale);
1✔
645
  }
646

647
  return getZoomFromMap3DRange(map, center.lat, height, fovy);
1✔
648
}
649

650
function getZoomFromMap3DRange(
651
  map: GoogleMapsMap3DElement,
652
  latitude: number,
653
  height: number,
654
  fovy: number
655
): number {
656
  const range = map.range || 0;
5!
657
  if (!range || !height) {
5!
NEW
658
    return 1;
×
659
  }
660

661
  const visibleMeters = 2 * range * Math.tan((fovy * Math.PI) / 360);
5✔
662
  const metersPerPixel = visibleMeters / height;
5✔
663
  const metersPerPixelAtZoom0 =
664
    (EARTH_CIRCUMFERENCE_METERS * Math.cos((latitude * Math.PI) / 180)) / 512;
5✔
665

666
  return Math.log2(metersPerPixelAtZoom0 / metersPerPixel);
5✔
667
}
668

669
function normalizeLatLng(center?: GoogleMapsLatLngLike): {
670
  lat: number;
671
  lng: number;
672
  altitude: number;
673
} {
674
  if (!center) {
8✔
675
    return {lat: 0, lng: 0, altitude: 0};
1✔
676
  }
677

678
  const value = 'toJSON' in center && center.toJSON ? center.toJSON() : center;
7!
679
  const lat = typeof value.lat === 'function' ? value.lat() : value.lat;
8!
680
  const lng = typeof value.lng === 'function' ? value.lng() : value.lng;
8!
681
  const altitude = 'altitude' in value && typeof value.altitude === 'number' ? value.altitude : 0;
8!
682

683
  return {
8✔
684
    lat: lat || 0,
8!
685
    lng: lng || 0,
8!
686
    altitude
687
  };
688
}
689

690
function pixelToLngLat(
691
  projection: google.maps.MapCanvasProjection,
692
  x: number,
693
  y: number
694
): [longitude: number, latitude: number] {
695
  const point = new google.maps.Point(x, y);
3✔
696
  const latLng = projection.fromContainerPixelToLatLng(point);
3✔
697
  // @ts-ignore (TS2531) Object is possibly 'null'
698
  return [latLng.lng(), latLng.lat()];
3✔
699
}
700

701
function getDOMEventPixel(
702
  event: Event | google.maps.MapMouseEvent,
703
  map: GoogleMapsMap3DElement
704
): {x: number; y: number} {
NEW
705
  if ('pixel' in event && event.pixel) {
×
NEW
706
    return event.pixel as {x: number; y: number};
×
707
  }
708

NEW
709
  const srcEvent = ('srcEvent' in event ? event.srcEvent : event) as MouseEvent;
×
NEW
710
  const rect = map.getBoundingClientRect();
×
NEW
711
  return {
×
712
    x: srcEvent.clientX - rect.left,
713
    y: srcEvent.clientY - rect.top
714
  };
715
}
716

717
function getEventPixel(event, deck: Deck): {x: number; y: number} {
718
  if (event.pixel) {
×
719
    return event.pixel;
×
720
  }
721
  // event.pixel may not exist when clicking on a POI
722
  // https://developers.google.com/maps/documentation/javascript/reference/map#MouseEvent
723
  const point = deck.getViewports()[0].project([event.latLng.lng(), event.latLng.lat()]);
×
724
  return {
×
725
    x: point[0],
726
    y: point[1]
727
  };
728
}
729

730
function addDOMEventListener(
731
  target: EventTarget,
732
  eventType: string,
733
  callback: (event: Event) => void
734
): GoogleMapsEventListener {
735
  target.addEventListener(eventType, callback);
55✔
736
  return {
55✔
737
    remove: () => target.removeEventListener(eventType, callback)
55✔
738
  };
739
}
740

741
// Triggers picking on a mouse event
742
function handleMouseEvent(deck: Deck, type: string, event) {
743
  if (!deck.isInitialized) {
×
744
    return;
×
745
  }
746

747
  const mockEvent: Record<string, any> = {
×
748
    type,
749
    offsetCenter: getEventPixel(event, deck),
750
    srcEvent: event
751
  };
752

753
  switch (type) {
×
754
    case 'click':
755
    case 'rightclick':
756
      mockEvent.type = 'click';
×
757
      mockEvent.tapCount = 1;
×
758
      // Hack: because we do not listen to pointer down, perform picking now
759
      deck._onPointerDown(mockEvent as MjolnirPointerEvent);
×
760
      deck._onEvent(mockEvent as MjolnirGestureEvent);
×
761
      break;
×
762

763
    case 'dblclick':
764
      mockEvent.type = 'click';
×
765
      mockEvent.tapCount = 2;
×
766
      deck._onEvent(mockEvent as MjolnirGestureEvent);
×
767
      break;
×
768

769
    case 'mousemove':
770
      mockEvent.type = 'pointermove';
×
771
      deck._onPointerMove(mockEvent as MjolnirPointerEvent);
×
772
      break;
×
773

774
    case 'mouseout':
775
      mockEvent.type = 'pointerleave';
×
776
      deck._onPointerMove(mockEvent as MjolnirPointerEvent);
×
777
      break;
×
778

779
    default:
780
      return;
×
781
  }
782
}
783

784
function handleMap3DEvent(
785
  deck: Deck,
786
  type: string,
787
  event: Event | google.maps.MapMouseEvent,
788
  map: GoogleMapsMap3DElement
789
) {
NEW
790
  if (!deck.isInitialized) {
×
NEW
791
    return;
×
792
  }
793

NEW
794
  const mockEvent: Record<string, any> = {
×
795
    type,
796
    offsetCenter: getDOMEventPixel(event, map),
797
    srcEvent: event
798
  };
799

NEW
800
  switch (type) {
×
801
    case 'click':
802
    case 'rightclick':
NEW
803
      mockEvent.type = 'click';
×
NEW
804
      mockEvent.tapCount = 1;
×
NEW
805
      deck._onPointerDown(mockEvent as MjolnirPointerEvent);
×
NEW
806
      deck._onEvent(mockEvent as MjolnirGestureEvent);
×
NEW
807
      break;
×
808

809
    case 'dblclick':
NEW
810
      mockEvent.type = 'click';
×
NEW
811
      mockEvent.tapCount = 2;
×
NEW
812
      deck._onEvent(mockEvent as MjolnirGestureEvent);
×
NEW
813
      break;
×
814

815
    case 'mousemove':
NEW
816
      mockEvent.type = 'pointermove';
×
NEW
817
      deck._onPointerMove(mockEvent as MjolnirPointerEvent);
×
NEW
818
      break;
×
819

820
    case 'mouseout':
NEW
821
      mockEvent.type = 'pointerleave';
×
NEW
822
      deck._onPointerMove(mockEvent as MjolnirPointerEvent);
×
NEW
823
      break;
×
824

825
    default:
NEW
826
      return;
×
827
  }
828
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc