Skip to content

Commit eba622a

Browse files
authored
feat(google-maps): add ground overlay component (#19143)
Add a component that will allow developers to overlay an image over a Google Map, following the Google Maps JavaScript API as in developers.google.com/maps/documentation/javascript/reference/image-overlay
1 parent bb30a3a commit eba622a

File tree

8 files changed

+386
-1
lines changed

8 files changed

+386
-1
lines changed

src/dev-app/google-map/google-map-demo.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<map-polygon *ngIf="isPolygonDisplayed" [options]="polygonOptions"></map-polygon>
1919
<map-rectangle *ngIf="isRectangleDisplayed" [options]="rectangleOptions"></map-rectangle>
2020
<map-circle *ngIf="isCircleDisplayed" [options]="circleOptions"></map-circle>
21+
<map-ground-overlay *ngIf="isGroundOverlayDisplayed"
22+
[url]="groundOverlayUrl"
23+
[bounds]="groundOverlayBounds"></map-ground-overlay>
2124
</google-map>
2225

2326
<p><label>Latitude:</label> {{display?.lat}}</p>
@@ -91,4 +94,11 @@
9194
</label>
9295
</div>
9396

97+
<div>
98+
<label for="ground-overlay-checkbox">
99+
Toggle Ground Overlay
100+
<input type="checkbox" (click)="toggleGroundOverlayDisplay()">
101+
</label>
102+
</div>
103+
94104
</div>

src/dev-app/google-map/google-map-demo.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export class GoogleMapDemo {
6565
isCircleDisplayed = false;
6666
circleOptions: google.maps.CircleOptions =
6767
{center: CIRCLE_CENTER, radius: CIRCLE_RADIUS, strokeColor: 'grey', strokeOpacity: 0.8};
68+
isGroundOverlayDisplayed = false;
69+
groundOverlayUrl = 'https://angular.io/assets/images/logos/angular/angular.svg';
70+
groundOverlayBounds = RECTANGLE_BOUNDS;
6871

6972
mapTypeId: google.maps.MapTypeId;
7073
mapTypeIds = [
@@ -142,4 +145,8 @@ export class GoogleMapDemo {
142145
mapTypeChanged(event: Event) {
143146
this.mapTypeId = (event.target as HTMLSelectElement).value as unknown as google.maps.MapTypeId;
144147
}
148+
149+
toggleGroundOverlayDisplay() {
150+
this.isGroundOverlayDisplayed = !this.isGroundOverlayDisplayed;
151+
}
145152
}

src/google-maps/google-maps-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {NgModule} from '@angular/core';
1010

1111
import {GoogleMap} from './google-map/google-map';
1212
import {MapCircle} from './map-circle/map-circle';
13+
import {MapGroundOverlay} from './map-ground-overlay/map-ground-overlay';
1314
import {MapInfoWindow} from './map-info-window/map-info-window';
1415
import {MapMarker} from './map-marker/map-marker';
1516
import {MapPolygon} from './map-polygon/map-polygon';
@@ -19,6 +20,7 @@ import {MapRectangle} from './map-rectangle/map-rectangle';
1920
const COMPONENTS = [
2021
GoogleMap,
2122
MapCircle,
23+
MapGroundOverlay,
2224
MapInfoWindow,
2325
MapMarker,
2426
MapPolygon,
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import {Component, ViewChild} from '@angular/core';
2+
import {async, TestBed} from '@angular/core/testing';
3+
import {By} from '@angular/platform-browser';
4+
5+
import {DEFAULT_OPTIONS} from '../google-map/google-map';
6+
import {GoogleMapsModule} from '../google-maps-module';
7+
import {
8+
createGroundOverlayConstructorSpy,
9+
createGroundOverlaySpy,
10+
createMapConstructorSpy,
11+
createMapSpy,
12+
TestingWindow,
13+
} from '../testing/fake-google-map-utils';
14+
15+
import {MapGroundOverlay} from './map-ground-overlay';
16+
17+
describe('MapGroundOverlay', () => {
18+
let mapSpy: jasmine.SpyObj<google.maps.Map>;
19+
const url = 'www.testimg.com/img.jpg';
20+
const bounds: google.maps.LatLngBoundsLiteral = {east: 3, north: 5, west: -3, south: -5};
21+
const clickable = true;
22+
const opacity = 0.5;
23+
const groundOverlayOptions: google.maps.GroundOverlayOptions = {clickable, opacity};
24+
25+
beforeEach(async(() => {
26+
TestBed.configureTestingModule({
27+
imports: [GoogleMapsModule],
28+
declarations: [TestApp],
29+
});
30+
}));
31+
32+
beforeEach(() => {
33+
TestBed.compileComponents();
34+
35+
mapSpy = createMapSpy(DEFAULT_OPTIONS);
36+
createMapConstructorSpy(mapSpy).and.callThrough();
37+
});
38+
39+
afterEach(() => {
40+
const testingWindow: TestingWindow = window;
41+
delete testingWindow.google;
42+
});
43+
44+
it('initializes a Google Map Ground Overlay', () => {
45+
const groundOverlaySpy = createGroundOverlaySpy(url, bounds, groundOverlayOptions);
46+
const groundOverlayConstructorSpy =
47+
createGroundOverlayConstructorSpy(groundOverlaySpy).and.callThrough();
48+
49+
const fixture = TestBed.createComponent(TestApp);
50+
fixture.componentInstance.url = url;
51+
fixture.componentInstance.bounds = bounds;
52+
fixture.componentInstance.clickable = clickable;
53+
fixture.componentInstance.opacity = opacity;
54+
fixture.detectChanges();
55+
56+
expect(groundOverlayConstructorSpy).toHaveBeenCalledWith(url, bounds, groundOverlayOptions);
57+
expect(groundOverlaySpy.setMap).toHaveBeenCalledWith(mapSpy);
58+
});
59+
60+
it('has an error if required url or bounds are not provided', () => {
61+
expect(() => {
62+
const fixture = TestBed.createComponent(TestApp);
63+
fixture.detectChanges();
64+
}).toThrow(new Error('An image url is required'));
65+
});
66+
67+
it('exposes methods that provide information about the Ground Overlay', () => {
68+
const groundOverlaySpy = createGroundOverlaySpy(url, bounds, groundOverlayOptions);
69+
createGroundOverlayConstructorSpy(groundOverlaySpy).and.callThrough();
70+
71+
const fixture = TestBed.createComponent(TestApp);
72+
const groundOverlayComponent = fixture.debugElement.query(By.directive(
73+
MapGroundOverlay))!.injector.get<MapGroundOverlay>(MapGroundOverlay);
74+
fixture.componentInstance.url = url;
75+
fixture.componentInstance.bounds = bounds;
76+
fixture.componentInstance.opacity = opacity;
77+
fixture.detectChanges();
78+
79+
groundOverlayComponent.getBounds();
80+
expect(groundOverlaySpy.getBounds).toHaveBeenCalled();
81+
82+
groundOverlaySpy.getOpacity.and.returnValue(opacity);
83+
expect(groundOverlayComponent.getOpacity()).toBe(opacity);
84+
85+
groundOverlaySpy.getUrl.and.returnValue(url);
86+
expect(groundOverlayComponent.getUrl()).toBe(url);
87+
});
88+
89+
it('initializes Ground Overlay event handlers', () => {
90+
const groundOverlaySpy = createGroundOverlaySpy(url, bounds, groundOverlayOptions);
91+
createGroundOverlayConstructorSpy(groundOverlaySpy).and.callThrough();
92+
93+
const addSpy = groundOverlaySpy.addListener;
94+
const fixture = TestBed.createComponent(TestApp);
95+
fixture.componentInstance.url = url;
96+
fixture.componentInstance.bounds = bounds;
97+
fixture.detectChanges();
98+
99+
expect(addSpy).toHaveBeenCalledWith('click', jasmine.any(Function));
100+
expect(addSpy).not.toHaveBeenCalledWith('dblclick', jasmine.any(Function));
101+
});
102+
103+
it('should be able to add an event listener after init', () => {
104+
const groundOverlaySpy = createGroundOverlaySpy(url, bounds, groundOverlayOptions);
105+
createGroundOverlayConstructorSpy(groundOverlaySpy).and.callThrough();
106+
107+
const addSpy = groundOverlaySpy.addListener;
108+
const fixture = TestBed.createComponent(TestApp);
109+
fixture.componentInstance.url = url;
110+
fixture.componentInstance.bounds = bounds;
111+
fixture.detectChanges();
112+
113+
expect(addSpy).not.toHaveBeenCalledWith('dblclick', jasmine.any(Function));
114+
115+
// Pick an event that isn't bound in the template.
116+
const subscription = fixture.componentInstance.groundOverlay.mapDblclick.subscribe();
117+
fixture.detectChanges();
118+
119+
expect(addSpy).toHaveBeenCalledWith('dblclick', jasmine.any(Function));
120+
subscription.unsubscribe();
121+
});
122+
});
123+
124+
@Component({
125+
selector: 'test-app',
126+
template: `<google-map>
127+
<map-ground-overlay [url]="url"
128+
[bounds]="bounds"
129+
[clickable]="clickable"
130+
[opacity]="opacity"
131+
(mapClick)="handleClick()">
132+
</map-ground-overlay>
133+
</google-map>`,
134+
})
135+
class TestApp {
136+
@ViewChild(MapGroundOverlay) groundOverlay: MapGroundOverlay;
137+
url!: string;
138+
bounds!: google.maps.LatLngBoundsLiteral;
139+
clickable = false;
140+
opacity = 1;
141+
142+
handleClick() {}
143+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// Workaround for: https://github.com/bazelbuild/rules_nodejs/issues/1265
10+
/// <reference types="googlemaps" />
11+
12+
import {Directive, Input, NgZone, OnDestroy, OnInit, Output} from '@angular/core';
13+
import {BehaviorSubject, Observable, Subject} from 'rxjs';
14+
import {map, take, takeUntil} from 'rxjs/operators';
15+
16+
import {GoogleMap} from '../google-map/google-map';
17+
import {MapEventManager} from '../map-event-manager';
18+
19+
/**
20+
* Angular component that renders a Google Maps Ground Overlay via the Google Maps JavaScript API.
21+
*
22+
* See developers.google.com/maps/documentation/javascript/reference/image-overlay#GroundOverlay
23+
*/
24+
@Directive({
25+
selector: 'map-ground-overlay',
26+
})
27+
export class MapGroundOverlay implements OnInit, OnDestroy {
28+
private _eventManager = new MapEventManager(this._ngZone);
29+
30+
private readonly _opacity = new BehaviorSubject<number>(1);
31+
private readonly _destroyed = new Subject<void>();
32+
33+
/**
34+
* The underlying google.maps.GroundOverlay object.
35+
*
36+
* See developers.google.com/maps/documentation/javascript/reference/image-overlay#GroundOverlay
37+
*/
38+
groundOverlay?: google.maps.GroundOverlay;
39+
40+
@Input() url!: string; // Asserted in ngOnInit.
41+
42+
@Input()
43+
bounds!: google.maps.LatLngBounds|google.maps.LatLngBoundsLiteral; // Asserted in ngOnInit.
44+
45+
@Input() clickable = false;
46+
47+
@Input()
48+
set opacity(opacity: number) {
49+
this._opacity.next(opacity);
50+
}
51+
52+
/**
53+
* See
54+
* developers.google.com/maps/documentation/javascript/reference/image-overlay#GroundOverlay.click
55+
*/
56+
@Output()
57+
mapClick: Observable<google.maps.MouseEvent> =
58+
this._eventManager.getLazyEmitter<google.maps.MouseEvent>('click');
59+
60+
/**
61+
* See
62+
* developers.google.com/maps/documentation/javascript/reference/image-overlay
63+
* #GroundOverlay.dblclick
64+
*/
65+
@Output()
66+
mapDblclick: Observable<google.maps.MouseEvent> =
67+
this._eventManager.getLazyEmitter<google.maps.MouseEvent>('dblclick');
68+
69+
constructor(private readonly _map: GoogleMap, private readonly _ngZone: NgZone) {}
70+
71+
ngOnInit() {
72+
if (!this.url) {
73+
throw Error('An image url is required');
74+
}
75+
if (!this.bounds) {
76+
throw Error('Image bounds are required');
77+
}
78+
if (this._map._isBrowser) {
79+
this._combineOptions().pipe(take(1)).subscribe(options => {
80+
// Create the object outside the zone so its events don't trigger change detection.
81+
// We'll bring it back in inside the `MapEventManager` only for the events that the
82+
// user has subscribed to.
83+
this._ngZone.runOutsideAngular(() => {
84+
this.groundOverlay = new google.maps.GroundOverlay(this.url, this.bounds, options);
85+
});
86+
this._assertInitialized();
87+
this.groundOverlay!.setMap(this._map.googleMap!);
88+
this._eventManager.setTarget(this.groundOverlay);
89+
});
90+
91+
this._watchForOpacityChanges();
92+
}
93+
}
94+
95+
ngOnDestroy() {
96+
this._eventManager.destroy();
97+
this._destroyed.next();
98+
this._destroyed.complete();
99+
if (this.groundOverlay) {
100+
this.groundOverlay.setMap(null);
101+
}
102+
}
103+
104+
/**
105+
* See
106+
* developers.google.com/maps/documentation/javascript/reference/image-overlay
107+
* #GroundOverlay.getBounds
108+
*/
109+
getBounds(): google.maps.LatLngBounds {
110+
this._assertInitialized();
111+
return this.groundOverlay!.getBounds();
112+
}
113+
114+
/**
115+
* See
116+
* developers.google.com/maps/documentation/javascript/reference/image-overlay
117+
* #GroundOverlay.getOpacity
118+
*/
119+
getOpacity(): number {
120+
this._assertInitialized();
121+
return this.groundOverlay!.getOpacity();
122+
}
123+
124+
/**
125+
* See
126+
* developers.google.com/maps/documentation/javascript/reference/image-overlay
127+
* #GroundOverlay.getUrl
128+
*/
129+
getUrl(): string {
130+
this._assertInitialized();
131+
return this.groundOverlay!.getUrl();
132+
}
133+
134+
private _combineOptions(): Observable<google.maps.GroundOverlayOptions> {
135+
return this._opacity.pipe(map(opacity => {
136+
const combinedOptions: google.maps.GroundOverlayOptions = {
137+
clickable: this.clickable,
138+
opacity,
139+
};
140+
return combinedOptions;
141+
}));
142+
}
143+
144+
private _watchForOpacityChanges() {
145+
this._opacity.pipe(takeUntil(this._destroyed)).subscribe(opacity => {
146+
if (opacity) {
147+
this._assertInitialized();
148+
this.groundOverlay!.setOpacity(opacity);
149+
}
150+
});
151+
}
152+
153+
private _assertInitialized() {
154+
if (!this._map.googleMap) {
155+
throw Error(
156+
'Cannot access Google Map information before the API has been initialized. ' +
157+
'Please wait for the API to load before trying to interact with it.');
158+
}
159+
if (!this.groundOverlay) {
160+
throw Error(
161+
'Cannot interact with a Google Map GroundOverlay before it has been initialized. ' +
162+
'Please wait for the GroundOverlay to load before trying to interact with it.');
163+
}
164+
}
165+
}

src/google-maps/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
export {GoogleMap} from './google-map/google-map';
1010
export {GoogleMapsModule} from './google-maps-module';
1111
export {MapCircle} from './map-circle/map-circle';
12+
export {MapGroundOverlay} from './map-ground-overlay/map-ground-overlay';
1213
export {MapInfoWindow} from './map-info-window/map-info-window';
1314
export {MapMarker} from './map-marker/map-marker';
1415
export {MapPolygon} from './map-polygon/map-polygon';

0 commit comments

Comments
 (0)