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/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/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..954b10af7 --- /dev/null +++ b/packages/core/directives/fit-bounds.ts @@ -0,0 +1,78 @@ +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'; + +/** + * 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]' +}) +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..ac1454abf 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,8 @@ 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, + FitBoundsService ], host: { // todo: deprecated - we will remove it with the next version @@ -180,8 +184,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 +272,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 +323,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 +384,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 +426,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 +456,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.getBounds$().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 5762827c0..b663bda63 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. */ @@ -144,6 +144,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 */ @@ -174,12 +176,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); @@ -210,6 +214,17 @@ export class AgmMarker implements OnDestroy, OnChanges, AfterContentInit { } } + /** + * @internal + */ + getFitBoundsDetails$(): Observable { + return this._fitBoundsDetails$.asObservable(); + } + + 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.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.spec.ts b/packages/core/services/fit-bounds.spec.ts new file mode 100644 index 000000000..a8bbf4852 --- /dev/null +++ b/packages/core/services/fit-bounds.spec.ts @@ -0,0 +1,130 @@ +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 latLngBoundsConstructs: number; + let latLngBoundsExtend: jest.Mock; + + beforeEach(fakeAsync(() => { + loader = { + load: jest.fn().mockReturnValue(Promise.resolve()) + }; + + latLngBoundsConstructs = 0; + latLngBoundsExtend = jest.fn(); + + (window).google = { + maps: { + LatLngBounds: class LatLngBounds { + extend: jest.Mock = latLngBoundsExtend; + + constructor() { + latLngBoundsConstructs += 1; + } + } + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: MapsAPILoader, useValue: loader}, + FitBoundsService + ] + }); + + fitBoundsService = TestBed.get(FitBoundsService); + tick(); + })); + + it('should wait for the load event', () => { + expect(loader.load).toHaveBeenCalledTimes(1); + expect(latLngBoundsConstructs).toEqual(0); + }); + + 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 new file mode 100644 index 000000000..09558acce --- /dev/null +++ b/packages/core/services/fit-bounds.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@angular/core'; +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; + +export interface FitBoundsDetails { + latLng: LatLng | LatLngLiteral; +} + +/** + * @internal + */ +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; + 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$), + 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)) { + 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); + } + + changeFitBoundsChangeSampleTime(timeMs: number) { + this._boundsChangeSampleTime$.next(timeMs); + } + + getBounds$(): Observable { + return this.bounds$; + } + + 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..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, @@ -81,7 +80,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"],