From a0ba861e7aac2e1ff59cfadb6d96d46be9f20378 Mon Sep 17 00:00:00 2001 From: Andreas Rammhold Date: Mon, 8 Aug 2016 14:39:30 +0200 Subject: [PATCH] feat(polylines): add support for Polylines Closes #554 --- src/core/directives-const.ts | 8 +- src/core/directives.ts | 2 + .../directives/google-map-polyline-point.ts | 36 +++ src/core/directives/google-map-polyline.ts | 245 ++++++++++++++++++ src/core/directives/google-map.ts | 4 +- src/core/map-types.ts | 2 +- src/core/services.ts | 1 + src/core/services/google-maps-api-wrapper.ts | 10 + src/core/services/google-maps-types.ts | 65 +++++ .../services/managers/polyline-manager.ts | 75 ++++++ .../managers/polyline-manager.spec.ts | 61 +++++ 11 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 src/core/directives/google-map-polyline-point.ts create mode 100644 src/core/directives/google-map-polyline.ts create mode 100644 src/core/services/managers/polyline-manager.ts create mode 100644 test/services/managers/polyline-manager.spec.ts diff --git a/src/core/directives-const.ts b/src/core/directives-const.ts index b3d4a2750..177aef4b7 100644 --- a/src/core/directives-const.ts +++ b/src/core/directives-const.ts @@ -2,6 +2,10 @@ import {SebmGoogleMap} from './directives/google-map'; import {SebmGoogleMapCircle} from './directives/google-map-circle'; import {SebmGoogleMapInfoWindow} from './directives/google-map-info-window'; import {SebmGoogleMapMarker} from './directives/google-map-marker'; +import {SebmGoogleMapPolyline} from './directives/google-map-polyline'; +import {SebmGoogleMapPolylinePoint} from './directives/google-map-polyline-point'; -export const GOOGLE_MAPS_DIRECTIVES: any[] = - [SebmGoogleMap, SebmGoogleMapMarker, SebmGoogleMapInfoWindow, SebmGoogleMapCircle]; +export const GOOGLE_MAPS_DIRECTIVES: any[] = [ + SebmGoogleMap, SebmGoogleMapMarker, SebmGoogleMapInfoWindow, SebmGoogleMapCircle, + SebmGoogleMapPolyline, SebmGoogleMapPolylinePoint +]; diff --git a/src/core/directives.ts b/src/core/directives.ts index f82998fc4..dc9c27be6 100644 --- a/src/core/directives.ts +++ b/src/core/directives.ts @@ -3,3 +3,5 @@ export {SebmGoogleMap} from './directives/google-map'; export {SebmGoogleMapCircle} from './directives/google-map-circle'; export {SebmGoogleMapInfoWindow} from './directives/google-map-info-window'; export {SebmGoogleMapMarker} from './directives/google-map-marker'; +export {SebmGoogleMapPolyline} from './directives/google-map-polyline'; +export {SebmGoogleMapPolylinePoint} from './directives/google-map-polyline-point'; diff --git a/src/core/directives/google-map-polyline-point.ts b/src/core/directives/google-map-polyline-point.ts new file mode 100644 index 000000000..1bca310ba --- /dev/null +++ b/src/core/directives/google-map-polyline-point.ts @@ -0,0 +1,36 @@ +import {Directive, EventEmitter, Input, OnChanges, Output, SimpleChanges} from '@angular/core'; +import {LatLngLiteral} from '../../core/services/google-maps-types'; + +/** + * SebmGoogleMapPolylinePoint represents one element of a polyline within a {@link + * SembGoogleMapPolyline} + */ +@Directive({selector: 'sebm-google-map-polyline-point'}) +export class SebmGoogleMapPolylinePoint implements OnChanges { + /** + * The latitude position of the point. + */ + @Input() public latitude: number; + + /** + * The longitude position of the point; + */ + @Input() public longitude: number; + + /** + * This event emitter gets emitted when the position of the point changed. + */ + @Output() positionChanged: EventEmitter = new EventEmitter(); + + constructor() {} + + ngOnChanges(changes: SimpleChanges): any { + if (changes['latitude'] || changes['longitude']) { + const position: LatLngLiteral = { + lat: changes['latitude'].currentValue, + lng: changes['longitude'].currentValue + }; + this.positionChanged.emit(position); + } + } +} diff --git a/src/core/directives/google-map-polyline.ts b/src/core/directives/google-map-polyline.ts new file mode 100644 index 000000000..11233a5c8 --- /dev/null +++ b/src/core/directives/google-map-polyline.ts @@ -0,0 +1,245 @@ +import {AfterContentInit, ContentChildren, Directive, EventEmitter, OnChanges, OnDestroy, QueryList, SimpleChanges} from '@angular/core'; +import {Subscription} from 'rxjs/Rx'; + +import {PolyMouseEvent} from '../services/google-maps-types'; +import {PolylineManager} from '../services/managers/polyline-manager'; + +import {SebmGoogleMapPolylinePoint} from './google-map-polyline-point'; + +let polylineId = 0; +/** + * SebmGoogleMapPolyline renders a polyline on a {@link SebmGoogleMap} + * + * ### Example + * ```typescript + * import {Component} from 'angular2/core'; + * import {SebmGoogleMap, SebmGooglePolyline, SebmGooglePolylinePoint} from + * 'angular2-google-maps/core'; + * + * @Component({ + * selector: 'my-map-cmp', + * directives: [SebmGoogleMap, SebmGooglePolyline, SebmGooglePolylinePoint], + * styles: [` + * .sebm-google-map-container { + * height: 300px; + * } + * `], + * template: ` + * + * + * + * + * + * + * + * + * ` + * }) + * ``` + */ +@Directive({ + selector: 'sebm-google-map-polyline', + inputs: [ + 'clickable', 'draggable: polylineDraggable', 'editable', 'geodesic', 'strokeColor', + 'strokeWeight', 'strokeOpacity', 'visible', 'zIndex' + ], + outputs: [ + 'lineClick', 'lineDblClick', 'lineDrag', 'lineDragEnd', 'lineMouseDown', 'lineMouseMove', + 'lineMouseOut', 'lineMouseOver', 'lineMouseUp', 'lineRightClick' + ] +}) +export class SebmGoogleMapPolyline implements OnDestroy, OnChanges, AfterContentInit { + /** + * Indicates whether this Polyline handles mouse events. Defaults to true. + */ + clickable: boolean = true; + + /** + * If set to true, the user can drag this shape over the map. The geodesic property defines the + * mode of dragging. Defaults to false. + */ + draggable: boolean = false; + + /** + * If set to true, the user can edit this shape by dragging the control points shown at the + * vertices and on each segment. Defaults to false. + */ + editable: boolean = false; + + /** + * When true, edges of the polygon are interpreted as geodesic and will follow the curvature of + * the Earth. When false, edges of the polygon are rendered as straight lines in screen space. + * Note that the shape of a geodesic polygon may appear to change when dragged, as the dimensions + * are maintained relative to the surface of the earth. Defaults to false. + */ + geodesic: boolean = false; + + /** + * The stroke color. All CSS3 colors are supported except for extended named colors. + */ + strokeColor: string; + + /** + * The stroke opacity between 0.0 and 1.0. + */ + strokeOpacity: number; + + /** + * The stroke width in pixels. + */ + strokeWeight: number; + + /** + * Whether this polyline is visible on the map. Defaults to true. + */ + visible: boolean = true; + + /** + * The zIndex compared to other polys. + */ + zIndex: number; + + /** + * This event is fired when the DOM click event is fired on the Polyline. + */ + lineClick: EventEmitter = new EventEmitter(); + + /** + * This event is fired when the DOM dblclick event is fired on the Polyline. + */ + lineDblClick: EventEmitter = new EventEmitter(); + + /** + * This event is repeatedly fired while the user drags the polyline. + */ + lineDrag: EventEmitter = new EventEmitter(); + + /** + * This event is fired when the user stops dragging the polyline. + */ + lineDragEnd: EventEmitter = new EventEmitter(); + + /** + * This event is fired when the user starts dragging the polyline. + */ + lineDragStart: EventEmitter = new EventEmitter(); + + /** + * This event is fired when the DOM mousedown event is fired on the Polyline. + */ + lineMouseDown: EventEmitter = new EventEmitter(); + + /** + * This event is fired when the DOM mousemove event is fired on the Polyline. + */ + lineMouseMove: EventEmitter = new EventEmitter(); + + /** + * This event is fired on Polyline mouseout. + */ + lineMouseOut: EventEmitter = new EventEmitter(); + + /** + * This event is fired on Polyline mouseover. + */ + lineMouseOver: EventEmitter = new EventEmitter(); + + /** + * This event is fired whe the DOM mouseup event is fired on the Polyline + */ + lineMouseUp: EventEmitter = new EventEmitter(); + + /** + * This even is fired when the Polyline is right-clicked on. + */ + lineRightClick: EventEmitter = new EventEmitter(); + + @ContentChildren(SebmGoogleMapPolylinePoint) + private _points: QueryList; + + private static _polylineOptionsAttributes: Array = [ + 'draggable', 'editable', 'visible', 'geodesic', 'strokeColor', 'strokeOpacity', 'strokeWeight', + 'zIndex' + ]; + + private _id: string; + private _polylineAddedToManager: boolean = false; + private _subscriptions: Subscription[] = []; + + constructor(private _polylineManager: PolylineManager) { this._id = (polylineId++).toString(); } + + /** @internal */ + ngAfterContentInit() { + if (this._points.length) { + this._points.forEach((point: SebmGoogleMapPolylinePoint) => { + const s = point.positionChanged.subscribe( + () => { this._polylineManager.updatePolylinePoints(this); }); + this._subscriptions.push(s); + }); + } + if (!this._polylineAddedToManager) { + this._init(); + } + const s = + this._points.changes.subscribe(() => this._polylineManager.updatePolylinePoints(this)); + this._subscriptions.push(s); + this._polylineManager.updatePolylinePoints(this); + } + + ngOnChanges(changes: SimpleChanges): any { + if (!this._polylineAddedToManager) { + this._init(); + return; + } + + let options: {[propName: string]: any} = {}; + const optionKeys = Object.keys(changes).filter( + k => SebmGoogleMapPolyline._polylineOptionsAttributes.indexOf(k) !== -1); + optionKeys.forEach(k => options[k] = changes[k].currentValue); + this._polylineManager.setPolylineOptions(this, options); + } + + private _init() { + this._polylineManager.addPolyline(this); + this._polylineAddedToManager = true; + this._addEventListeners(); + } + + private _addEventListeners() { + const handlers = [ + {name: 'click', handler: (ev: PolyMouseEvent) => this.lineClick.emit(ev)}, + {name: 'dbclick', handler: (ev: PolyMouseEvent) => this.lineDblClick.emit(ev)}, + {name: 'drag', handler: (ev: MouseEvent) => this.lineDrag.emit(ev)}, + {name: 'dragend', handler: (ev: MouseEvent) => this.lineDragEnd.emit(ev)}, + {name: 'dragstart', handler: (ev: MouseEvent) => this.lineDragStart.emit(ev)}, + {name: 'mousedown', handler: (ev: PolyMouseEvent) => this.lineMouseDown.emit(ev)}, + {name: 'mousemove', handler: (ev: PolyMouseEvent) => this.lineMouseMove.emit(ev)}, + {name: 'mouseout', handler: (ev: PolyMouseEvent) => this.lineMouseOut.emit(ev)}, + {name: 'mouseover', handler: (ev: PolyMouseEvent) => this.lineMouseOver.emit(ev)}, + {name: 'mouseup', handler: (ev: PolyMouseEvent) => this.lineMouseUp.emit(ev)}, + {name: 'rightclick', handler: (ev: PolyMouseEvent) => this.lineRightClick.emit(ev)}, + ]; + handlers.forEach((obj) => { + const os = this._polylineManager.createEventObservable(obj.name, this).subscribe(obj.handler); + this._subscriptions.push(os); + }); + } + + /** @internal */ + _getPoints(): Array { + if (this._points) { + return this._points.toArray(); + } + return []; + } + + /** @internal */ + id(): string { return this._id; } + + /** @internal */ + ngOnDestroy() { + this._polylineManager.deletePolyline(this); + // unsubscribe all registered observable subscriptions + this._subscriptions.forEach((s) => s.unsubscribe()); + } +} diff --git a/src/core/directives/google-map.ts b/src/core/directives/google-map.ts index c12841478..907443bf0 100644 --- a/src/core/directives/google-map.ts +++ b/src/core/directives/google-map.ts @@ -8,6 +8,7 @@ import {LatLngBounds, LatLngBoundsLiteral, MapTypeStyle} from '../services/googl import {CircleManager} from '../services/managers/circle-manager'; import {InfoWindowManager} from '../services/managers/info-window-manager'; import {MarkerManager} from '../services/managers/marker-manager'; +import {PolylineManager} from '../services/managers/polyline-manager'; /** * SebMGoogleMap renders a Google Map. @@ -36,7 +37,8 @@ import {MarkerManager} from '../services/managers/marker-manager'; */ @Component({ selector: 'sebm-google-map', - providers: [GoogleMapsAPIWrapper, MarkerManager, InfoWindowManager, CircleManager], + providers: + [GoogleMapsAPIWrapper, MarkerManager, InfoWindowManager, CircleManager, PolylineManager], inputs: [ 'longitude', 'latitude', 'zoom', 'draggable: mapDraggable', 'disableDoubleClickZoom', 'disableDefaultUI', 'scrollwheel', 'backgroundColor', 'draggableCursor', 'draggingCursor', diff --git a/src/core/map-types.ts b/src/core/map-types.ts index 995ffbf26..ad59c50e6 100644 --- a/src/core/map-types.ts +++ b/src/core/map-types.ts @@ -1,7 +1,7 @@ import {LatLngLiteral} from './services/google-maps-types'; // exported map types -export {LatLngBounds, LatLngBoundsLiteral, LatLngLiteral} from './services/google-maps-types'; +export {LatLngBounds, LatLngBoundsLiteral, LatLngLiteral, PolyMouseEvent} from './services/google-maps-types'; /** * MouseEvent gets emitted when the user triggers mouse events on the map. diff --git a/src/core/services.ts b/src/core/services.ts index f8da53f8d..4860c008e 100644 --- a/src/core/services.ts +++ b/src/core/services.ts @@ -2,6 +2,7 @@ export {GoogleMapsAPIWrapper} from './services/google-maps-api-wrapper'; export {CircleManager} from './services/managers/circle-manager'; export {InfoWindowManager} from './services/managers/info-window-manager'; export {MarkerManager} from './services/managers/marker-manager'; +export {PolylineManager} from './services/managers/polyline-manager'; export {GoogleMapsScriptProtocol, LazyMapsAPILoader, LazyMapsAPILoaderConfig, LazyMapsAPILoaderConfigLiteral, provideLazyMapsAPILoaderConfig} from './services/maps-api-loader/lazy-maps-api-loader'; export {MapsAPILoader} from './services/maps-api-loader/maps-api-loader'; export {NoOpMapsAPILoader} from './services/maps-api-loader/noop-maps-api-loader'; diff --git a/src/core/services/google-maps-api-wrapper.ts b/src/core/services/google-maps-api-wrapper.ts index 4ae507ea6..5ebc1cc61 100644 --- a/src/core/services/google-maps-api-wrapper.ts +++ b/src/core/services/google-maps-api-wrapper.ts @@ -3,6 +3,8 @@ import {Observable} from 'rxjs/Observable'; import {Observer} from 'rxjs/Observer'; import * as mapTypes from './google-maps-types'; +import {Polyline} from './google-maps-types'; +import {PolylineOptions} from './google-maps-types'; import {MapsAPILoader} from './maps-api-loader/maps-api-loader'; // todo: add types for this @@ -59,6 +61,14 @@ export class GoogleMapsAPIWrapper { }); } + public createPolyline(options: PolylineOptions): Promise { + return this.getNativeMap().then((map: mapTypes.GoogleMap) => { + let line = new google.maps.Polyline(options); + line.setMap(map); + return line; + }); + } + subscribeToMapEvent(eventName: string): Observable { return Observable.create((observer: Observer) => { this._map.then((m: mapTypes.GoogleMap) => { diff --git a/src/core/services/google-maps-types.ts b/src/core/services/google-maps-types.ts index fd671e6b2..4a88932f8 100644 --- a/src/core/services/google-maps-types.ts +++ b/src/core/services/google-maps-types.ts @@ -194,3 +194,68 @@ export interface InfoWindowOptions { position?: LatLng|LatLngLiteral; zIndex?: number; } + +export interface Point { + x: number; + y: number; + equals(other: Point): boolean; + toString(): string; +} + +export interface GoogleSymbol { + anchor?: Point; + fillColor?: string; + fillOpacity?: string; + labelOrigin?: Point; + path?: string; + rotation?: number; + scale?: number; + strokeColor?: string; + strokeOpacity?: number; + strokeWeight?: number; +} + +export interface IconSequence { + fixedRotation?: boolean; + icon?: GoogleSymbol; + offset?: string; + repeat?: string; +} + +export interface PolylineOptions { + clickable?: boolean; + draggable?: boolean; + editable?: boolean; + geodesic?: boolean; + icon?: Array; + map?: GoogleMap; + path?: Array|Array; + strokeColor?: string; + strokeOpacity?: number; + strokeWeight?: number; + visible?: boolean; + zIndex?: number; +} + +export interface Polyline extends MVCObject { + getDraggable(): boolean; + getEditable(): boolean; + getMap(): GoogleMap; + getPath(): Array; + getVisible(): boolean; + setDraggable(draggable: boolean): void; + setEditable(editable: boolean): void; + setMap(map: GoogleMap): void; + setOptions(options: PolylineOptions): void; + setPath(path: Array): void; + setVisible(visible: boolean): void; +} + +/** + * PolyMouseEvent gets emitted when the user triggers mouse events on a polyline. + */ +export interface PolyMouseEvent extends MouseEvent { + edge: number; + path: number; + vertex: number; +} diff --git a/src/core/services/managers/polyline-manager.ts b/src/core/services/managers/polyline-manager.ts new file mode 100644 index 000000000..67a85bf6b --- /dev/null +++ b/src/core/services/managers/polyline-manager.ts @@ -0,0 +1,75 @@ +import {Injectable, NgZone} from '@angular/core'; +import {Observable, Observer} from 'rxjs/Rx'; + +import {SebmGoogleMapPolyline} from '../../directives/google-map-polyline'; +import {SebmGoogleMapPolylinePoint} from '../../directives/google-map-polyline-point'; +import {LatLngLiteral} from '../../services/google-maps-types'; +import {GoogleMapsAPIWrapper} from '../google-maps-api-wrapper'; +import {Polyline} from '../google-maps-types'; + +@Injectable() +export class PolylineManager { + private _polylines: Map> = + new Map>(); + + constructor(private _mapsWrapper: GoogleMapsAPIWrapper, private _zone: NgZone) {} + + private static _convertPoints(line: SebmGoogleMapPolyline): Array { + const path = line._getPoints().map((point: SebmGoogleMapPolylinePoint) => { + return {lat: point.latitude, lng: point.longitude}; + }); + return path; + } + + addPolyline(line: SebmGoogleMapPolyline) { + const path = PolylineManager._convertPoints(line); + const polylinePromise = this._mapsWrapper.createPolyline({ + clickable: line.clickable, + draggable: line.draggable, + editable: line.editable, + geodesic: line.geodesic, + strokeColor: line.strokeColor, + strokeOpacity: line.strokeOpacity, + strokeWeight: line.strokeWeight, + visible: line.visible, + zIndex: line.zIndex, + path: path + }); + this._polylines.set(line, polylinePromise); + } + + updatePolylinePoints(line: SebmGoogleMapPolyline): Promise { + const path = PolylineManager._convertPoints(line); + const m = this._polylines.get(line); + if (m == null) { + return Promise.resolve(); + } + return m.then((l: Polyline) => { return this._zone.run(() => { l.setPath(path); }); }); + } + + setPolylineOptions(line: SebmGoogleMapPolyline, options: {[propName: string]: any}): + Promise { + return this._polylines.get(line).then((l: Polyline) => { l.setOptions(options); }); + } + + deletePolyline(line: SebmGoogleMapPolyline): Promise { + const m = this._polylines.get(line); + if (m == null) { + return Promise.resolve(); + } + return m.then((l: Polyline) => { + return this._zone.run(() => { + l.setMap(null); + this._polylines.delete(line); + }); + }); + } + + createEventObservable(eventName: string, line: SebmGoogleMapPolyline): Observable { + return Observable.create((observer: Observer) => { + this._polylines.get(line).then((l: Polyline) => { + l.addListener(eventName, (e: T) => this._zone.run(() => observer.next(e))); + }); + }); + } +} diff --git a/test/services/managers/polyline-manager.spec.ts b/test/services/managers/polyline-manager.spec.ts new file mode 100644 index 000000000..f53a6ca40 --- /dev/null +++ b/test/services/managers/polyline-manager.spec.ts @@ -0,0 +1,61 @@ +import {NgZone} from '@angular/core'; +import {addProviders, describe, inject, it} from '@angular/core/testing'; + +import {SebmGoogleMapPolyline} from '../../../src/core/directives/google-map-polyline'; +import {GoogleMapsAPIWrapper} from '../../../src/core/services/google-maps-api-wrapper'; +import {Polyline} from '../../../src/core/services/google-maps-types'; +import {PolylineManager} from '../../../src/core/services/managers/polyline-manager'; + +export function main() { + describe('PolylineManager', () => { + beforeEach(() => { + addProviders([ + {provide: NgZone, useFactory: () => new NgZone({enableLongStackTrace: true})}, + PolylineManager, SebmGoogleMapPolyline, { + provide: GoogleMapsAPIWrapper, + useValue: jasmine.createSpyObj('GoogleMapsAPIWrapper', ['createPolyline']) + } + ]); + }); + + describe('Create a new polyline', () => { + it('should call the mapsApiWrapper when creating a new polyline', + inject( + [PolylineManager, GoogleMapsAPIWrapper], + (polylineManager: PolylineManager, apiWrapper: GoogleMapsAPIWrapper) => { + const newPolyline = new SebmGoogleMapPolyline(polylineManager); + polylineManager.addPolyline(newPolyline); + + expect(apiWrapper.createPolyline).toHaveBeenCalledWith({ + clickable: true, + draggable: false, + editable: false, + geodesic: false, + strokeColor: undefined, + strokeOpacity: undefined, + strokeWeight: undefined, + visible: true, + zIndex: undefined, + path: [] + }); + })); + }); + + describe('Delete a polyline', () => { + it('should set the map to null when deleting a existing polyline', + inject( + [PolylineManager, GoogleMapsAPIWrapper], + (polylineManager: PolylineManager, apiWrapper: GoogleMapsAPIWrapper) => { + const newPolyline = new SebmGoogleMapPolyline(polylineManager); + + const polylineInstance: Polyline = jasmine.createSpyObj('Polyline', ['setMap']); + (apiWrapper.createPolyline).and.returnValue(Promise.resolve(polylineInstance)); + + polylineManager.addPolyline(newPolyline); + polylineManager.deletePolyline(newPolyline).then(() => { + expect(polylineInstance.setMap).toHaveBeenCalledWith(null); + }); + })); + }); + }); +}