From 39e69c96c8361ed524b59d1232dafae361cb1d5a Mon Sep 17 00:00:00 2001 From: Matthew Ehrlich Date: Thu, 20 Feb 2020 15:09:35 -0800 Subject: [PATCH 1/2] feat(google-maps): Add Circle component Adds a component to draw a circle onto a Google Map. --- src/dev-app/google-map/google-map-demo.html | 14 + src/dev-app/google-map/google-map-demo.ts | 24 ++ src/google-maps/google-maps-module.ts | 4 +- src/google-maps/map-circle/map-circle.spec.ts | 182 ++++++++++++ src/google-maps/map-circle/map-circle.ts | 268 ++++++++++++++++++ src/google-maps/public-api.ts | 1 + .../testing/fake-google-map-utils.ts | 32 +++ 7 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 src/google-maps/map-circle/map-circle.spec.ts create mode 100644 src/google-maps/map-circle/map-circle.ts diff --git a/src/dev-app/google-map/google-map-demo.html b/src/dev-app/google-map/google-map-demo.html index 6e3f905a87e7..825a31a10ce3 100644 --- a/src/dev-app/google-map/google-map-demo.html +++ b/src/dev-app/google-map/google-map-demo.html @@ -16,6 +16,7 @@ +

{{display?.lat}}

@@ -66,4 +67,17 @@ +
+ +
+
+ +
+ diff --git a/src/dev-app/google-map/google-map-demo.ts b/src/dev-app/google-map/google-map-demo.ts index b0afc7ab3d30..2fc1c608a68f 100644 --- a/src/dev-app/google-map/google-map-demo.ts +++ b/src/dev-app/google-map/google-map-demo.ts @@ -8,6 +8,7 @@ import {Component, ViewChild} from '@angular/core'; import { + MapCircle, MapInfoWindow, MapMarker, MapPolygon, @@ -28,6 +29,12 @@ const RECTANGLE_BOUNDS: google.maps.LatLngBoundsLiteral = { south: -5 }; +const CIRCLE_CENTER: google.maps.LatLngLiteral = { + lat: 19, + lng: 20 +}; +const CIRCLE_RADIUS = 500000; + /** Demo Component for @angular/google-maps/map */ @Component({ selector: 'google-map-demo', @@ -39,6 +46,7 @@ export class GoogleMapDemo { @ViewChild(MapPolyline) polyline: MapPolyline; @ViewChild(MapPolygon) polygon: MapPolygon; @ViewChild(MapRectangle) rectangle: MapRectangle; + @ViewChild(MapCircle) circle: MapCircle; center = {lat: 24, lng: 12}; markerOptions = {draggable: false}; @@ -54,6 +62,9 @@ export class GoogleMapDemo { isRectangleDisplayed = false; rectangleOptions: google.maps .RectangleOptions = {bounds: RECTANGLE_BOUNDS, strokeColor: 'grey', strokeOpacity: 0.8}; + isCircleDisplayed = false; + circleOptions: google.maps.CircleOptions = + {center: CIRCLE_CENTER, radius: CIRCLE_RADIUS, strokeColor: 'grey', strokeOpacity: 0.8}; handleClick(event: google.maps.MouseEvent) { this.markerPositions.push(event.latLng.toJSON()); @@ -106,4 +117,17 @@ export class GoogleMapDemo { bounds: this.rectangle.getBounds() }; } + + toggleCircleDisplay() { + this.isCircleDisplayed = !this.isCircleDisplayed; + } + + toggleEditableCircle() { + this.circleOptions = { + ...this.circleOptions, + editable: !this.circleOptions.editable, + center: this.circle.getCenter(), + radius: this.circle.getRadius(), + }; + } } diff --git a/src/google-maps/google-maps-module.ts b/src/google-maps/google-maps-module.ts index 97b2b6a150c5..7fbec356a089 100644 --- a/src/google-maps/google-maps-module.ts +++ b/src/google-maps/google-maps-module.ts @@ -9,6 +9,7 @@ import {NgModule} from '@angular/core'; import {GoogleMap} from './google-map/google-map'; +import {MapCircle} from './map-circle/map-circle'; import {MapInfoWindow} from './map-info-window/map-info-window'; import {MapMarker} from './map-marker/map-marker'; import {MapPolygon} from './map-polygon/map-polygon'; @@ -17,10 +18,11 @@ import {MapRectangle} from './map-rectangle/map-rectangle'; const COMPONENTS = [ GoogleMap, + MapCircle, MapInfoWindow, MapMarker, - MapPolyline, MapPolygon, + MapPolyline, MapRectangle, ]; diff --git a/src/google-maps/map-circle/map-circle.spec.ts b/src/google-maps/map-circle/map-circle.spec.ts new file mode 100644 index 000000000000..3187f7ce1952 --- /dev/null +++ b/src/google-maps/map-circle/map-circle.spec.ts @@ -0,0 +1,182 @@ +import {Component, ViewChild} from '@angular/core'; +import {async, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; + +import {DEFAULT_OPTIONS, UpdatedGoogleMap} from '../google-map/google-map'; +import {GoogleMapsModule} from '../google-maps-module'; +import { + createCircleConstructorSpy, + createCircleSpy, + createMapConstructorSpy, + createMapSpy, + TestingWindow, +} from '../testing/fake-google-map-utils'; + +import {MapCircle} from './map-circle'; + +describe('MapCircle', () => { + let mapSpy: jasmine.SpyObj; + let circleCenter: google.maps.LatLngLiteral; + let circleRadius: number; + let circleOptions: google.maps.CircleOptions; + + beforeEach(async(() => { + circleCenter = {lat: 30, lng: 15}; + circleRadius = 15; + circleOptions = { + center: circleCenter, + radius: circleRadius, + strokeColor: 'grey', + strokeOpacity: 0.8, + }; + TestBed.configureTestingModule({ + imports: [GoogleMapsModule], + declarations: [TestApp], + }); + })); + + beforeEach(() => { + TestBed.compileComponents(); + + mapSpy = createMapSpy(DEFAULT_OPTIONS); + createMapConstructorSpy(mapSpy).and.callThrough(); + }); + + afterEach(() => { + const testingWindow: TestingWindow = window; + delete testingWindow.google; + }); + + it('initializes a Google Map Circle', () => { + const circleSpy = createCircleSpy({}); + const circleConstructorSpy = createCircleConstructorSpy(circleSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + + expect(circleConstructorSpy).toHaveBeenCalledWith({center: undefined, radius: undefined}); + expect(circleSpy.setMap).toHaveBeenCalledWith(mapSpy); + }); + + it('sets center and radius from input', () => { + const center: google.maps.LatLngLiteral = {lat: 3, lng: 5}; + const radius = 15; + const options: google.maps.CircleOptions = {center, radius}; + const circleSpy = createCircleSpy(options); + const circleConstructorSpy = createCircleConstructorSpy(circleSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.componentInstance.center = center; + fixture.componentInstance.radius = radius; + fixture.detectChanges(); + + expect(circleConstructorSpy).toHaveBeenCalledWith(options); + }); + + it('gives precedence to other inputs over options', () => { + const center: google.maps.LatLngLiteral = {lat: 3, lng: 5}; + const radius = 15; + const expectedOptions: google.maps.CircleOptions = {...circleOptions, center, radius}; + const circleSpy = createCircleSpy(expectedOptions); + const circleConstructorSpy = createCircleConstructorSpy(circleSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.componentInstance.options = circleOptions; + fixture.componentInstance.center = center; + fixture.componentInstance.radius = radius; + fixture.detectChanges(); + + expect(circleConstructorSpy).toHaveBeenCalledWith(expectedOptions); + }); + + it('exposes methods that provide information about the Circle', () => { + const circleSpy = createCircleSpy(circleOptions); + createCircleConstructorSpy(circleSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + const circleComponent = + fixture.debugElement.query(By.directive(MapCircle))!.injector.get(MapCircle); + fixture.detectChanges(); + + circleComponent.getCenter(); + expect(circleSpy.getCenter).toHaveBeenCalled(); + + circleSpy.getRadius.and.returnValue(10); + expect(circleComponent.getRadius()).toBe(10); + + circleSpy.getDraggable.and.returnValue(true); + expect(circleComponent.getDraggable()).toBe(true); + + circleSpy.getEditable.and.returnValue(true); + expect(circleComponent.getEditable()).toBe(true); + + circleSpy.getVisible.and.returnValue(true); + expect(circleComponent.getVisible()).toBe(true); + }); + + it('initializes Circle event handlers', () => { + const circleSpy = createCircleSpy(circleOptions); + createCircleConstructorSpy(circleSpy).and.callThrough(); + + const addSpy = circleSpy.addListener; + const fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + + expect(addSpy).toHaveBeenCalledWith('center_changed', jasmine.any(Function)); + expect(addSpy).toHaveBeenCalledWith('click', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('dblclick', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('drag', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('dragend', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('dragstart', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('mousedown', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('mousemove', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('mouseout', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('mouseover', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('mouseup', jasmine.any(Function)); + expect(addSpy).not.toHaveBeenCalledWith('radius_changed', jasmine.any(Function)); + expect(addSpy).toHaveBeenCalledWith('rightclick', jasmine.any(Function)); + }); + + it('should be able to add an event listener after init', () => { + const circleSpy = createCircleSpy(circleOptions); + createCircleConstructorSpy(circleSpy).and.callThrough(); + + const addSpy = circleSpy.addListener; + const fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + + expect(addSpy).not.toHaveBeenCalledWith('dragend', jasmine.any(Function)); + + // Pick an event that isn't bound in the template. + const subscription = fixture.componentInstance.circle.circleDragend.subscribe(); + fixture.detectChanges(); + + expect(addSpy).toHaveBeenCalledWith('dragend', jasmine.any(Function)); + subscription.unsubscribe(); + }); +}); + +@Component({ + selector: 'test-app', + template: ` + + + `, +}) +class TestApp { + @ViewChild(MapCircle) circle: MapCircle; + options?: google.maps.CircleOptions; + center?: google.maps.LatLngLiteral; + radius?: number; + + handleCenterChange() {} + + handleClick() {} + + handleRightclick() {} +} diff --git a/src/google-maps/map-circle/map-circle.ts b/src/google-maps/map-circle/map-circle.ts new file mode 100644 index 000000000000..4e0cff977637 --- /dev/null +++ b/src/google-maps/map-circle/map-circle.ts @@ -0,0 +1,268 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +// Workaround for: https://github.com/bazelbuild/rules_nodejs/issues/1265 +/// + +import {Directive, Input, NgZone, OnDestroy, OnInit, Output} from '@angular/core'; +import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs'; +import {map, take, takeUntil} from 'rxjs/operators'; + +import {GoogleMap} from '../google-map/google-map'; +import {MapEventManager} from '../map-event-manager'; + +/** + * Angular component that renders a Google Maps Circle via the Google Maps JavaScript API. + * @see developers.google.com/maps/documentation/javascript/reference/polygon#Circle + */ +@Directive({ + selector: 'map-circle', +}) +export class MapCircle implements OnInit, OnDestroy { + private _eventManager = new MapEventManager(this._ngZone); + private readonly _options = new BehaviorSubject({}); + private readonly _center = + new BehaviorSubject(undefined); + private readonly _radius = new BehaviorSubject(undefined); + + private readonly _destroyed = new Subject(); + + /** + * Underlying google.maps.Circle object. + * + * @see developers.google.com/maps/documentation/javascript/reference/polygon#Circle + */ + circle: google.maps.Circle; // initialized in ngOnInit + + @Input() + set options(options: google.maps.CircleOptions) { + this._options.next(options || {}); + } + + @Input() + set center(center: google.maps.LatLng|google.maps.LatLngLiteral) { + this._center.next(center); + } + + @Input() + set radius(radius: number) { + this._radius.next(radius); + } + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.center_changed + */ + @Output() + centerChanged: Observable = this._eventManager.getLazyEmitter('center_changed'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.click + */ + @Output() + circleClick: Observable = + this._eventManager.getLazyEmitter('click'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.dblclick + */ + @Output() + circleDblclick: Observable = + this._eventManager.getLazyEmitter('dblclick'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.drag + */ + @Output() + circleDrag: Observable = + this._eventManager.getLazyEmitter('drag'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.dragend + */ + @Output() + circleDragend: Observable = + this._eventManager.getLazyEmitter('dragend'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.dragstart + */ + @Output() + circleDragstart: Observable = + this._eventManager.getLazyEmitter('dragstart'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.mousedown + */ + @Output() + circleMousedown: Observable = + this._eventManager.getLazyEmitter('mousedown'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.mousemove + */ + @Output() + circleMousemove: Observable = + this._eventManager.getLazyEmitter('mousemove'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.mouseout + */ + @Output() + circleMouseout: Observable = + this._eventManager.getLazyEmitter('mouseout'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.mouseover + */ + @Output() + circleMouseover: Observable = + this._eventManager.getLazyEmitter('mouseover'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.mouseup + */ + @Output() + circleMouseup: Observable = + this._eventManager.getLazyEmitter('mouseup'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.radius_changed + */ + @Output() + radiusChanged: Observable = this._eventManager.getLazyEmitter('radius_changed'); + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.rightclick + */ + @Output() + circleRightclick: Observable = + this._eventManager.getLazyEmitter('rightclick'); + + constructor(private readonly _map: GoogleMap, private readonly _ngZone: NgZone) {} + + ngOnInit() { + const combinedOptionsChanges = this._combineOptions(); + + combinedOptionsChanges.pipe(take(1)).subscribe(options => { + // Create the object outside the zone so its events don't trigger change detection. + // We'll bring it back in inside the `MapEventManager` only for the events that the + // user has subscribed to. + this._ngZone.runOutsideAngular(() => { + this.circle = new google.maps.Circle(options); + }); + this.circle.setMap(this._map._googleMap); + this._eventManager.setTarget(this.circle); + }); + + this._watchForOptionsChanges(); + this._watchForCenterChanges(); + this._watchForRadiusChanges(); + } + + ngOnDestroy() { + this._eventManager.destroy(); + this._destroyed.next(); + this._destroyed.complete(); + this.circle.setMap(null); + } + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.getBounds + */ + getBounds(): google.maps.LatLngBounds { + return this.circle.getBounds(); + } + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.getCenter + */ + getCenter(): google.maps.LatLng { + return this.circle.getCenter(); + } + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.getDraggable + */ + getDraggable(): boolean { + return this.circle.getDraggable(); + } + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.getEditable + */ + getEditable(): boolean { + return this.circle.getEditable(); + } + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.getCenter + */ + getRadius(): number { + return this.circle.getRadius(); + } + + /** + * @see + * developers.google.com/maps/documentation/javascript/reference/polygon#Circle.getVisible + */ + getVisible(): boolean { + return this.circle.getVisible(); + } + + private _combineOptions(): Observable { + return combineLatest([this._options, this._center, this._radius]) + .pipe(map(([options, center, radius]) => { + const combinedOptions: google.maps.CircleOptions = { + ...options, + center: center || options.center, + radius: radius !== undefined ? radius : options.radius, + }; + return combinedOptions; + })); + } + + private _watchForOptionsChanges() { + this._options.pipe(takeUntil(this._destroyed)).subscribe(options => { + this.circle.setOptions(options); + }); + } + + private _watchForCenterChanges() { + this._center.pipe(takeUntil(this._destroyed)).subscribe(center => { + if (center) { + this.circle.setCenter(center); + } + }); + } + + private _watchForRadiusChanges() { + this._radius.pipe(takeUntil(this._destroyed)).subscribe(radius => { + if (radius !== undefined) { + this.circle.setRadius(radius); + } + }); + } +} diff --git a/src/google-maps/public-api.ts b/src/google-maps/public-api.ts index c7a297c04a22..b39c3a07695f 100644 --- a/src/google-maps/public-api.ts +++ b/src/google-maps/public-api.ts @@ -8,6 +8,7 @@ export {GoogleMap} from './google-map/google-map'; export {GoogleMapsModule} from './google-maps-module'; +export {MapCircle} from './map-circle/map-circle'; export {MapInfoWindow} from './map-info-window/map-info-window'; export {MapMarker} from './map-marker/map-marker'; export {MapPolygon} from './map-polygon/map-polygon'; diff --git a/src/google-maps/testing/fake-google-map-utils.ts b/src/google-maps/testing/fake-google-map-utils.ts index 4c4fb8858647..2811c35cf941 100644 --- a/src/google-maps/testing/fake-google-map-utils.ts +++ b/src/google-maps/testing/fake-google-map-utils.ts @@ -18,6 +18,7 @@ export interface TestingWindow extends Window { Polyline?: jasmine.Spy; Polygon?: jasmine.Spy; Rectangle?: jasmine.Spy; + Circle?: jasmine.Spy; }; }; } @@ -205,3 +206,34 @@ export function createRectangleConstructorSpy(rectangleSpy: jasmine.SpyObj { + const circleSpy = jasmine.createSpyObj('google.maps.Circle', [ + 'addListener', 'getCenter', 'getRadius', 'getDraggable', 'getEditable', 'getVisible', 'setMap', + 'setOptions', 'setCenter', 'setRadius' + ]); + circleSpy.addListener.and.returnValue({remove: () => {}}); + return circleSpy; +} + +/** Creates a jasmine.Spy to watch for the constructor of a google.maps.Circle */ +export function createCircleConstructorSpy(circleSpy: jasmine.SpyObj): + jasmine.Spy { + const circleConstructorSpy = + jasmine.createSpy('Circle constructor', (_options: google.maps.CircleOptions) => { + return circleSpy; + }); + const testingWindow: TestingWindow = window; + if (testingWindow.google && testingWindow.google.maps) { + testingWindow.google.maps['Circle'] = circleConstructorSpy; + } else { + testingWindow.google = { + maps: { + 'Circle': circleConstructorSpy, + }, + }; + } + return circleConstructorSpy; +} From e43b6d16279865cc36890717d1631a6b67c3965b Mon Sep 17 00:00:00 2001 From: Matthew Ehrlich Date: Thu, 20 Feb 2020 15:14:38 -0800 Subject: [PATCH 2/2] feat(google-maps): Add Circle component Update public API for circle component. --- .../google-maps/google-maps.d.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tools/public_api_guard/google-maps/google-maps.d.ts b/tools/public_api_guard/google-maps/google-maps.d.ts index 7c6bafa440ba..a38d8248f7f0 100644 --- a/tools/public_api_guard/google-maps/google-maps.d.ts +++ b/tools/public_api_guard/google-maps/google-maps.d.ts @@ -52,7 +52,38 @@ export declare class GoogleMap implements OnChanges, OnInit, OnDestroy { export declare class GoogleMapsModule { static ɵinj: i0.ɵɵInjectorDef; - static ɵmod: i0.ɵɵNgModuleDefWithMeta; + static ɵmod: i0.ɵɵNgModuleDefWithMeta; +} + +export declare class MapCircle implements OnInit, OnDestroy { + set center(center: google.maps.LatLng | google.maps.LatLngLiteral); + centerChanged: Observable; + circle: google.maps.Circle; + circleClick: Observable; + circleDblclick: Observable; + circleDrag: Observable; + circleDragend: Observable; + circleDragstart: Observable; + circleMousedown: Observable; + circleMousemove: Observable; + circleMouseout: Observable; + circleMouseover: Observable; + circleMouseup: Observable; + circleRightclick: Observable; + set options(options: google.maps.CircleOptions); + set radius(radius: number); + radiusChanged: Observable; + constructor(_map: GoogleMap, _ngZone: NgZone); + getBounds(): google.maps.LatLngBounds; + getCenter(): google.maps.LatLng; + getDraggable(): boolean; + getEditable(): boolean; + getRadius(): number; + getVisible(): boolean; + ngOnDestroy(): void; + ngOnInit(): void; + static ɵdir: i0.ɵɵDirectiveDefWithMeta; + static ɵfac: i0.ɵɵFactoryDef; } export declare class MapInfoWindow implements OnInit, OnDestroy {