diff --git a/src/dev-app/google-map/google-map-demo.html b/src/dev-app/google-map/google-map-demo.html index 60440a485e98..40b575181274 100644 --- a/src/dev-app/google-map/google-map-demo.html +++ b/src/dev-app/google-map/google-map-demo.html @@ -8,7 +8,7 @@ (mapMousemove)="handleMove($event)" (mapRightclick)="handleRightclick()" [mapTypeId]="mapTypeId"> - + diff --git a/src/dev-app/google-map/google-map-demo.ts b/src/dev-app/google-map/google-map-demo.ts index 6fac35af593e..4d2f3d4f2929 100644 --- a/src/dev-app/google-map/google-map-demo.ts +++ b/src/dev-app/google-map/google-map-demo.ts @@ -119,9 +119,6 @@ export class GoogleMapDemo { google.maps.MapTypeId.TERRAIN, ]; - markerClustererImagePath = - 'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m'; - directionsResult?: google.maps.DirectionsResult; constructor(private readonly _mapDirectionsService: MapDirectionsService) {} diff --git a/src/dev-app/index.html b/src/dev-app/index.html index b02a2bafb2c9..5711153b87f9 100644 --- a/src/dev-app/index.html +++ b/src/dev-app/index.html @@ -20,7 +20,7 @@ - + + ``` Additional information can be found by looking at [Marker Clustering](https://developers.google.com/maps/documentation/javascript/marker-clustering) in the Google Maps JavaScript API documentation. @@ -26,8 +26,6 @@ export class GoogleMapDemo { center: google.maps.LatLngLiteral = {lat: 24, lng: 12}; zoom = 4; markerPositions: google.maps.LatLngLiteral[] = []; - markerClustererImagePath = - 'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m'; addMarker(event: google.maps.MapMouseEvent) { this.markerPositions.push(event.latLng.toJSON()); @@ -42,7 +40,7 @@ export class GoogleMapDemo { [center]="center" [zoom]="zoom" (mapClick)="addMarker($event)"> - + diff --git a/src/google-maps/map-marker-clusterer/map-marker-clusterer.spec.ts b/src/google-maps/map-marker-clusterer/map-marker-clusterer.spec.ts index 41f35486fb7a..32dceda13ee2 100644 --- a/src/google-maps/map-marker-clusterer/map-marker-clusterer.spec.ts +++ b/src/google-maps/map-marker-clusterer/map-marker-clusterer.spec.ts @@ -50,11 +50,11 @@ describe('MapMarkerClusterer', () => { afterEach(() => { (window.google as any) = undefined; - (window as any).MarkerClusterer = undefined; + (window as any).markerClusterer = undefined; }); it('throws an error if the clustering library has not been loaded', () => { - (window as any).MarkerClusterer = undefined; + (window as any).markerClusterer = undefined; markerClustererConstructorSpy = createMarkerClustererConstructorSpy( markerClustererSpy, false, @@ -63,8 +63,8 @@ describe('MapMarkerClusterer', () => { expect(() => fixture.detectChanges()).toThrow( new Error( 'MarkerClusterer class not found, cannot construct a marker cluster. ' + - 'Please install the MarkerClustererPlus library: ' + - 'https://github.com/googlemaps/js-markerclustererplus', + 'Please install the MarkerClusterer library: ' + + 'https://github.com/googlemaps/js-markerclusterer', ), ); }); @@ -72,106 +72,47 @@ describe('MapMarkerClusterer', () => { it('initializes a Google Map Marker Clusterer', () => { fixture.detectChanges(); - expect(markerClustererConstructorSpy).toHaveBeenCalledWith(mapSpy, [], { - ariaLabelFn: undefined, - averageCenter: undefined, - batchSize: undefined, - batchSizeIE: undefined, - calculator: undefined, - clusterClass: undefined, - enableRetinaIcons: undefined, - gridSize: undefined, - ignoreHidden: undefined, - imageExtension: undefined, - imagePath: undefined, - imageSizes: undefined, - maxZoom: undefined, - minimumClusterSize: undefined, - styles: undefined, - title: undefined, - zIndex: undefined, - zoomOnClick: undefined, + expect(markerClustererConstructorSpy).toHaveBeenCalledWith({ + map: mapSpy, + renderer: undefined, + algorithm: undefined, + onClusterClick: jasmine.any(Function), }); }); it('sets marker clusterer inputs', () => { - fixture.componentInstance.ariaLabelFn = (testString: string) => testString; - fixture.componentInstance.averageCenter = true; - fixture.componentInstance.batchSize = 1; - fixture.componentInstance.clusterClass = 'testClusterClass'; - fixture.componentInstance.enableRetinaIcons = true; - fixture.componentInstance.gridSize = 2; - fixture.componentInstance.ignoreHidden = true; - fixture.componentInstance.imageExtension = 'testImageExtension'; - fixture.componentInstance.imagePath = 'testImagePath'; - fixture.componentInstance.imageSizes = [3]; - fixture.componentInstance.maxZoom = 4; - fixture.componentInstance.minimumClusterSize = 5; - fixture.componentInstance.styles = []; - fixture.componentInstance.title = 'testTitle'; - fixture.componentInstance.zIndex = 6; - fixture.componentInstance.zoomOnClick = true; + fixture.componentInstance.algorithm = {name: 'custom'}; + fixture.componentInstance.renderer = {render: () => null!}; fixture.detectChanges(); - expect(markerClustererConstructorSpy).toHaveBeenCalledWith(mapSpy, [], { - ariaLabelFn: jasmine.any(Function), - averageCenter: true, - batchSize: 1, - batchSizeIE: undefined, - calculator: undefined, - clusterClass: 'testClusterClass', - enableRetinaIcons: true, - gridSize: 2, - ignoreHidden: true, - imageExtension: 'testImageExtension', - imagePath: 'testImagePath', - imageSizes: [3], - maxZoom: 4, - minimumClusterSize: 5, - styles: [], - title: 'testTitle', - zIndex: 6, - zoomOnClick: true, + expect(markerClustererConstructorSpy).toHaveBeenCalledWith({ + map: mapSpy, + algorithm: fixture.componentInstance.algorithm, + renderer: fixture.componentInstance.renderer, + onClusterClick: jasmine.any(Function), }); }); - it('sets marker clusterer options', () => { + it('recreates the clusterer if the options change', () => { + fixture.componentInstance.algorithm = {name: 'custom1'}; fixture.detectChanges(); - const options: MarkerClustererOptions = { - enableRetinaIcons: true, - gridSize: 1337, - ignoreHidden: true, - imageExtension: 'png', - }; - fixture.componentInstance.options = options; - fixture.detectChanges(); - expect(markerClustererSpy.setOptions).toHaveBeenCalledWith(jasmine.objectContaining(options)); - }); - it('gives precedence to specific inputs over options', () => { - fixture.detectChanges(); - const options: MarkerClustererOptions = { - enableRetinaIcons: true, - gridSize: 1337, - ignoreHidden: true, - imageExtension: 'png', - }; - const expectedOptions: MarkerClustererOptions = { - enableRetinaIcons: false, - gridSize: 42, - ignoreHidden: false, - imageExtension: 'jpeg', - }; - fixture.componentInstance.enableRetinaIcons = expectedOptions.enableRetinaIcons; - fixture.componentInstance.gridSize = expectedOptions.gridSize; - fixture.componentInstance.ignoreHidden = expectedOptions.ignoreHidden; - fixture.componentInstance.imageExtension = expectedOptions.imageExtension; - fixture.componentInstance.options = options; + expect(markerClustererConstructorSpy).toHaveBeenCalledWith({ + map: mapSpy, + algorithm: jasmine.objectContaining({name: 'custom1'}), + renderer: undefined, + onClusterClick: jasmine.any(Function), + }); + + fixture.componentInstance.algorithm = {name: 'custom2'}; fixture.detectChanges(); - expect(markerClustererSpy.setOptions).toHaveBeenCalledWith( - jasmine.objectContaining(expectedOptions), - ); + expect(markerClustererConstructorSpy).toHaveBeenCalledWith({ + map: mapSpy, + algorithm: jasmine.objectContaining({name: 'custom2'}), + renderer: undefined, + onClusterClick: jasmine.any(Function), + }); }); it('sets Google Maps Markers in the MarkerClusterer', () => { @@ -196,7 +137,7 @@ describe('MapMarkerClusterer', () => { expect(markerClustererSpy.addMarkers).toHaveBeenCalledWith([anyMarkerMatcher], true); expect(markerClustererSpy.removeMarkers).toHaveBeenCalledWith([anyMarkerMatcher], true); - expect(markerClustererSpy.repaint).toHaveBeenCalledTimes(1); + expect(markerClustererSpy.render).toHaveBeenCalledTimes(1); fixture.componentInstance.state = 'state0'; fixture.detectChanges(); @@ -206,149 +147,50 @@ describe('MapMarkerClusterer', () => { [anyMarkerMatcher, anyMarkerMatcher], true, ); - expect(markerClustererSpy.repaint).toHaveBeenCalledTimes(2); + expect(markerClustererSpy.render).toHaveBeenCalledTimes(2); }); - it('exposes marker clusterer methods', () => { + it('initializes event handlers on the map related to clustering', () => { fixture.detectChanges(); - const markerClustererComponent = fixture.componentInstance.markerClusterer; - - markerClustererComponent.fitMapToMarkers(5); - expect(markerClustererSpy.fitMapToMarkers).toHaveBeenCalledWith(5); - - markerClustererSpy.getAverageCenter.and.returnValue(true); - expect(markerClustererComponent.getAverageCenter()).toBe(true); - - markerClustererSpy.getBatchSizeIE.and.returnValue(6); - expect(markerClustererComponent.getBatchSizeIE()).toBe(6); - - const calculator = (markers: google.maps.Marker[], count: number) => ({ - index: 1, - text: 'testText', - title: 'testTitle', - }); - markerClustererSpy.getCalculator.and.returnValue(calculator); - expect(markerClustererComponent.getCalculator()).toBe(calculator); - - markerClustererSpy.getClusterClass.and.returnValue('testClusterClass'); - expect(markerClustererComponent.getClusterClass()).toBe('testClusterClass'); - - markerClustererSpy.getClusters.and.returnValue([]); - expect(markerClustererComponent.getClusters()).toEqual([]); - - markerClustererSpy.getEnableRetinaIcons.and.returnValue(true); - expect(markerClustererComponent.getEnableRetinaIcons()).toBe(true); - - markerClustererSpy.getGridSize.and.returnValue(7); - expect(markerClustererComponent.getGridSize()).toBe(7); - - markerClustererSpy.getIgnoreHidden.and.returnValue(true); - expect(markerClustererComponent.getIgnoreHidden()).toBe(true); - markerClustererSpy.getImageExtension.and.returnValue('testImageExtension'); - expect(markerClustererComponent.getImageExtension()).toBe('testImageExtension'); - - markerClustererSpy.getImagePath.and.returnValue('testImagePath'); - expect(markerClustererComponent.getImagePath()).toBe('testImagePath'); - - markerClustererSpy.getImageSizes.and.returnValue([]); - expect(markerClustererComponent.getImageSizes()).toEqual([]); - - markerClustererSpy.getMaxZoom.and.returnValue(8); - expect(markerClustererComponent.getMaxZoom()).toBe(8); - - markerClustererSpy.getMinimumClusterSize.and.returnValue(9); - expect(markerClustererComponent.getMinimumClusterSize()).toBe(9); - - markerClustererSpy.getStyles.and.returnValue([]); - expect(markerClustererComponent.getStyles()).toEqual([]); - - markerClustererSpy.getTitle.and.returnValue('testTitle'); - expect(markerClustererComponent.getTitle()).toBe('testTitle'); - - markerClustererSpy.getTotalClusters.and.returnValue(10); - expect(markerClustererComponent.getTotalClusters()).toBe(10); - - markerClustererSpy.getTotalMarkers.and.returnValue(11); - expect(markerClustererComponent.getTotalMarkers()).toBe(11); + expect(mapSpy.addListener).toHaveBeenCalledWith('clusteringbegin', jasmine.any(Function)); + expect(mapSpy.addListener).not.toHaveBeenCalledWith('clusteringend', jasmine.any(Function)); + }); - markerClustererSpy.getZIndex.and.returnValue(12); - expect(markerClustererComponent.getZIndex()).toBe(12); + it('emits to clusterClick when the `onClusterClick` callback is invoked', () => { + fixture.detectChanges(); - markerClustererSpy.getZoomOnClick.and.returnValue(true); - expect(markerClustererComponent.getZoomOnClick()).toBe(true); - }); + expect(fixture.componentInstance.onClusterClick).not.toHaveBeenCalled(); - it('initializes marker clusterer event handlers', () => { + const callback = markerClustererConstructorSpy.calls.mostRecent().args[0].onClusterClick; + callback({}, {}, {}); fixture.detectChanges(); - expect(markerClustererSpy.addListener).toHaveBeenCalledWith( - 'clusteringbegin', - jasmine.any(Function), - ); - expect(markerClustererSpy.addListener).not.toHaveBeenCalledWith( - 'clusteringend', - jasmine.any(Function), - ); - expect(markerClustererSpy.addListener).toHaveBeenCalledWith('click', jasmine.any(Function)); + expect(fixture.componentInstance.onClusterClick).toHaveBeenCalledTimes(1); }); }); @Component({ selector: 'test-app', - template: ` - - - - - - `, + template: ` + + + + + + + + `, }) class TestApp { @ViewChild(MapMarkerClusterer) markerClusterer: MapMarkerClusterer; - - ariaLabelFn?: AriaLabelFn; - averageCenter?: boolean; - batchSize?: number; - batchSizeIE?: number; - calculator?: Calculator; - clusterClass?: string; - enableRetinaIcons?: boolean; - gridSize?: number; - ignoreHidden?: boolean; - imageExtension?: string; - imagePath?: string; - imageSizes?: number[]; - maxZoom?: number; - minimumClusterSize?: number; - styles?: ClusterIconStyle[]; - title?: string; - zIndex?: number; - zoomOnClick?: boolean; - options?: MarkerClustererOptions; - + renderer: Renderer; + algorithm: Algorithm; state = 'state1'; - - onClusteringBegin() {} - onClusterClick() {} + onClusteringBegin = jasmine.createSpy('onclusteringbegin spy'); + onClusterClick = jasmine.createSpy('clusterClick spy'); } diff --git a/src/google-maps/map-marker-clusterer/map-marker-clusterer.ts b/src/google-maps/map-marker-clusterer/map-marker-clusterer.ts index 538e5ed45551..dda1acc0ce31 100644 --- a/src/google-maps/map-marker-clusterer/map-marker-clusterer.ts +++ b/src/google-maps/map-marker-clusterer/map-marker-clusterer.ts @@ -15,6 +15,7 @@ import { ChangeDetectionStrategy, Component, ContentChildren, + EventEmitter, Input, NgZone, OnChanges, @@ -25,15 +26,16 @@ import { SimpleChanges, ViewEncapsulation, } from '@angular/core'; -import {Observable, Subject} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {Observable, Subscription} from 'rxjs'; import {GoogleMap} from '../google-map/google-map'; import {MapEventManager} from '../map-event-manager'; import {MapMarker} from '../map-marker/map-marker'; -/** Default options for a clusterer. */ -const DEFAULT_CLUSTERER_OPTIONS: MarkerClustererOptions = {}; +declare const markerClusterer: { + MarkerClusterer: typeof MarkerClusterer; + defaultOnClusterClickHandler: onClusterClickHandler; +}; /** * Angular component for implementing a Google Maps Marker Clusterer. @@ -47,149 +49,43 @@ const DEFAULT_CLUSTERER_OPTIONS: MarkerClustererOptions = {}; template: '', encapsulation: ViewEncapsulation.None, }) -export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, OnDestroy { +export class MapMarkerClusterer implements OnInit, OnChanges, AfterContentInit, OnDestroy { private readonly _currentMarkers = new Set(); - private readonly _eventManager = new MapEventManager(this._ngZone); - private readonly _destroy = new Subject(); + private readonly _closestMapEventManager = new MapEventManager(this._ngZone); + private _markersSubscription = Subscription.EMPTY; /** Whether the clusterer is allowed to be initialized. */ private readonly _canInitialize: boolean; + /** + * Used to customize how the marker cluster is rendered. + * See https://googlemaps.github.io/js-markerclusterer/interfaces/Renderer.html. + */ @Input() - ariaLabelFn: AriaLabelFn = () => ''; - - @Input() - set averageCenter(averageCenter: boolean) { - this._averageCenter = averageCenter; - } - private _averageCenter: boolean; - - @Input() batchSize?: number; - - @Input() - set batchSizeIE(batchSizeIE: number) { - this._batchSizeIE = batchSizeIE; - } - private _batchSizeIE: number; - - @Input() - set calculator(calculator: Calculator) { - this._calculator = calculator; - } - private _calculator: Calculator; - - @Input() - set clusterClass(clusterClass: string) { - this._clusterClass = clusterClass; - } - private _clusterClass: string; - - @Input() - set enableRetinaIcons(enableRetinaIcons: boolean) { - this._enableRetinaIcons = enableRetinaIcons; - } - private _enableRetinaIcons: boolean; - - @Input() - set gridSize(gridSize: number) { - this._gridSize = gridSize; - } - private _gridSize: number; - - @Input() - set ignoreHidden(ignoreHidden: boolean) { - this._ignoreHidden = ignoreHidden; - } - private _ignoreHidden: boolean; - - @Input() - set imageExtension(imageExtension: string) { - this._imageExtension = imageExtension; - } - private _imageExtension: string; - - @Input() - set imagePath(imagePath: string) { - this._imagePath = imagePath; - } - private _imagePath: string; - - @Input() - set imageSizes(imageSizes: number[]) { - this._imageSizes = imageSizes; - } - private _imageSizes: number[]; - - @Input() - set maxZoom(maxZoom: number) { - this._maxZoom = maxZoom; - } - private _maxZoom: number; - - @Input() - set minimumClusterSize(minimumClusterSize: number) { - this._minimumClusterSize = minimumClusterSize; - } - private _minimumClusterSize: number; - - @Input() - set styles(styles: ClusterIconStyle[]) { - this._styles = styles; - } - private _styles: ClusterIconStyle[]; - - @Input() - set title(title: string) { - this._title = title; - } - private _title: string; - - @Input() - set zIndex(zIndex: number) { - this._zIndex = zIndex; - } - private _zIndex: number; - - @Input() - set zoomOnClick(zoomOnClick: boolean) { - this._zoomOnClick = zoomOnClick; - } - private _zoomOnClick: boolean; - - @Input() - set options(options: MarkerClustererOptions) { - this._options = options; - } - private _options: MarkerClustererOptions; + renderer: Renderer; /** - * See - * googlemaps.github.io/v3-utility-library/modules/ - * _google_markerclustererplus.html#clusteringbegin + * Algorithm used to cluster the markers. + * See https://googlemaps.github.io/js-markerclusterer/interfaces/Algorithm.html. */ + @Input() + algorithm: Algorithm; + + /** Emits when clustering has started. */ @Output() readonly clusteringbegin: Observable = - this._eventManager.getLazyEmitter('clusteringbegin'); + this._closestMapEventManager.getLazyEmitter('clusteringbegin'); - /** - * See - * googlemaps.github.io/v3-utility-library/modules/_google_markerclustererplus.html#clusteringend - */ + /** Emits when clustering is done. */ @Output() readonly clusteringend: Observable = - this._eventManager.getLazyEmitter('clusteringend'); + this._closestMapEventManager.getLazyEmitter('clusteringend'); /** Emits when a cluster has been clicked. */ @Output() - readonly clusterClick: Observable = this._eventManager.getLazyEmitter('click'); + readonly clusterClick: EventEmitter = new EventEmitter(); @ContentChildren(MapMarker, {descendants: true}) _markers: QueryList; - /** - * The underlying MarkerClusterer object. - * - * See - * googlemaps.github.io/v3-utility-library/classes/ - * _google_markerclustererplus.markerclusterer.html - */ + /** Underlying MarkerClusterer object used to interact with Google Maps. */ markerClusterer?: MarkerClusterer; constructor(private readonly _googleMap: GoogleMap, private readonly _ngZone: NgZone) { @@ -198,31 +94,21 @@ export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, ngOnInit() { if (this._canInitialize) { - const clustererWindow = window as unknown as typeof globalThis & { - MarkerClusterer?: MarkerClusterer; - }; + this._createCluster(); - if (!clustererWindow.MarkerClusterer && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throw Error( - 'MarkerClusterer class not found, cannot construct a marker cluster. ' + - 'Please install the MarkerClustererPlus library: ' + - 'https://github.com/googlemaps/js-markerclustererplus', - ); - } + // The `clusteringbegin` and `clusteringend` events are + // emitted on the map so that's why set it as the target. + this._closestMapEventManager.setTarget(this._googleMap.googleMap!); + } + } - // Create the object outside the zone so its events don't trigger change detection. - // We'll bring it back in inside the `MapEventManager` only for the events that the - // user has subscribed to. - this._ngZone.runOutsideAngular(() => { - this.markerClusterer = new MarkerClusterer( - this._googleMap.googleMap!, - [], - this._combineOptions(), - ); - }); + ngOnChanges(changes: SimpleChanges) { + const change = changes['renderer'] || changes['algorithm']; - this._assertInitialized(); - this._eventManager.setTarget(this.markerClusterer); + // Since the options are set in the constructor, we have to recreate the cluster if they change. + if (this.markerClusterer && change && !change.isFirstChange()) { + this._createCluster(); + this._watchForMarkerChanges(); } } @@ -232,218 +118,10 @@ export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, } } - ngOnChanges(changes: SimpleChanges) { - const { - markerClusterer: clusterer, - ariaLabelFn, - _averageCenter, - _batchSizeIE, - _calculator, - _styles, - _clusterClass, - _enableRetinaIcons, - _gridSize, - _ignoreHidden, - _imageExtension, - _imagePath, - _imageSizes, - _maxZoom, - _minimumClusterSize, - _title, - _zIndex, - _zoomOnClick, - } = this; - - if (clusterer) { - if (changes['options']) { - clusterer.setOptions(this._combineOptions()); - } - if (changes['ariaLabelFn']) { - clusterer.ariaLabelFn = ariaLabelFn; - } - if (changes['averageCenter'] && _averageCenter !== undefined) { - clusterer.setAverageCenter(_averageCenter); - } - if (changes['batchSizeIE'] && _batchSizeIE !== undefined) { - clusterer.setBatchSizeIE(_batchSizeIE); - } - if (changes['calculator'] && !!_calculator) { - clusterer.setCalculator(_calculator); - } - if (changes['clusterClass'] && _clusterClass !== undefined) { - clusterer.setClusterClass(_clusterClass); - } - if (changes['enableRetinaIcons'] && _enableRetinaIcons !== undefined) { - clusterer.setEnableRetinaIcons(_enableRetinaIcons); - } - if (changes['gridSize'] && _gridSize !== undefined) { - clusterer.setGridSize(_gridSize); - } - if (changes['ignoreHidden'] && _ignoreHidden !== undefined) { - clusterer.setIgnoreHidden(_ignoreHidden); - } - if (changes['imageExtension'] && _imageExtension !== undefined) { - clusterer.setImageExtension(_imageExtension); - } - if (changes['imagePath'] && _imagePath !== undefined) { - clusterer.setImagePath(_imagePath); - } - if (changes['imageSizes'] && _imageSizes) { - clusterer.setImageSizes(_imageSizes); - } - if (changes['maxZoom'] && _maxZoom !== undefined) { - clusterer.setMaxZoom(_maxZoom); - } - if (changes['minimumClusterSize'] && _minimumClusterSize !== undefined) { - clusterer.setMinimumClusterSize(_minimumClusterSize); - } - if (changes['styles'] && _styles) { - clusterer.setStyles(_styles); - } - if (changes['title'] && _title !== undefined) { - clusterer.setTitle(_title); - } - if (changes['zIndex'] && _zIndex !== undefined) { - clusterer.setZIndex(_zIndex); - } - if (changes['zoomOnClick'] && _zoomOnClick !== undefined) { - clusterer.setZoomOnClick(_zoomOnClick); - } - } - } - ngOnDestroy() { - this._destroy.next(); - this._destroy.complete(); - this._eventManager.destroy(); - if (this.markerClusterer) { - this.markerClusterer.setMap(null); - } - } - - fitMapToMarkers(padding: number | google.maps.Padding) { - this._assertInitialized(); - this.markerClusterer.fitMapToMarkers(padding); - } - - getAverageCenter(): boolean { - this._assertInitialized(); - return this.markerClusterer.getAverageCenter(); - } - - getBatchSizeIE(): number { - this._assertInitialized(); - return this.markerClusterer.getBatchSizeIE(); - } - - getCalculator(): Calculator { - this._assertInitialized(); - return this.markerClusterer.getCalculator(); - } - - getClusterClass(): string { - this._assertInitialized(); - return this.markerClusterer.getClusterClass(); - } - - getClusters(): Cluster[] { - this._assertInitialized(); - return this.markerClusterer.getClusters(); - } - - getEnableRetinaIcons(): boolean { - this._assertInitialized(); - return this.markerClusterer.getEnableRetinaIcons(); - } - - getGridSize(): number { - this._assertInitialized(); - return this.markerClusterer.getGridSize(); - } - - getIgnoreHidden(): boolean { - this._assertInitialized(); - return this.markerClusterer.getIgnoreHidden(); - } - - getImageExtension(): string { - this._assertInitialized(); - return this.markerClusterer.getImageExtension(); - } - - getImagePath(): string { - this._assertInitialized(); - return this.markerClusterer.getImagePath(); - } - - getImageSizes(): number[] { - this._assertInitialized(); - return this.markerClusterer.getImageSizes(); - } - - getMaxZoom(): number { - this._assertInitialized(); - return this.markerClusterer.getMaxZoom(); - } - - getMinimumClusterSize(): number { - this._assertInitialized(); - return this.markerClusterer.getMinimumClusterSize(); - } - - getStyles(): ClusterIconStyle[] { - this._assertInitialized(); - return this.markerClusterer.getStyles(); - } - - getTitle(): string { - this._assertInitialized(); - return this.markerClusterer.getTitle(); - } - - getTotalClusters(): number { - this._assertInitialized(); - return this.markerClusterer.getTotalClusters(); - } - - getTotalMarkers(): number { - this._assertInitialized(); - return this.markerClusterer.getTotalMarkers(); - } - - getZIndex(): number { - this._assertInitialized(); - return this.markerClusterer.getZIndex(); - } - - getZoomOnClick(): boolean { - this._assertInitialized(); - return this.markerClusterer.getZoomOnClick(); - } - - private _combineOptions(): MarkerClustererOptions { - const options = this._options || DEFAULT_CLUSTERER_OPTIONS; - return { - ...options, - ariaLabelFn: this.ariaLabelFn ?? options.ariaLabelFn, - averageCenter: this._averageCenter ?? options.averageCenter, - batchSize: this.batchSize ?? options.batchSize, - batchSizeIE: this._batchSizeIE ?? options.batchSizeIE, - calculator: this._calculator ?? options.calculator, - clusterClass: this._clusterClass ?? options.clusterClass, - enableRetinaIcons: this._enableRetinaIcons ?? options.enableRetinaIcons, - gridSize: this._gridSize ?? options.gridSize, - ignoreHidden: this._ignoreHidden ?? options.ignoreHidden, - imageExtension: this._imageExtension ?? options.imageExtension, - imagePath: this._imagePath ?? options.imagePath, - imageSizes: this._imageSizes ?? options.imageSizes, - maxZoom: this._maxZoom ?? options.maxZoom, - minimumClusterSize: this._minimumClusterSize ?? options.minimumClusterSize, - styles: this._styles ?? options.styles, - title: this._title ?? options.title, - zIndex: this._zIndex ?? options.zIndex, - zoomOnClick: this._zoomOnClick ?? options.zoomOnClick, - }; + this._markersSubscription.unsubscribe(); + this._closestMapEventManager.destroy(); + this._destroyCluster(); } private _watchForMarkerChanges() { @@ -455,31 +133,68 @@ export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, } this.markerClusterer.addMarkers(initialMarkers); - this._markers.changes - .pipe(takeUntil(this._destroy)) - .subscribe((markerComponents: MapMarker[]) => { - this._assertInitialized(); - const newMarkers = new Set(this._getInternalMarkers(markerComponents)); - const markersToAdd: google.maps.Marker[] = []; - const markersToRemove: google.maps.Marker[] = []; - for (const marker of Array.from(newMarkers)) { - if (!this._currentMarkers.has(marker)) { - this._currentMarkers.add(marker); - markersToAdd.push(marker); - } - } - for (const marker of Array.from(this._currentMarkers)) { - if (!newMarkers.has(marker)) { - markersToRemove.push(marker); - } + this._markersSubscription.unsubscribe(); + this._markersSubscription = this._markers.changes.subscribe((markerComponents: MapMarker[]) => { + this._assertInitialized(); + const newMarkers = new Set(this._getInternalMarkers(markerComponents)); + const markersToAdd: google.maps.Marker[] = []; + const markersToRemove: google.maps.Marker[] = []; + for (const marker of Array.from(newMarkers)) { + if (!this._currentMarkers.has(marker)) { + this._currentMarkers.add(marker); + markersToAdd.push(marker); } - this.markerClusterer.addMarkers(markersToAdd, true); - this.markerClusterer.removeMarkers(markersToRemove, true); - this.markerClusterer.repaint(); - for (const marker of markersToRemove) { - this._currentMarkers.delete(marker); + } + for (const marker of Array.from(this._currentMarkers)) { + if (!newMarkers.has(marker)) { + markersToRemove.push(marker); } + } + this.markerClusterer.addMarkers(markersToAdd, true); + this.markerClusterer.removeMarkers(markersToRemove, true); + this.markerClusterer.render(); + for (const marker of markersToRemove) { + this._currentMarkers.delete(marker); + } + }); + } + + private _createCluster() { + if (!markerClusterer?.MarkerClusterer && (typeof ngDevMode === 'undefined' || ngDevMode)) { + throw Error( + 'MarkerClusterer class not found, cannot construct a marker cluster. ' + + 'Please install the MarkerClusterer library: ' + + 'https://github.com/googlemaps/js-markerclusterer', + ); + } + + this._destroyCluster(); + + // Create the object outside the zone so its events don't trigger change detection. + // We'll bring it back in inside the `MapEventManager` only for the events that the + // user has subscribed to. + this._ngZone.runOutsideAngular(() => { + this.markerClusterer = new markerClusterer.MarkerClusterer({ + map: this._googleMap.googleMap!, + renderer: this.renderer, + algorithm: this.algorithm, + onClusterClick: (event, cluster, map) => { + if (this.clusterClick.observers.length) { + this._ngZone.run(() => this.clusterClick.emit(cluster)); + } else { + markerClusterer.defaultOnClusterClickHandler(event, cluster, map); + } + }, }); + }); + } + + private _destroyCluster() { + // TODO(crisbeto): the naming here seems odd, but the `MarkerCluster` method isn't + // exposed. All this method seems to do at the time of writing is to call into `reset`. + // See: https://github.com/googlemaps/js-markerclusterer/blob/main/src/markerclusterer.ts#L205 + this.markerClusterer?.onRemove(); + this.markerClusterer = undefined; } private _getInternalMarkers(markers: MapMarker[]): google.maps.Marker[] { diff --git a/src/google-maps/map-marker-clusterer/marker-clusterer-types.ts b/src/google-maps/map-marker-clusterer/marker-clusterer-types.ts index fd4b43b28a1d..e5186aea67bd 100644 --- a/src/google-maps/map-marker-clusterer/marker-clusterer-types.ts +++ b/src/google-maps/map-marker-clusterer/marker-clusterer-types.ts @@ -8,177 +8,153 @@ /// +// tslint:disable + +declare type onClusterClickHandler = ( + event: google.maps.MapMouseEvent, + cluster: Cluster, + map: google.maps.Map, +) => void; + /** - * Class for clustering markers on a Google Map. - * - * See - * googlemaps.github.io/v3-utility-library/classes/_google_markerclustererplus.markerclusterer.html + * MarkerClusterer creates and manages per-zoom-level clusters for large amounts + * of markers. See {@link MarkerClustererOptions} for more details. */ declare class MarkerClusterer { - constructor( - map: google.maps.Map, - markers?: google.maps.Marker[], - options?: MarkerClustererOptions, - ); - ariaLabelFn: AriaLabelFn; - static BATCH_SIZE: number; - static BATCH_SIZE_IE: number; - static IMAGE_EXTENSION: string; - static IMAGE_PATH: string; - static IMAGE_SIZES: number[]; - addListener(eventName: string, handler: Function): google.maps.MapsEventListener; - addMarker(marker: MarkerClusterer, nodraw: boolean): void; - addMarkers(markers: google.maps.Marker[], nodraw?: boolean): void; - bindTo(key: string, target: google.maps.MVCObject, targetKey: string, noNotify: boolean): void; - changed(key: string): void; - clearMarkers(): void; - fitMapToMarkers(padding: number | google.maps.Padding): void; - get(key: string): any; - getAverageCenter(): boolean; - getBatchSizeIE(): number; - getCalculator(): Calculator; - getClusterClass(): string; - getClusters(): Cluster[]; - getEnableRetinaIcons(): boolean; - getGridSize(): number; - getIgnoreHidden(): boolean; - getImageExtension(): string; - getImagePath(): string; - getImageSizes(): number[]; - getMap(): google.maps.Map | google.maps.StreetViewPanorama; - getMarkers(): google.maps.Marker[]; - getMaxZoom(): number; - getMinimumClusterSize(): number; - getPanes(): google.maps.MapPanes; - getProjection(): google.maps.MapCanvasProjection; - getStyles(): ClusterIconStyle[]; - getTitle(): string; - getTotalClusters(): number; - getTotalMarkers(): number; - getZIndex(): number; - getZoomOnClick(): boolean; - notify(key: string): void; - removeMarker(marker: google.maps.Marker, nodraw: boolean): boolean; - removeMarkers(markers: google.maps.Marker[], nodraw?: boolean): boolean; - repaint(): void; - set(key: string, value: any): void; - setAverageCenter(averageCenter: boolean): void; - setBatchSizeIE(batchSizeIE: number): void; - setCalculator(calculator: Calculator): void; - setClusterClass(clusterClass: string): void; - setEnableRetinaIcons(enableRetinaIcons: boolean): void; - setGridSize(gridSize: number): void; - setIgnoreHidden(ignoreHidden: boolean): void; - setImageExtension(imageExtension: string): void; - setImagePath(imagePath: string): void; - setImageSizes(imageSizes: number[]): void; - setMap(map: google.maps.Map | null): void; - setMaxZoom(maxZoom: number): void; - setMinimumClusterSize(minimumClusterSize: number): void; - setStyles(styles: ClusterIconStyle[]): void; - setTitle(title: string): void; - setValues(values: any): void; - setZIndex(zIndex: number): void; - setZoomOnClick(zoomOnClick: boolean): void; - // Note: This one doesn't appear in the docs page, but it exists at runtime. - setOptions(options: MarkerClustererOptions): void; - unbind(key: string): void; - unbindAll(): void; - static CALCULATOR(markers: google.maps.Marker[], numStyles: number): ClusterIconInfo; - static withDefaultStyle(overrides: ClusterIconStyle): ClusterIconStyle; + /** @see {@link MarkerClustererOptions.onClusterClick} */ + onClusterClick: onClusterClickHandler; + constructor({map, markers, algorithm, renderer, onClusterClick}: MarkerClustererOptions); + addMarker(marker: google.maps.Marker, noDraw?: boolean): void; + addMarkers(markers: google.maps.Marker[], noDraw?: boolean): void; + removeMarker(marker: google.maps.Marker, noDraw?: boolean): boolean; + removeMarkers(markers: google.maps.Marker[], noDraw?: boolean): boolean; + clearMarkers(noDraw?: boolean): void; + /** + * Recalculates and draws all the marker clusters. + */ + render(): void; + onAdd(): void; + onRemove(): void; } -/** - * Cluster class from the @google/markerclustererplus library. - * - * See googlemaps.github.io/v3-utility-library/classes/_google_markerclustererplus.cluster.html - */ -declare class Cluster { - constructor(markerClusterer: MarkerClusterer); - getCenter(): google.maps.LatLng; - getMarkers(): google.maps.Marker[]; - getSize(): number; - updateIcon(): void; +interface MarkerClustererOptions { + markers?: google.maps.Marker[]; + /** + * An algorithm to cluster markers. Default is {@link SuperClusterAlgorithm}. Must + * provide a `calculate` method accepting {@link AlgorithmInput} and returning + * an array of {@link Cluster}. + */ + algorithm?: Algorithm; + map?: google.maps.Map | null; + /** + * An object that converts a {@link Cluster} into a `google.maps.Marker`. + * Default is {@link DefaultRenderer}. + */ + renderer?: Renderer; + onClusterClick?: onClusterClickHandler; } -/** - * Options for constructing a MarkerClusterer from the @google/markerclustererplus library. - * - * See - * googlemaps.github.io/v3-utility-library/classes/ - * _google_markerclustererplus.markerclustereroptions.html - */ -declare interface MarkerClustererOptions { - ariaLabelFn?: AriaLabelFn; - averageCenter?: boolean; - batchSize?: number; - batchSizeIE?: number; - calculator?: Calculator; - clusterClass?: string; - enableRetinaIcons?: boolean; - gridSize?: number; - ignoreHidden?: boolean; - imageExtension?: string; - imagePath?: string; - imageSizes?: number[]; - maxZoom?: number; - minimumClusterSize?: number; - styles?: ClusterIconStyle[]; - title?: string; - zIndex?: number; - zoomOnClick?: boolean; +declare enum MarkerClustererEvents { + CLUSTERING_BEGIN = 'clusteringbegin', + CLUSTERING_END = 'clusteringend', + CLUSTER_CLICK = 'click', } -/** - * Style interface for a marker cluster icon. - * - * See - * googlemaps.github.io/v3-utility-library/interfaces/ - * _google_markerclustererplus.clustericonstyle.html - */ -declare interface ClusterIconStyle { - anchorIcon?: [number, number]; - anchorText?: [number, number]; - backgroundPosition?: string; - className?: string; - fontFamily?: string; - fontStyle?: string; - fontWeight?: string; - height: number; - textColor?: string; - textDecoration?: string; - textLineHeight?: number; - textSize?: number; - url?: string; - width: number; +interface ClusterOptions { + position?: google.maps.LatLng | google.maps.LatLngLiteral; + markers?: google.maps.Marker[]; } -/** - * Info interface for a marker cluster icon. - * - * See - * googlemaps.github.io/v3-utility-library/interfaces/ - * _google_markerclustererplus.clustericoninfo.html - */ -declare interface ClusterIconInfo { - index: number; - text: string; - title: string; +declare class Cluster { + marker: google.maps.Marker; + readonly markers?: google.maps.Marker[]; + constructor({markers, position}: ClusterOptions); + get bounds(): google.maps.LatLngBounds | undefined; + get position(): google.maps.LatLng; + /** + * Get the count of **visible** markers. + */ + get count(): number; + /** + * Add a marker to the cluster. + */ + push(marker: google.maps.Marker): void; + /** + * Cleanup references and remove marker from map. + */ + delete(): void; } -/** - * Function type alias for determining the aria label on a Google Maps marker cluster. - * - * See googlemaps.github.io/v3-utility-library/modules/_google_markerclustererplus.html#arialabelfn - */ -declare type AriaLabelFn = (text: string) => string; +declare class ClusterStats { + readonly markers: { + sum: number; + }; + readonly clusters: { + count: number; + markers: { + mean: number; + sum: number; + min: number; + max: number; + }; + }; + constructor(markers: google.maps.Marker[], clusters: Cluster[]); +} -/** - * Function type alias for calculating how a marker cluster is displayed. - * - * See googlemaps.github.io/v3-utility-library/modules/_google_markerclustererplus.html#calculator - */ -declare type Calculator = ( - markers: google.maps.Marker[], - clusterIconStylesCount: number, -) => ClusterIconInfo; +interface Renderer { + /** + * Turn a {@link Cluster} into a `google.maps.Marker`. + * + * Below is a simple example to create a marker with the number of markers in the cluster as a label. + * + * ```typescript + * return new google.maps.Marker({ + * position, + * label: String(markers.length), + * }); + * ``` + */ + render(cluster: Cluster, stats: ClusterStats): google.maps.Marker; +} + +declare class DefaultRenderer implements Renderer { + /** + * The default render function for the library used by {@link MarkerClusterer}. + * + * Currently set to use the following: + * + * ```typescript + * // change color if this cluster has more markers than the mean cluster + * const color = + * count > Math.max(10, stats.clusters.markers.mean) + * ? "#ff0000" + * : "#0000ff"; + * + * // create svg url with fill color + * const svg = window.btoa(` + * + * + * + * + * + * `); + * + * // create marker using svg icon + * return new google.maps.Marker({ + * position, + * icon: { + * url: `data:image/svg+xml;base64,${svg}`, + * scaledSize: new google.maps.Size(45, 45), + * }, + * label: { + * text: String(count), + * color: "rgba(255,255,255,0.9)", + * fontSize: "12px", + * }, + * // adjust zIndex to be above other markers + * zIndex: 1000 + count, + * }); + * ``` + */ + render({count, position}: Cluster, stats: ClusterStats): google.maps.Marker; +} diff --git a/src/google-maps/testing/fake-google-map-utils.ts b/src/google-maps/testing/fake-google-map-utils.ts index a1bf7bc58c45..b4758990b318 100644 --- a/src/google-maps/testing/fake-google-map-utils.ts +++ b/src/google-maps/testing/fake-google-map-utils.ts @@ -37,7 +37,10 @@ export interface TestingWindow extends Window { Geocoder?: jasmine.Spy; }; }; - MarkerClusterer?: jasmine.Spy; + markerClusterer?: { + MarkerClusterer?: jasmine.Spy; + defaultOnClusterClickHandler?: jasmine.Spy; + }; } /** Creates a jasmine.SpyObj for a google.maps.Map. */ @@ -504,52 +507,16 @@ export function createBicyclingLayerConstructorSpy( /** Creates a jasmine.SpyObj for a MarkerClusterer */ export function createMarkerClustererSpy(): jasmine.SpyObj { - const markerClustererSpy = jasmine.createSpyObj('MarkerClusterer', [ - 'addListener', + return jasmine.createSpyObj('MarkerClusterer', [ + 'addMarker', 'addMarkers', - 'fitMapToMarkers', - 'getAverageCenter', - 'getBatchSizeIE', - 'getCalculator', - 'getClusterClass', - 'getClusters', - 'getEnableRetinaIcons', - 'getGridSize', - 'getIgnoreHidden', - 'getImageExtension', - 'getImagePath', - 'getImageSizes', - 'getMaxZoom', - 'getMinimumClusterSize', - 'getStyles', - 'getTitle', - 'getTotalClusters', - 'getTotalMarkers', - 'getZIndex', - 'getZoomOnClick', + 'removeMarker', 'removeMarkers', - 'repaint', - 'setAverageCenter', - 'setBatchSizeIE', - 'setCalculator', - 'setClusterClass', - 'setEnableRetinaIcons', - 'setGridSize', - 'setIgnoreHidden', - 'setImageExtension', - 'setImagePath', - 'setImageSizes', - 'setMap', - 'setMaxZoom', - 'setMinimumClusterSize', - 'setStyles', - 'setTitle', - 'setZIndex', - 'setZoomOnClick', - 'setOptions', + 'clearMarkers', + 'render', + 'onAdd', + 'onRemove', ]); - markerClustererSpy.addListener.and.returnValue({remove: () => {}}); - return markerClustererSpy; } /** Creates a jasmine.Spy to watch for the constructor of a MarkerClusterer */ @@ -566,7 +533,10 @@ export function createMarkerClustererConstructorSpy( ); if (apiLoaded) { const testingWindow: TestingWindow = window; - testingWindow['MarkerClusterer'] = markerClustererConstructorSpy; + testingWindow.markerClusterer = { + MarkerClusterer: markerClustererConstructorSpy, + defaultOnClusterClickHandler: jasmine.createSpy('defaultOnClusterClickHandler'), + }; } return markerClustererConstructorSpy; } diff --git a/tools/public_api_guard/google-maps/google-maps.md b/tools/public_api_guard/google-maps/google-maps.md index 84ffb299a924..9856063c5e9c 100644 --- a/tools/public_api_guard/google-maps/google-maps.md +++ b/tools/public_api_guard/google-maps/google-maps.md @@ -426,83 +426,16 @@ export class MapMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint { } // @public -export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, OnDestroy { +export class MapMarkerClusterer implements OnInit, OnChanges, AfterContentInit, OnDestroy { constructor(_googleMap: GoogleMap, _ngZone: NgZone); - // (undocumented) - ariaLabelFn: AriaLabelFn; - // (undocumented) - set averageCenter(averageCenter: boolean); - // (undocumented) - batchSize?: number; - // (undocumented) - set batchSizeIE(batchSizeIE: number); - // (undocumented) - set calculator(calculator: Calculator); - // (undocumented) - set clusterClass(clusterClass: string); - readonly clusterClick: Observable; + algorithm: Algorithm; + readonly clusterClick: EventEmitter; readonly clusteringbegin: Observable; readonly clusteringend: Observable; - // (undocumented) - set enableRetinaIcons(enableRetinaIcons: boolean); - // (undocumented) - fitMapToMarkers(padding: number | google.maps.Padding): void; - // (undocumented) - getAverageCenter(): boolean; - // (undocumented) - getBatchSizeIE(): number; - // (undocumented) - getCalculator(): Calculator; - // (undocumented) - getClusterClass(): string; - // (undocumented) - getClusters(): Cluster[]; - // (undocumented) - getEnableRetinaIcons(): boolean; - // (undocumented) - getGridSize(): number; - // (undocumented) - getIgnoreHidden(): boolean; - // (undocumented) - getImageExtension(): string; - // (undocumented) - getImagePath(): string; - // (undocumented) - getImageSizes(): number[]; - // (undocumented) - getMaxZoom(): number; - // (undocumented) - getMinimumClusterSize(): number; - // (undocumented) - getStyles(): ClusterIconStyle[]; - // (undocumented) - getTitle(): string; - // (undocumented) - getTotalClusters(): number; - // (undocumented) - getTotalMarkers(): number; - // (undocumented) - getZIndex(): number; - // (undocumented) - getZoomOnClick(): boolean; - // (undocumented) - set gridSize(gridSize: number); - // (undocumented) - set ignoreHidden(ignoreHidden: boolean); - // (undocumented) - set imageExtension(imageExtension: string); - // (undocumented) - set imagePath(imagePath: string); - // (undocumented) - set imageSizes(imageSizes: number[]); markerClusterer?: MarkerClusterer; // (undocumented) _markers: QueryList; // (undocumented) - set maxZoom(maxZoom: number); - // (undocumented) - set minimumClusterSize(minimumClusterSize: number); - // (undocumented) ngAfterContentInit(): void; // (undocumented) ngOnChanges(changes: SimpleChanges): void; @@ -510,18 +443,9 @@ export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, ngOnDestroy(): void; // (undocumented) ngOnInit(): void; + renderer: Renderer; // (undocumented) - set options(options: MarkerClustererOptions); - // (undocumented) - set styles(styles: ClusterIconStyle[]); - // (undocumented) - set title(title: string); - // (undocumented) - set zIndex(zIndex: number); - // (undocumented) - set zoomOnClick(zoomOnClick: boolean); - // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; }