From ecd9d5f4f51b992a5518f3b79bf4dfc1d4af661f Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Mon, 30 Apr 2018 16:23:11 +0200 Subject: [PATCH 1/9] feat(core): auto fitBounds --- .prettierrc.json | 3 + packages/core/core.module.ts | 3 +- packages/core/directives.ts | 1 + packages/core/directives/fit-bounds.ts | 75 +++++++++++++++++++++ packages/core/directives/map.ts | 53 +++++++++++++-- packages/core/directives/marker.ts | 37 +++++++--- packages/core/services/fit-bounds.spec.ts | 38 +++++++++++ packages/core/services/fit-bounds.ts | 60 +++++++++++++++++ packages/core/services/google-maps-types.ts | 3 +- tslint.json | 2 +- 10 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 .prettierrc.json create mode 100644 packages/core/directives/fit-bounds.ts create mode 100644 packages/core/services/fit-bounds.spec.ts create mode 100644 packages/core/services/fit-bounds.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..92cde390a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} \ No newline at end of file diff --git a/packages/core/core.module.ts b/packages/core/core.module.ts index 98ec6a37f..d971b8801 100644 --- a/packages/core/core.module.ts +++ b/packages/core/core.module.ts @@ -12,6 +12,7 @@ import {LazyMapsAPILoader} from './services/maps-api-loader/lazy-maps-api-loader import {LAZY_MAPS_API_CONFIG, LazyMapsAPILoaderConfigLiteral} from './services/maps-api-loader/lazy-maps-api-loader'; import {MapsAPILoader} from './services/maps-api-loader/maps-api-loader'; import {BROWSER_GLOBALS_PROVIDERS} from './utils/browser-globals'; +import {AgmFitBounds} from '@agm/core/directives/fit-bounds'; /** * @internal @@ -20,7 +21,7 @@ export function coreDirectives() { return [ AgmMap, AgmMarker, AgmInfoWindow, AgmCircle, AgmPolygon, AgmPolyline, AgmPolylinePoint, AgmKmlLayer, - AgmDataLayer + AgmDataLayer, AgmFitBounds ]; } diff --git a/packages/core/directives.ts b/packages/core/directives.ts index cf95e9436..9ba61daf3 100644 --- a/packages/core/directives.ts +++ b/packages/core/directives.ts @@ -7,3 +7,4 @@ export {AgmMarker} from './directives/marker'; export {AgmPolygon} from './directives/polygon'; export {AgmPolyline} from './directives/polyline'; export {AgmPolylinePoint} from './directives/polyline-point'; +export {AgmFitBounds} from './directives/fit-bounds'; diff --git a/packages/core/directives/fit-bounds.ts b/packages/core/directives/fit-bounds.ts new file mode 100644 index 000000000..187046c50 --- /dev/null +++ b/packages/core/directives/fit-bounds.ts @@ -0,0 +1,75 @@ +import { Directive, OnInit, Self, OnDestroy, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { FitBoundsService, FitBoundsAccessor, FitBoundsDetails } from '../services/fit-bounds'; +import { Subscription, Subject } from 'rxjs'; +import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { LatLng, LatLngLiteral } from '@agm/core'; + +/** + * TODO: docs + */ +@Directive({ + selector: '[agmFitBounds]' +}) +export class AgmFitBounds implements OnInit, OnDestroy, OnChanges { + /** + * If the value is true, the element gets added to the bounds of the map. + * Default: true. + */ + @Input() agmFitBounds: boolean = true; + + private _destroyed$: Subject = new Subject(); + private _latestFitBoundsDetails: FitBoundsDetails | null = null; + + constructor( + @Self() private readonly _fitBoundsAccessor: FitBoundsAccessor, + private readonly _fitBoundsService: FitBoundsService + ) {} + + /** + * @internal + */ + ngOnChanges(changes: SimpleChanges) { + this._updateBounds(); + } + + /** + * @internal + */ + ngOnInit() { + this._fitBoundsAccessor + .getFitBoundsDetails$() + .pipe( + distinctUntilChanged( + (x: FitBoundsDetails, y: FitBoundsDetails) => + x.latLng.lat === y.latLng.lng + ), + takeUntil(this._destroyed$) + ) + .subscribe(details => this._updateBounds(details)); + } + + private _updateBounds(newFitBoundsDetails?: FitBoundsDetails) { + if (newFitBoundsDetails) { + this._latestFitBoundsDetails = newFitBoundsDetails; + } + if (!this._latestFitBoundsDetails) { + return; + } + if (this.agmFitBounds) { + this._fitBoundsService.addToBounds(this._latestFitBoundsDetails.latLng); + } else { + this._fitBoundsService.removeFromBounds(this._latestFitBoundsDetails.latLng); + } + } + + /** + * @internal + */ + ngOnDestroy() { + this._destroyed$.next(); + this._destroyed$.complete(); + if (this._latestFitBoundsDetails !== null) { + this._fitBoundsService.removeFromBounds(this._latestFitBoundsDetails.latLng); + } + } +} diff --git a/packages/core/directives/map.ts b/packages/core/directives/map.ts index 06bd559ad..0d3a0ad6d 100644 --- a/packages/core/directives/map.ts +++ b/packages/core/directives/map.ts @@ -14,6 +14,9 @@ import {PolygonManager} from '../services/managers/polygon-manager'; import {PolylineManager} from '../services/managers/polyline-manager'; import {KmlLayerManager} from './../services/managers/kml-layer-manager'; import {DataLayerManager} from './../services/managers/data-layer-manager'; +import {FitBoundsService} from '../services/fit-bounds'; + +declare var google: any; /** * AgmMap renders a Google Map. @@ -42,7 +45,7 @@ import {DataLayerManager} from './../services/managers/data-layer-manager'; selector: 'agm-map', providers: [ GoogleMapsAPIWrapper, MarkerManager, InfoWindowManager, CircleManager, PolylineManager, - PolygonManager, KmlLayerManager, DataLayerManager + PolygonManager, KmlLayerManager, DataLayerManager, FitBoundsService ], host: { // todo: deprecated - we will remove it with the next version @@ -179,8 +182,9 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { /** * Sets the viewport to contain the given bounds. + * If this option to `true`, the bounds get automatically computed from all elements that use the {@link AgmFitBounds} directive. */ - @Input() fitBounds: LatLngBoundsLiteral|LatLngBounds = null; + @Input() fitBounds: LatLngBoundsLiteral|LatLngBounds|boolean = false; /** * The initial enabled/disabled state of the Scale control. This is disabled by default. @@ -266,6 +270,7 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { ]; private _observableSubscriptions: Subscription[] = []; + private _fitBoundsSubscription: Subscription; /** * This event emitter gets emitted when the user clicks on the map (but not when they click on a @@ -316,7 +321,7 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { */ @Output() mapReady: EventEmitter = new EventEmitter(); - constructor(private _elem: ElementRef, private _mapsWrapper: GoogleMapsAPIWrapper) {} + constructor(private _elem: ElementRef, private _mapsWrapper: GoogleMapsAPIWrapper, protected _fitBoundsService: FitBoundsService) {} /** @internal */ ngOnInit() { @@ -377,6 +382,9 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { // remove all listeners from the map instance this._mapsWrapper.clearInstanceListeners(); + if (this._fitBoundsSubscription) { + this._fitBoundsSubscription.unsubscribe(); + } } /* @internal */ @@ -416,13 +424,13 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { private _updatePosition(changes: SimpleChanges) { if (changes['latitude'] == null && changes['longitude'] == null && - changes['fitBounds'] == null) { + !changes['fitBounds']) { // no position update needed return; } // we prefer fitBounds in changes - if (changes['fitBounds'] && this.fitBounds != null) { + if ('fitBounds' in changes) { this._fitBounds(); return; } @@ -446,11 +454,42 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { } private _fitBounds() { + switch (this.fitBounds) { + case true: + this._subscribeToFitBoundsUpdates(); + break; + case false: + if (this._fitBoundsSubscription) { + this._fitBoundsSubscription.unsubscribe(); + } + break; + default: + this._updateBounds(this.fitBounds); + } + } + + private _subscribeToFitBoundsUpdates() { + this._fitBoundsSubscription = this._fitBoundsService.bounds$.subscribe(b => this._updateBounds(b)); + } + + protected _updateBounds(bounds: LatLngBounds|LatLngBoundsLiteral) { + if (this._isLatLngBoundsLiteral(bounds)) { + const newBounds = google.maps.LatLngBounds(); + newBounds.union(bounds); + bounds = newBounds; + } + if (bounds.isEmpty()) { + return; + } if (this.usePanning) { - this._mapsWrapper.panToBounds(this.fitBounds); + this._mapsWrapper.panToBounds(bounds); return; } - this._mapsWrapper.fitBounds(this.fitBounds); + this._mapsWrapper.fitBounds(bounds); + } + + private _isLatLngBoundsLiteral(bounds: LatLngBounds|LatLngBoundsLiteral): bounds is LatLngBoundsLiteral { + return (bounds).extend === undefined; } private _handleMapCenterChange() { diff --git a/packages/core/directives/marker.ts b/packages/core/directives/marker.ts index 5fcefd322..0735f02a8 100644 --- a/packages/core/directives/marker.ts +++ b/packages/core/directives/marker.ts @@ -1,14 +1,11 @@ -import {Directive, EventEmitter, OnChanges, OnDestroy, SimpleChange, - AfterContentInit, ContentChildren, QueryList, Input, Output -} from '@angular/core'; -import {Subscription} from 'rxjs'; - -import {MouseEvent} from '../map-types'; +import { AfterContentInit, ContentChildren, Directive, EventEmitter, Input, OnChanges, OnDestroy, Output, QueryList, SimpleChange, forwardRef } from '@angular/core'; +import { Observable, ReplaySubject, Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { MarkerLabel, MouseEvent } from '../map-types'; +import { FitBoundsAccessor, FitBoundsDetails } from '../services/fit-bounds'; import * as mapTypes from '../services/google-maps-types'; -import {MarkerManager} from '../services/managers/marker-manager'; - -import {AgmInfoWindow} from './info-window'; -import {MarkerLabel} from '../map-types'; +import { MarkerManager } from '../services/managers/marker-manager'; +import { AgmInfoWindow } from './info-window'; let markerId = 0; @@ -37,13 +34,16 @@ let markerId = 0; */ @Directive({ selector: 'agm-marker', + providers: [ + {provide: FitBoundsAccessor, useExisting: forwardRef(() => AgmMarker)} + ], inputs: [ 'latitude', 'longitude', 'title', 'label', 'draggable: markerDraggable', 'iconUrl', 'openInfoWindow', 'opacity', 'visible', 'zIndex', 'animation' ], outputs: ['markerClick', 'dragEnd', 'mouseOver', 'mouseOut'] }) -export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { +export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit, FitBoundsAccessor { /** * The latitude position of the marker. */ @@ -139,6 +139,8 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { private _id: string; private _observableSubscriptions: Subscription[] = []; + protected readonly _fitBoundsDetails$: ReplaySubject = new ReplaySubject(1); + constructor(private _markerManager: MarkerManager) { this._id = (markerId++).toString(); } /* @internal */ @@ -163,12 +165,14 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { } if (!this._markerAddedToManger) { this._markerManager.addMarker(this); + this._updateFitBoundsDetails(); this._markerAddedToManger = true; this._addEventListeners(); return; } if (changes['latitude'] || changes['longitude']) { this._markerManager.updateMarkerPosition(this); + this._updateFitBoundsDetails(); } if (changes['title']) { this._markerManager.updateTitle(this); @@ -199,6 +203,17 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { } } + /** + * @internal + */ + getFitBoundsDetails$(): Observable { + return this._fitBoundsDetails$.asObservable().pipe(tap(() => console.log('subscribe'))); + } + + protected _updateFitBoundsDetails() { + this._fitBoundsDetails$.next({latLng: {lat: this.latitude, lng: this.longitude}}); + } + private _addEventListeners() { const cs = this._markerManager.createEventObservable('click', this).subscribe(() => { if (this.openInfoWindow) { diff --git a/packages/core/services/fit-bounds.spec.ts b/packages/core/services/fit-bounds.spec.ts new file mode 100644 index 000000000..252609af6 --- /dev/null +++ b/packages/core/services/fit-bounds.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { FitBoundsService } from './fit-bounds'; +import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; +import { LatLngBounds } from '@agm/core'; + +describe('FitBoundsService', () => { + let loader: MapsAPILoader; + let fitBoundsService: FitBoundsService; + let latLngBounds: jest.Mock; + + beforeEach(() => { + loader = { + load: jest.fn().mockReturnValue(Promise.resolve()) + }; + + latLngBounds = jest.fn(); + (window).google = { + maps: { + LatLngBounds: jest.fn().mockReturnValue(latLngBounds) + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: MapsAPILoader, useValue: loader}, + FitBoundsService + ] + }); + + fitBoundsService = TestBed.get(FitBoundsService); + }); + + it('should wait for the load event', () => { + expect(loader.load).toHaveBeenCalledTimes(1); + expect(latLngBounds.mock.instances.length).toEqual(0); + }); + +}); \ No newline at end of file diff --git a/packages/core/services/fit-bounds.ts b/packages/core/services/fit-bounds.ts new file mode 100644 index 000000000..295c9e778 --- /dev/null +++ b/packages/core/services/fit-bounds.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, from } from 'rxjs'; +import { flatMap, map, skipWhile } from 'rxjs/operators'; +import { LatLng, LatLngBounds, LatLngLiteral } from './google-maps-types'; +import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; + +declare var google: any; + +export interface FitBoundsDetails { + latLng: LatLng|LatLngLiteral; +} + +export abstract class FitBoundsAccessor { + abstract getFitBoundsDetails$(): Observable; +} + +@Injectable() +export class FitBoundsService { + readonly bounds$: Observable; + protected readonly _boundsChangeDebounceTime$: BehaviorSubject = new BehaviorSubject(200); + protected readonly _includeInBounds$: BehaviorSubject> = new BehaviorSubject>(new Map()); + protected _emitPaused: boolean = false; + + constructor(loader: MapsAPILoader) { + this.bounds$ = from(loader.load()).pipe( + flatMap(() => this._includeInBounds$), + skipWhile(() => this._emitPaused), + // debounce(() => this._boundsChangeDebounceTime$), + map((includeInBounds: Map) => { + const bounds = new google.maps.LatLngBounds() as LatLngBounds; + includeInBounds.forEach(b => bounds.extend(b)); + return bounds; + }) + ); + } + + addToBounds(latLng: LatLng | LatLngLiteral) { + const id = this._createIdentifier(latLng); + if (this._includeInBounds$.value.has(id)) { + return; + } + const map = this._includeInBounds$.value; + map.set(id, latLng); + this._includeInBounds$.next(map); + } + + removeFromBounds(latLng: LatLng|LatLngLiteral) { + const map = this._includeInBounds$.value; + map.delete(this._createIdentifier(latLng)); + this._includeInBounds$.next(map); + } + + changeFitBoundsDebounceTime(timeMs: number) { + this._boundsChangeDebounceTime$.next(timeMs); + } + + protected _createIdentifier(latLng: LatLng|LatLngLiteral): string { + return `${latLng.lat}+${latLng.lng}`; + } +} diff --git a/packages/core/services/google-maps-types.ts b/packages/core/services/google-maps-types.ts index fdd649b01..3a08b8f13 100644 --- a/packages/core/services/google-maps-types.ts +++ b/packages/core/services/google-maps-types.ts @@ -20,6 +20,7 @@ export interface LatLng { constructor(lat: number, lng: number): void; lat(): number; lng(): number; + toString(): string; } export interface Marker extends MVCObject { @@ -97,7 +98,7 @@ export interface CircleOptions { export interface LatLngBounds { contains(latLng: LatLng): boolean; equals(other: LatLngBounds|LatLngBoundsLiteral): boolean; - extend(point: LatLng): void; + extend(point: LatLng|LatLngLiteral): void; getCenter(): LatLng; getNorthEast(): LatLng; getSouthWest(): LatLng; diff --git a/tslint.json b/tslint.json index 3d4c0bd22..e8e32a8c6 100644 --- a/tslint.json +++ b/tslint.json @@ -81,7 +81,7 @@ "no-attribute-parameter-decorator": true, "no-input-rename": true, "no-output-rename": true, - "no-forward-ref": true, + "no-forward-ref": false, "use-life-cycle-interface": true, "use-pipe-transform-interface": true, "pipe-naming": [true, "camelCase", "agm"], From 1b9c8578babde68c20732c3a1f6239ddcbab3b42 Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Tue, 5 Jun 2018 22:26:47 +0200 Subject: [PATCH 2/9] add tests --- packages/core/services/fit-bounds.spec.ts | 108 ++++++++++++++++++++-- packages/core/services/fit-bounds.ts | 58 ++++++++---- 2 files changed, 140 insertions(+), 26 deletions(-) diff --git a/packages/core/services/fit-bounds.spec.ts b/packages/core/services/fit-bounds.spec.ts index 252609af6..a8bbf4852 100644 --- a/packages/core/services/fit-bounds.spec.ts +++ b/packages/core/services/fit-bounds.spec.ts @@ -1,22 +1,32 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; import { FitBoundsService } from './fit-bounds'; import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; import { LatLngBounds } from '@agm/core'; +import { take, first } from 'rxjs/operators'; describe('FitBoundsService', () => { let loader: MapsAPILoader; let fitBoundsService: FitBoundsService; - let latLngBounds: jest.Mock; + let latLngBoundsConstructs: number; + let latLngBoundsExtend: jest.Mock; - beforeEach(() => { + beforeEach(fakeAsync(() => { loader = { load: jest.fn().mockReturnValue(Promise.resolve()) }; - latLngBounds = jest.fn(); + latLngBoundsConstructs = 0; + latLngBoundsExtend = jest.fn(); + (window).google = { maps: { - LatLngBounds: jest.fn().mockReturnValue(latLngBounds) + LatLngBounds: class LatLngBounds { + extend: jest.Mock = latLngBoundsExtend; + + constructor() { + latLngBoundsConstructs += 1; + } + } } }; @@ -28,11 +38,93 @@ describe('FitBoundsService', () => { }); fitBoundsService = TestBed.get(FitBoundsService); - }); + tick(); + })); it('should wait for the load event', () => { expect(loader.load).toHaveBeenCalledTimes(1); - expect(latLngBounds.mock.instances.length).toEqual(0); + expect(latLngBoundsConstructs).toEqual(0); }); -}); \ No newline at end of file + it('should emit empty bounds when API finished loaded but the are not entries in the includeInBounds$ map', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().pipe(first()).subscribe(success); + tick(); + expect(success).toHaveBeenCalledTimes(1); + discardPeriodicTasks(); + })); + + it('should emit the new bounds every 200ms by default', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 3, lng: 3}); + expect(success).toHaveBeenCalledTimes(1); + tick(150); + expect(success).toHaveBeenCalledTimes(1); + tick(200); + expect(success).toHaveBeenCalledTimes(2); + discardPeriodicTasks(); + })); + + it('should provide all latLng to the bounds', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + const latLngs = [ + {lat: 2, lng: 2}, + {lat: 3, lng: 3}, + {lat: 4, lng: 4} + ]; + fitBoundsService.addToBounds(latLngs[0]); + fitBoundsService.addToBounds(latLngs[1]); + fitBoundsService.addToBounds(latLngs[2]); + expect(latLngBoundsExtend).toHaveBeenCalledTimes(0); + tick(200); + expect(latLngBoundsExtend).toHaveBeenCalledTimes(3); + expect(latLngBoundsExtend).toHaveBeenCalledWith(latLngs[0]); + expect(latLngBoundsExtend).toHaveBeenCalledWith(latLngs[1]); + expect(latLngBoundsExtend).toHaveBeenCalledWith(latLngs[2]); + discardPeriodicTasks(); + })); + + it('should remove latlng from bounds and emit the new bounds after the sample time', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 3, lng: 3}); + tick(200); + latLngBoundsExtend.mockReset(); + + fitBoundsService.removeFromBounds({lat: 2, lng: 2}); + fitBoundsService.removeFromBounds({lat: 3, lng: 3}); + tick(150); + expect(latLngBoundsExtend).toHaveBeenCalledTimes(0); + tick(200); + + expect(latLngBoundsExtend).toHaveBeenCalledTimes(0); + discardPeriodicTasks(); + })); + + it('should use the new _boundsChangeSampleTime$ for all next bounds', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 3, lng: 3}); + tick(200); + success.mockReset(); + + fitBoundsService.changeFitBoundsChangeSampleTime(100); + fitBoundsService.removeFromBounds({lat: 2, lng: 2}); + fitBoundsService.removeFromBounds({lat: 3, lng: 3}); + tick(100); + + expect(success).toHaveBeenCalledTimes(1); + discardPeriodicTasks(); + })); + +}); diff --git a/packages/core/services/fit-bounds.ts b/packages/core/services/fit-bounds.ts index 295c9e778..3aeeb140f 100644 --- a/packages/core/services/fit-bounds.ts +++ b/packages/core/services/fit-bounds.ts @@ -1,39 +1,57 @@ import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable, from } from 'rxjs'; -import { flatMap, map, skipWhile } from 'rxjs/operators'; +import { BehaviorSubject, Observable, from, timer } from 'rxjs'; +import { + flatMap, + map, + skipWhile, + sample, + switchMap, + shareReplay +} from 'rxjs/operators'; import { LatLng, LatLngBounds, LatLngLiteral } from './google-maps-types'; import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; declare var google: any; +// todo: internal? export interface FitBoundsDetails { - latLng: LatLng|LatLngLiteral; + latLng: LatLng | LatLngLiteral; } +/** + * @internal + */ +export type BoundsMap = Map; + export abstract class FitBoundsAccessor { abstract getFitBoundsDetails$(): Observable; } @Injectable() export class FitBoundsService { - readonly bounds$: Observable; - protected readonly _boundsChangeDebounceTime$: BehaviorSubject = new BehaviorSubject(200); - protected readonly _includeInBounds$: BehaviorSubject> = new BehaviorSubject>(new Map()); - protected _emitPaused: boolean = false; + protected readonly bounds$: Observable; + protected readonly _boundsChangeSampleTime$ = new BehaviorSubject(200); + protected readonly _includeInBounds$ = new BehaviorSubject(new Map()); constructor(loader: MapsAPILoader) { this.bounds$ = from(loader.load()).pipe( flatMap(() => this._includeInBounds$), - skipWhile(() => this._emitPaused), - // debounce(() => this._boundsChangeDebounceTime$), - map((includeInBounds: Map) => { - const bounds = new google.maps.LatLngBounds() as LatLngBounds; - includeInBounds.forEach(b => bounds.extend(b)); - return bounds; - }) + sample( + this._boundsChangeSampleTime$.pipe(switchMap(time => timer(0, time))) + ), + map(includeInBounds => this._generateBounds(includeInBounds)), + shareReplay(1) ); } + private _generateBounds( + includeInBounds: Map + ) { + const bounds = new google.maps.LatLngBounds() as LatLngBounds; + includeInBounds.forEach(b => bounds.extend(b)); + return bounds; + } + addToBounds(latLng: LatLng | LatLngLiteral) { const id = this._createIdentifier(latLng); if (this._includeInBounds$.value.has(id)) { @@ -44,17 +62,21 @@ export class FitBoundsService { this._includeInBounds$.next(map); } - removeFromBounds(latLng: LatLng|LatLngLiteral) { + removeFromBounds(latLng: LatLng | LatLngLiteral) { const map = this._includeInBounds$.value; map.delete(this._createIdentifier(latLng)); this._includeInBounds$.next(map); } - changeFitBoundsDebounceTime(timeMs: number) { - this._boundsChangeDebounceTime$.next(timeMs); + changeFitBoundsChangeSampleTime(timeMs: number) { + this._boundsChangeSampleTime$.next(timeMs); + } + + getBounds$(): Observable { + return this.bounds$; } - protected _createIdentifier(latLng: LatLng|LatLngLiteral): string { + protected _createIdentifier(latLng: LatLng | LatLngLiteral): string { return `${latLng.lat}+${latLng.lng}`; } } From 52d50bfa93dd9c9ac7b5d54bd991519999a50daa Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Tue, 5 Jun 2018 22:34:53 +0200 Subject: [PATCH 3/9] remove tslint rule --- tslint.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tslint.json b/tslint.json index e8e32a8c6..4e08d2f46 100644 --- a/tslint.json +++ b/tslint.json @@ -49,8 +49,7 @@ "typedef": [ true, "parameter", - "property-declaration", - "member-variable-declaration" + "property-declaration" ], "typedef-whitespace": [ true, From 8f189d6f07d3e41f728164f98b12f57ed3cdd419 Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Tue, 5 Jun 2018 22:38:27 +0200 Subject: [PATCH 4/9] fix method call --- packages/core/directives/map.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/directives/map.ts b/packages/core/directives/map.ts index 0d3a0ad6d..426d5ee91 100644 --- a/packages/core/directives/map.ts +++ b/packages/core/directives/map.ts @@ -469,7 +469,7 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { } private _subscribeToFitBoundsUpdates() { - this._fitBoundsSubscription = this._fitBoundsService.bounds$.subscribe(b => this._updateBounds(b)); + this._fitBoundsSubscription = this._fitBoundsService.getBounds$().subscribe(b => this._updateBounds(b)); } protected _updateBounds(bounds: LatLngBounds|LatLngBoundsLiteral) { From 9f643845a2059ba866d6280fab6b91796423f346 Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Mon, 30 Apr 2018 16:23:11 +0200 Subject: [PATCH 5/9] feat(core): auto fitBounds --- .prettierrc.json | 3 + packages/core/core.module.ts | 3 +- packages/core/directives.ts | 1 + packages/core/directives/fit-bounds.ts | 75 +++++++++++++++++++++ packages/core/directives/map.ts | 53 +++++++++++++-- packages/core/directives/marker.ts | 37 +++++++--- packages/core/services/fit-bounds.spec.ts | 38 +++++++++++ packages/core/services/fit-bounds.ts | 60 +++++++++++++++++ packages/core/services/google-maps-types.ts | 3 +- tslint.json | 2 +- 10 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 .prettierrc.json create mode 100644 packages/core/directives/fit-bounds.ts create mode 100644 packages/core/services/fit-bounds.spec.ts create mode 100644 packages/core/services/fit-bounds.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..92cde390a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} \ No newline at end of file diff --git a/packages/core/core.module.ts b/packages/core/core.module.ts index f4f4277bc..97b0edc2b 100644 --- a/packages/core/core.module.ts +++ b/packages/core/core.module.ts @@ -13,6 +13,7 @@ import {LazyMapsAPILoader} from './services/maps-api-loader/lazy-maps-api-loader import {LAZY_MAPS_API_CONFIG, LazyMapsAPILoaderConfigLiteral} from './services/maps-api-loader/lazy-maps-api-loader'; import {MapsAPILoader} from './services/maps-api-loader/maps-api-loader'; import {BROWSER_GLOBALS_PROVIDERS} from './utils/browser-globals'; +import {AgmFitBounds} from '@agm/core/directives/fit-bounds'; /** * @internal @@ -21,7 +22,7 @@ export function coreDirectives() { return [ AgmMap, AgmMarker, AgmInfoWindow, AgmCircle, AgmRectangle, AgmPolygon, AgmPolyline, AgmPolylinePoint, AgmKmlLayer, - AgmDataLayer + AgmDataLayer, AgmFitBounds ]; } diff --git a/packages/core/directives.ts b/packages/core/directives.ts index d4bb07238..3edc4fef9 100644 --- a/packages/core/directives.ts +++ b/packages/core/directives.ts @@ -8,3 +8,4 @@ export {AgmMarker} from './directives/marker'; export {AgmPolygon} from './directives/polygon'; export {AgmPolyline} from './directives/polyline'; export {AgmPolylinePoint} from './directives/polyline-point'; +export {AgmFitBounds} from './directives/fit-bounds'; diff --git a/packages/core/directives/fit-bounds.ts b/packages/core/directives/fit-bounds.ts new file mode 100644 index 000000000..187046c50 --- /dev/null +++ b/packages/core/directives/fit-bounds.ts @@ -0,0 +1,75 @@ +import { Directive, OnInit, Self, OnDestroy, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { FitBoundsService, FitBoundsAccessor, FitBoundsDetails } from '../services/fit-bounds'; +import { Subscription, Subject } from 'rxjs'; +import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { LatLng, LatLngLiteral } from '@agm/core'; + +/** + * TODO: docs + */ +@Directive({ + selector: '[agmFitBounds]' +}) +export class AgmFitBounds implements OnInit, OnDestroy, OnChanges { + /** + * If the value is true, the element gets added to the bounds of the map. + * Default: true. + */ + @Input() agmFitBounds: boolean = true; + + private _destroyed$: Subject = new Subject(); + private _latestFitBoundsDetails: FitBoundsDetails | null = null; + + constructor( + @Self() private readonly _fitBoundsAccessor: FitBoundsAccessor, + private readonly _fitBoundsService: FitBoundsService + ) {} + + /** + * @internal + */ + ngOnChanges(changes: SimpleChanges) { + this._updateBounds(); + } + + /** + * @internal + */ + ngOnInit() { + this._fitBoundsAccessor + .getFitBoundsDetails$() + .pipe( + distinctUntilChanged( + (x: FitBoundsDetails, y: FitBoundsDetails) => + x.latLng.lat === y.latLng.lng + ), + takeUntil(this._destroyed$) + ) + .subscribe(details => this._updateBounds(details)); + } + + private _updateBounds(newFitBoundsDetails?: FitBoundsDetails) { + if (newFitBoundsDetails) { + this._latestFitBoundsDetails = newFitBoundsDetails; + } + if (!this._latestFitBoundsDetails) { + return; + } + if (this.agmFitBounds) { + this._fitBoundsService.addToBounds(this._latestFitBoundsDetails.latLng); + } else { + this._fitBoundsService.removeFromBounds(this._latestFitBoundsDetails.latLng); + } + } + + /** + * @internal + */ + ngOnDestroy() { + this._destroyed$.next(); + this._destroyed$.complete(); + if (this._latestFitBoundsDetails !== null) { + this._fitBoundsService.removeFromBounds(this._latestFitBoundsDetails.latLng); + } + } +} diff --git a/packages/core/directives/map.ts b/packages/core/directives/map.ts index 00d30964f..1a1ad843a 100644 --- a/packages/core/directives/map.ts +++ b/packages/core/directives/map.ts @@ -15,6 +15,9 @@ import {PolygonManager} from '../services/managers/polygon-manager'; import {PolylineManager} from '../services/managers/polyline-manager'; import {KmlLayerManager} from './../services/managers/kml-layer-manager'; import {DataLayerManager} from './../services/managers/data-layer-manager'; +import {FitBoundsService} from '../services/fit-bounds'; + +declare var google: any; /** * AgmMap renders a Google Map. @@ -43,7 +46,7 @@ import {DataLayerManager} from './../services/managers/data-layer-manager'; selector: 'agm-map', providers: [ GoogleMapsAPIWrapper, MarkerManager, InfoWindowManager, CircleManager, RectangleManager, - PolylineManager, PolygonManager, KmlLayerManager, DataLayerManager + PolylineManager, PolygonManager, KmlLayerManager, DataLayerManager, DataLayerManager ], host: { // todo: deprecated - we will remove it with the next version @@ -180,8 +183,9 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { /** * Sets the viewport to contain the given bounds. + * If this option to `true`, the bounds get automatically computed from all elements that use the {@link AgmFitBounds} directive. */ - @Input() fitBounds: LatLngBoundsLiteral|LatLngBounds = null; + @Input() fitBounds: LatLngBoundsLiteral|LatLngBounds|boolean = false; /** * The initial enabled/disabled state of the Scale control. This is disabled by default. @@ -267,6 +271,7 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { ]; private _observableSubscriptions: Subscription[] = []; + private _fitBoundsSubscription: Subscription; /** * This event emitter gets emitted when the user clicks on the map (but not when they click on a @@ -317,7 +322,7 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { */ @Output() mapReady: EventEmitter = new EventEmitter(); - constructor(private _elem: ElementRef, private _mapsWrapper: GoogleMapsAPIWrapper) {} + constructor(private _elem: ElementRef, private _mapsWrapper: GoogleMapsAPIWrapper, protected _fitBoundsService: FitBoundsService) {} /** @internal */ ngOnInit() { @@ -378,6 +383,9 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { // remove all listeners from the map instance this._mapsWrapper.clearInstanceListeners(); + if (this._fitBoundsSubscription) { + this._fitBoundsSubscription.unsubscribe(); + } } /* @internal */ @@ -417,13 +425,13 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { private _updatePosition(changes: SimpleChanges) { if (changes['latitude'] == null && changes['longitude'] == null && - changes['fitBounds'] == null) { + !changes['fitBounds']) { // no position update needed return; } // we prefer fitBounds in changes - if (changes['fitBounds'] && this.fitBounds != null) { + if ('fitBounds' in changes) { this._fitBounds(); return; } @@ -447,11 +455,42 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { } private _fitBounds() { + switch (this.fitBounds) { + case true: + this._subscribeToFitBoundsUpdates(); + break; + case false: + if (this._fitBoundsSubscription) { + this._fitBoundsSubscription.unsubscribe(); + } + break; + default: + this._updateBounds(this.fitBounds); + } + } + + private _subscribeToFitBoundsUpdates() { + this._fitBoundsSubscription = this._fitBoundsService.bounds$.subscribe(b => this._updateBounds(b)); + } + + protected _updateBounds(bounds: LatLngBounds|LatLngBoundsLiteral) { + if (this._isLatLngBoundsLiteral(bounds)) { + const newBounds = google.maps.LatLngBounds(); + newBounds.union(bounds); + bounds = newBounds; + } + if (bounds.isEmpty()) { + return; + } if (this.usePanning) { - this._mapsWrapper.panToBounds(this.fitBounds); + this._mapsWrapper.panToBounds(bounds); return; } - this._mapsWrapper.fitBounds(this.fitBounds); + this._mapsWrapper.fitBounds(bounds); + } + + private _isLatLngBoundsLiteral(bounds: LatLngBounds|LatLngBoundsLiteral): bounds is LatLngBoundsLiteral { + return (bounds).extend === undefined; } private _handleMapCenterChange() { diff --git a/packages/core/directives/marker.ts b/packages/core/directives/marker.ts index e4ccd8f8e..7dac97784 100644 --- a/packages/core/directives/marker.ts +++ b/packages/core/directives/marker.ts @@ -1,14 +1,11 @@ -import {Directive, EventEmitter, OnChanges, OnDestroy, SimpleChange, - AfterContentInit, ContentChildren, QueryList, Input, Output -} from '@angular/core'; -import {Subscription} from 'rxjs'; - -import {MouseEvent} from '../map-types'; +import { AfterContentInit, ContentChildren, Directive, EventEmitter, Input, OnChanges, OnDestroy, Output, QueryList, SimpleChange, forwardRef } from '@angular/core'; +import { Observable, ReplaySubject, Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { MarkerLabel, MouseEvent } from '../map-types'; +import { FitBoundsAccessor, FitBoundsDetails } from '../services/fit-bounds'; import * as mapTypes from '../services/google-maps-types'; -import {MarkerManager} from '../services/managers/marker-manager'; - -import {AgmInfoWindow} from './info-window'; -import {MarkerLabel} from '../map-types'; +import { MarkerManager } from '../services/managers/marker-manager'; +import { AgmInfoWindow } from './info-window'; let markerId = 0; @@ -37,13 +34,16 @@ let markerId = 0; */ @Directive({ selector: 'agm-marker', + providers: [ + {provide: FitBoundsAccessor, useExisting: forwardRef(() => AgmMarker)} + ], inputs: [ 'latitude', 'longitude', 'title', 'label', 'draggable: markerDraggable', 'iconUrl', 'openInfoWindow', 'opacity', 'visible', 'zIndex', 'animation' ], outputs: ['markerClick', 'dragEnd', 'mouseOver', 'mouseOut'] }) -export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { +export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit, FitBoundsAccessor { /** * The latitude position of the marker. */ @@ -139,6 +139,8 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { private _id: string; private _observableSubscriptions: Subscription[] = []; + protected readonly _fitBoundsDetails$: ReplaySubject = new ReplaySubject(1); + constructor(private _markerManager: MarkerManager) { this._id = (markerId++).toString(); } /* @internal */ @@ -169,12 +171,14 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { } if (!this._markerAddedToManger) { this._markerManager.addMarker(this); + this._updateFitBoundsDetails(); this._markerAddedToManger = true; this._addEventListeners(); return; } if (changes['latitude'] || changes['longitude']) { this._markerManager.updateMarkerPosition(this); + this._updateFitBoundsDetails(); } if (changes['title']) { this._markerManager.updateTitle(this); @@ -205,6 +209,17 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { } } + /** + * @internal + */ + getFitBoundsDetails$(): Observable { + return this._fitBoundsDetails$.asObservable().pipe(tap(() => console.log('subscribe'))); + } + + protected _updateFitBoundsDetails() { + this._fitBoundsDetails$.next({latLng: {lat: this.latitude, lng: this.longitude}}); + } + private _addEventListeners() { const cs = this._markerManager.createEventObservable('click', this).subscribe(() => { if (this.openInfoWindow) { diff --git a/packages/core/services/fit-bounds.spec.ts b/packages/core/services/fit-bounds.spec.ts new file mode 100644 index 000000000..252609af6 --- /dev/null +++ b/packages/core/services/fit-bounds.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { FitBoundsService } from './fit-bounds'; +import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; +import { LatLngBounds } from '@agm/core'; + +describe('FitBoundsService', () => { + let loader: MapsAPILoader; + let fitBoundsService: FitBoundsService; + let latLngBounds: jest.Mock; + + beforeEach(() => { + loader = { + load: jest.fn().mockReturnValue(Promise.resolve()) + }; + + latLngBounds = jest.fn(); + (window).google = { + maps: { + LatLngBounds: jest.fn().mockReturnValue(latLngBounds) + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: MapsAPILoader, useValue: loader}, + FitBoundsService + ] + }); + + fitBoundsService = TestBed.get(FitBoundsService); + }); + + it('should wait for the load event', () => { + expect(loader.load).toHaveBeenCalledTimes(1); + expect(latLngBounds.mock.instances.length).toEqual(0); + }); + +}); \ No newline at end of file diff --git a/packages/core/services/fit-bounds.ts b/packages/core/services/fit-bounds.ts new file mode 100644 index 000000000..295c9e778 --- /dev/null +++ b/packages/core/services/fit-bounds.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, from } from 'rxjs'; +import { flatMap, map, skipWhile } from 'rxjs/operators'; +import { LatLng, LatLngBounds, LatLngLiteral } from './google-maps-types'; +import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; + +declare var google: any; + +export interface FitBoundsDetails { + latLng: LatLng|LatLngLiteral; +} + +export abstract class FitBoundsAccessor { + abstract getFitBoundsDetails$(): Observable; +} + +@Injectable() +export class FitBoundsService { + readonly bounds$: Observable; + protected readonly _boundsChangeDebounceTime$: BehaviorSubject = new BehaviorSubject(200); + protected readonly _includeInBounds$: BehaviorSubject> = new BehaviorSubject>(new Map()); + protected _emitPaused: boolean = false; + + constructor(loader: MapsAPILoader) { + this.bounds$ = from(loader.load()).pipe( + flatMap(() => this._includeInBounds$), + skipWhile(() => this._emitPaused), + // debounce(() => this._boundsChangeDebounceTime$), + map((includeInBounds: Map) => { + const bounds = new google.maps.LatLngBounds() as LatLngBounds; + includeInBounds.forEach(b => bounds.extend(b)); + return bounds; + }) + ); + } + + addToBounds(latLng: LatLng | LatLngLiteral) { + const id = this._createIdentifier(latLng); + if (this._includeInBounds$.value.has(id)) { + return; + } + const map = this._includeInBounds$.value; + map.set(id, latLng); + this._includeInBounds$.next(map); + } + + removeFromBounds(latLng: LatLng|LatLngLiteral) { + const map = this._includeInBounds$.value; + map.delete(this._createIdentifier(latLng)); + this._includeInBounds$.next(map); + } + + changeFitBoundsDebounceTime(timeMs: number) { + this._boundsChangeDebounceTime$.next(timeMs); + } + + protected _createIdentifier(latLng: LatLng|LatLngLiteral): string { + return `${latLng.lat}+${latLng.lng}`; + } +} diff --git a/packages/core/services/google-maps-types.ts b/packages/core/services/google-maps-types.ts index 1012a1db5..67d7b67b0 100644 --- a/packages/core/services/google-maps-types.ts +++ b/packages/core/services/google-maps-types.ts @@ -20,6 +20,7 @@ export interface LatLng { constructor(lat: number, lng: number): void; lat(): number; lng(): number; + toString(): string; } export interface Marker extends MVCObject { @@ -127,7 +128,7 @@ export interface RectangleOptions { export interface LatLngBounds { contains(latLng: LatLng): boolean; equals(other: LatLngBounds|LatLngBoundsLiteral): boolean; - extend(point: LatLng): void; + extend(point: LatLng|LatLngLiteral): void; getCenter(): LatLng; getNorthEast(): LatLng; getSouthWest(): LatLng; diff --git a/tslint.json b/tslint.json index 3d4c0bd22..e8e32a8c6 100644 --- a/tslint.json +++ b/tslint.json @@ -81,7 +81,7 @@ "no-attribute-parameter-decorator": true, "no-input-rename": true, "no-output-rename": true, - "no-forward-ref": true, + "no-forward-ref": false, "use-life-cycle-interface": true, "use-pipe-transform-interface": true, "pipe-naming": [true, "camelCase", "agm"], From c649f70f88ce9ce1245920cd3e9a49e0b066a0dc Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Tue, 5 Jun 2018 22:26:47 +0200 Subject: [PATCH 6/9] add tests --- packages/core/services/fit-bounds.spec.ts | 108 ++++++++++++++++++++-- packages/core/services/fit-bounds.ts | 58 ++++++++---- 2 files changed, 140 insertions(+), 26 deletions(-) diff --git a/packages/core/services/fit-bounds.spec.ts b/packages/core/services/fit-bounds.spec.ts index 252609af6..a8bbf4852 100644 --- a/packages/core/services/fit-bounds.spec.ts +++ b/packages/core/services/fit-bounds.spec.ts @@ -1,22 +1,32 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; import { FitBoundsService } from './fit-bounds'; import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; import { LatLngBounds } from '@agm/core'; +import { take, first } from 'rxjs/operators'; describe('FitBoundsService', () => { let loader: MapsAPILoader; let fitBoundsService: FitBoundsService; - let latLngBounds: jest.Mock; + let latLngBoundsConstructs: number; + let latLngBoundsExtend: jest.Mock; - beforeEach(() => { + beforeEach(fakeAsync(() => { loader = { load: jest.fn().mockReturnValue(Promise.resolve()) }; - latLngBounds = jest.fn(); + latLngBoundsConstructs = 0; + latLngBoundsExtend = jest.fn(); + (window).google = { maps: { - LatLngBounds: jest.fn().mockReturnValue(latLngBounds) + LatLngBounds: class LatLngBounds { + extend: jest.Mock = latLngBoundsExtend; + + constructor() { + latLngBoundsConstructs += 1; + } + } } }; @@ -28,11 +38,93 @@ describe('FitBoundsService', () => { }); fitBoundsService = TestBed.get(FitBoundsService); - }); + tick(); + })); it('should wait for the load event', () => { expect(loader.load).toHaveBeenCalledTimes(1); - expect(latLngBounds.mock.instances.length).toEqual(0); + expect(latLngBoundsConstructs).toEqual(0); }); -}); \ No newline at end of file + it('should emit empty bounds when API finished loaded but the are not entries in the includeInBounds$ map', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().pipe(first()).subscribe(success); + tick(); + expect(success).toHaveBeenCalledTimes(1); + discardPeriodicTasks(); + })); + + it('should emit the new bounds every 200ms by default', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 3, lng: 3}); + expect(success).toHaveBeenCalledTimes(1); + tick(150); + expect(success).toHaveBeenCalledTimes(1); + tick(200); + expect(success).toHaveBeenCalledTimes(2); + discardPeriodicTasks(); + })); + + it('should provide all latLng to the bounds', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + const latLngs = [ + {lat: 2, lng: 2}, + {lat: 3, lng: 3}, + {lat: 4, lng: 4} + ]; + fitBoundsService.addToBounds(latLngs[0]); + fitBoundsService.addToBounds(latLngs[1]); + fitBoundsService.addToBounds(latLngs[2]); + expect(latLngBoundsExtend).toHaveBeenCalledTimes(0); + tick(200); + expect(latLngBoundsExtend).toHaveBeenCalledTimes(3); + expect(latLngBoundsExtend).toHaveBeenCalledWith(latLngs[0]); + expect(latLngBoundsExtend).toHaveBeenCalledWith(latLngs[1]); + expect(latLngBoundsExtend).toHaveBeenCalledWith(latLngs[2]); + discardPeriodicTasks(); + })); + + it('should remove latlng from bounds and emit the new bounds after the sample time', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 3, lng: 3}); + tick(200); + latLngBoundsExtend.mockReset(); + + fitBoundsService.removeFromBounds({lat: 2, lng: 2}); + fitBoundsService.removeFromBounds({lat: 3, lng: 3}); + tick(150); + expect(latLngBoundsExtend).toHaveBeenCalledTimes(0); + tick(200); + + expect(latLngBoundsExtend).toHaveBeenCalledTimes(0); + discardPeriodicTasks(); + })); + + it('should use the new _boundsChangeSampleTime$ for all next bounds', fakeAsync(() => { + const success = jest.fn(); + fitBoundsService.getBounds$().subscribe(success); + tick(1); + fitBoundsService.addToBounds({lat: 2, lng: 2}); + fitBoundsService.addToBounds({lat: 3, lng: 3}); + tick(200); + success.mockReset(); + + fitBoundsService.changeFitBoundsChangeSampleTime(100); + fitBoundsService.removeFromBounds({lat: 2, lng: 2}); + fitBoundsService.removeFromBounds({lat: 3, lng: 3}); + tick(100); + + expect(success).toHaveBeenCalledTimes(1); + discardPeriodicTasks(); + })); + +}); diff --git a/packages/core/services/fit-bounds.ts b/packages/core/services/fit-bounds.ts index 295c9e778..3aeeb140f 100644 --- a/packages/core/services/fit-bounds.ts +++ b/packages/core/services/fit-bounds.ts @@ -1,39 +1,57 @@ import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable, from } from 'rxjs'; -import { flatMap, map, skipWhile } from 'rxjs/operators'; +import { BehaviorSubject, Observable, from, timer } from 'rxjs'; +import { + flatMap, + map, + skipWhile, + sample, + switchMap, + shareReplay +} from 'rxjs/operators'; import { LatLng, LatLngBounds, LatLngLiteral } from './google-maps-types'; import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; declare var google: any; +// todo: internal? export interface FitBoundsDetails { - latLng: LatLng|LatLngLiteral; + latLng: LatLng | LatLngLiteral; } +/** + * @internal + */ +export type BoundsMap = Map; + export abstract class FitBoundsAccessor { abstract getFitBoundsDetails$(): Observable; } @Injectable() export class FitBoundsService { - readonly bounds$: Observable; - protected readonly _boundsChangeDebounceTime$: BehaviorSubject = new BehaviorSubject(200); - protected readonly _includeInBounds$: BehaviorSubject> = new BehaviorSubject>(new Map()); - protected _emitPaused: boolean = false; + protected readonly bounds$: Observable; + protected readonly _boundsChangeSampleTime$ = new BehaviorSubject(200); + protected readonly _includeInBounds$ = new BehaviorSubject(new Map()); constructor(loader: MapsAPILoader) { this.bounds$ = from(loader.load()).pipe( flatMap(() => this._includeInBounds$), - skipWhile(() => this._emitPaused), - // debounce(() => this._boundsChangeDebounceTime$), - map((includeInBounds: Map) => { - const bounds = new google.maps.LatLngBounds() as LatLngBounds; - includeInBounds.forEach(b => bounds.extend(b)); - return bounds; - }) + sample( + this._boundsChangeSampleTime$.pipe(switchMap(time => timer(0, time))) + ), + map(includeInBounds => this._generateBounds(includeInBounds)), + shareReplay(1) ); } + private _generateBounds( + includeInBounds: Map + ) { + const bounds = new google.maps.LatLngBounds() as LatLngBounds; + includeInBounds.forEach(b => bounds.extend(b)); + return bounds; + } + addToBounds(latLng: LatLng | LatLngLiteral) { const id = this._createIdentifier(latLng); if (this._includeInBounds$.value.has(id)) { @@ -44,17 +62,21 @@ export class FitBoundsService { this._includeInBounds$.next(map); } - removeFromBounds(latLng: LatLng|LatLngLiteral) { + removeFromBounds(latLng: LatLng | LatLngLiteral) { const map = this._includeInBounds$.value; map.delete(this._createIdentifier(latLng)); this._includeInBounds$.next(map); } - changeFitBoundsDebounceTime(timeMs: number) { - this._boundsChangeDebounceTime$.next(timeMs); + changeFitBoundsChangeSampleTime(timeMs: number) { + this._boundsChangeSampleTime$.next(timeMs); + } + + getBounds$(): Observable { + return this.bounds$; } - protected _createIdentifier(latLng: LatLng|LatLngLiteral): string { + protected _createIdentifier(latLng: LatLng | LatLngLiteral): string { return `${latLng.lat}+${latLng.lng}`; } } From e1a0350c8bdbb59dd1033b1b61932ece8279de0b Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Tue, 5 Jun 2018 22:34:53 +0200 Subject: [PATCH 7/9] remove tslint rule --- tslint.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tslint.json b/tslint.json index e8e32a8c6..4e08d2f46 100644 --- a/tslint.json +++ b/tslint.json @@ -49,8 +49,7 @@ "typedef": [ true, "parameter", - "property-declaration", - "member-variable-declaration" + "property-declaration" ], "typedef-whitespace": [ true, From 090eddd31c8dac86a7e0ceebaf3b0c8e56573c40 Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Tue, 5 Jun 2018 22:38:27 +0200 Subject: [PATCH 8/9] fix method call --- packages/core/directives/map.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/directives/map.ts b/packages/core/directives/map.ts index 1a1ad843a..4bd1ffbba 100644 --- a/packages/core/directives/map.ts +++ b/packages/core/directives/map.ts @@ -470,7 +470,7 @@ export class AgmMap implements OnChanges, OnInit, OnDestroy { } private _subscribeToFitBoundsUpdates() { - this._fitBoundsSubscription = this._fitBoundsService.bounds$.subscribe(b => this._updateBounds(b)); + this._fitBoundsSubscription = this._fitBoundsService.getBounds$().subscribe(b => this._updateBounds(b)); } protected _updateBounds(bounds: LatLngBounds|LatLngBoundsLiteral) { From 6f2a979ad27b552f598ab33ed3b155c11c95b820 Mon Sep 17 00:00:00 2001 From: Sebastian Holstein Date: Sat, 22 Sep 2018 10:32:40 +0200 Subject: [PATCH 9/9] Add docs and remove console logs --- docs/content/guides/auto-fit-bounds.md | 19 +++++++ .../guides/implement-auto-fit-bounds.md | 54 +++++++++++++++++++ packages/core/directives/fit-bounds.ts | 5 +- packages/core/directives/marker.ts | 2 +- packages/core/services.ts | 1 + packages/core/services/fit-bounds.ts | 8 ++- 6 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 docs/content/guides/auto-fit-bounds.md create mode 100644 docs/content/guides/implement-auto-fit-bounds.md diff --git a/docs/content/guides/auto-fit-bounds.md b/docs/content/guides/auto-fit-bounds.md new file mode 100644 index 000000000..67d6f01a7 --- /dev/null +++ b/docs/content/guides/auto-fit-bounds.md @@ -0,0 +1,19 @@ ++++ +date = "2018-09-22T09:31:00-01:00" +draft = false +title = "Enable auto fit bounds" + ++++ + +Angular Google Maps (AGM) has an auto fit bounds feature, that adds all containing components to the bounds of the map. +To enable it, set the `fitBounds` input of `agm-map` to `true` and add the `agmFitBounds` input/directive to `true` for all components +you want to include in the bounds of the map. + +```html + + + + + + +``` \ No newline at end of file diff --git a/docs/content/guides/implement-auto-fit-bounds.md b/docs/content/guides/implement-auto-fit-bounds.md new file mode 100644 index 000000000..26e0ae96c --- /dev/null +++ b/docs/content/guides/implement-auto-fit-bounds.md @@ -0,0 +1,54 @@ ++++ +date = "2018-09-22T09:31:00-01:00" +draft = false +title = "Support auto fit bounds for custom components" + ++++ + +Angular Google Maps (AGM) has an auto fit bounds feature, that adds all containing components to the bounds of the map: + +```html + + + +``` + +Let`s say we have a custom component, that extends the features of AGM: + + +```html + + + +``` + +To add support the auto fit bounds feature for ``, we have to implement the `FitBoundsAccessor`: + +```typescript +import { FitBoundsAccessor, FitBoundsDetails } from '@agm/core'; +import { forwardRef, Component } from '@angular/core'; + +@Component({ + selector: 'my-custom-component', + template: '', + providers: [ + {provide: FitBoundsAccessor, useExisting: forwardRef(() => MyCustomComponent)} + ], +}) +export class MyCustomComponent implements FitBoundsAccessor { + ** + * This is a method you need to implement with your custom logic. + */ + getFitBoundsDetails$(): Observable { + return ...; + } +} +``` + +The last step is to change your template. Add the `agmFitBounds` input/directive and set the value to true: + +```html + + + +``` \ No newline at end of file diff --git a/packages/core/directives/fit-bounds.ts b/packages/core/directives/fit-bounds.ts index 187046c50..954b10af7 100644 --- a/packages/core/directives/fit-bounds.ts +++ b/packages/core/directives/fit-bounds.ts @@ -5,7 +5,10 @@ import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; import { LatLng, LatLngLiteral } from '@agm/core'; /** - * TODO: docs + * Adds the given directive to the auto fit bounds feature when the value is true. + * To make it work with you custom AGM component, you also have to implement the {@link FitBoundsAccessor} abstract class. + * @example + * */ @Directive({ selector: '[agmFitBounds]' diff --git a/packages/core/directives/marker.ts b/packages/core/directives/marker.ts index 7dac97784..1748563a5 100644 --- a/packages/core/directives/marker.ts +++ b/packages/core/directives/marker.ts @@ -213,7 +213,7 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit, FitBou * @internal */ getFitBoundsDetails$(): Observable { - return this._fitBoundsDetails$.asObservable().pipe(tap(() => console.log('subscribe'))); + return this._fitBoundsDetails$.asObservable(); } protected _updateFitBoundsDetails() { diff --git a/packages/core/services.ts b/packages/core/services.ts index 1dfb9206f..cfb35e6af 100644 --- a/packages/core/services.ts +++ b/packages/core/services.ts @@ -10,3 +10,4 @@ export {DataLayerManager} from './services/managers/data-layer-manager'; export {GoogleMapsScriptProtocol, LAZY_MAPS_API_CONFIG, LazyMapsAPILoader, LazyMapsAPILoaderConfigLiteral} 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'; +export {FitBoundsAccessor, FitBoundsDetails} from './services/fit-bounds'; diff --git a/packages/core/services/fit-bounds.ts b/packages/core/services/fit-bounds.ts index 3aeeb140f..09558acce 100644 --- a/packages/core/services/fit-bounds.ts +++ b/packages/core/services/fit-bounds.ts @@ -13,7 +13,6 @@ import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; declare var google: any; -// todo: internal? export interface FitBoundsDetails { latLng: LatLng | LatLngLiteral; } @@ -23,10 +22,17 @@ export interface FitBoundsDetails { */ export type BoundsMap = Map; +/** + * Class to implement when you what to be able to make it work with the auto fit bounds feature + * of AGM. + */ export abstract class FitBoundsAccessor { abstract getFitBoundsDetails$(): Observable; } +/** + * The FitBoundsService is responsible for computing the bounds of the a single map. + */ @Injectable() export class FitBoundsService { protected readonly bounds$: Observable;