diff --git a/src/dev-app/google-map/google-map-demo.html b/src/dev-app/google-map/google-map-demo.html index f157e6fa6146..45a2d0787c7c 100644 --- a/src/dev-app/google-map/google-map-demo.html +++ b/src/dev-app/google-map/google-map-demo.html @@ -10,26 +10,25 @@ (mapRightclick)="handleRightclick()" [mapTypeId]="mapTypeId" [mapId]="mapId"> - - + + @for (markerPosition of markerPositions; track markerPosition) { - + (mapClick)="infoWindow.open(marker)"> } - @if (hasAdvancedMarker) { + @if (hasCustomContentMarker) { - @@ -216,18 +215,8 @@
-
- -
-
diff --git a/src/dev-app/google-map/google-map-demo.ts b/src/dev-app/google-map/google-map-demo.ts index dcc1d10c61b4..031cd9a859c5 100644 --- a/src/dev-app/google-map/google-map-demo.ts +++ b/src/dev-app/google-map/google-map-demo.ts @@ -25,13 +25,12 @@ import { MapHeatmapLayer, MapInfoWindow, MapKmlLayer, - MapMarker, - MapMarkerClusterer, MapPolygon, MapPolyline, MapRectangle, MapTrafficLayer, MapTransitLayer, + MapMarkerClusterer, } from '@angular/google-maps'; const POLYLINE_PATH: google.maps.LatLngLiteral[] = [ @@ -75,7 +74,6 @@ let apiLoadingPromise: Promise | null = null; MapHeatmapLayer, MapInfoWindow, MapKmlLayer, - MapMarker, MapMarkerClusterer, MapAdvancedMarker, MapPolygon, @@ -98,7 +96,6 @@ export class GoogleMapDemo { center = {lat: 24, lng: 12}; mapAdvancedMarkerPosition = {lat: 22, lng: 21}; - markerOptions = {draggable: false}; markerPositions: google.maps.LatLngLiteral[] = []; zoom = 4; display?: google.maps.LatLngLiteral; @@ -153,17 +150,13 @@ export class GoogleMapDemo { isTrafficLayerDisplayed = false; isTransitLayerDisplayed = false; isBicyclingLayerDisplayed = false; - hasAdvancedMarker = false; - hasAdvancedMarkerCustomContent = true; + hasCustomContentMarker = false; // This is necessary for testing advanced markers. It seems like any value works locally. mapId = '123'; mapTypeId: google.maps.MapTypeId; mapTypeIds = ['hybrid', 'roadmap', 'satellite', 'terrain'] as google.maps.MapTypeId[]; - markerClustererImagePath = - 'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m'; - directionsResult?: google.maps.DirectionsResult; constructor() { @@ -262,7 +255,7 @@ export class GoogleMapDemo { if (!apiLoadingPromise) { apiLoadingPromise = this._loadScript( - 'https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js', + 'https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js', ); } diff --git a/src/google-maps/deprecated-map-marker-clusterer/README.md b/src/google-maps/deprecated-map-marker-clusterer/README.md new file mode 100644 index 000000000000..1179e5e3864e --- /dev/null +++ b/src/google-maps/deprecated-map-marker-clusterer/README.md @@ -0,0 +1,57 @@ +# Deprecation warning ⚠️ +This component is based on the deprecated `@googlemaps/markerclustererplus` library. Use the `map-marker-clusterer` component instead. + +## DeprecatedMapMarkerClusterer + +The `DeprecatedMapMarkerClusterer` component wraps the [`MarkerClusterer` class](https://googlemaps.github.io/js-markerclustererplus/classes/markerclusterer.html) from the [Google Maps JavaScript MarkerClustererPlus Library](https://github.com/googlemaps/js-markerclustererplus). The `DeprecatedMapMarkerClusterer` component displays a cluster of markers that are children of the `` tag. Unlike the other Google Maps components, MapMarkerClusterer does not have an `options` input, so any input (listed in the [documentation](https://googlemaps.github.io/js-markerclustererplus/index.html) for the `MarkerClusterer` class) should be set directly. + +## Loading the Library + +Like the Google Maps JavaScript API, the MarkerClustererPlus library needs to be loaded separately. This can be accomplished by using this script tag: + +```html + +``` + +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. + +## Example + +```typescript +// google-map-demo.component.ts +import {Component} from '@angular/core'; +import {GoogleMap, MapMarker, DeprecatedMapMarkerClusterer} from '@angular/google-maps'; + +@Component({ + selector: 'google-map-demo', + templateUrl: 'google-map-demo.html', + imports: [GoogleMap, MapMarker, DeprecatedMapMarkerClusterer], +}) +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()); + } +} +``` + +```html + + + + @for (position of markerPositions; track position) { + + } + + +``` diff --git a/src/google-maps/deprecated-map-marker-clusterer/deprecated-map-marker-clusterer.spec.ts b/src/google-maps/deprecated-map-marker-clusterer/deprecated-map-marker-clusterer.spec.ts new file mode 100644 index 000000000000..9b1d297b9536 --- /dev/null +++ b/src/google-maps/deprecated-map-marker-clusterer/deprecated-map-marker-clusterer.spec.ts @@ -0,0 +1,375 @@ +import {Component, ViewChild} from '@angular/core'; +import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing'; + +import {DEFAULT_OPTIONS, GoogleMap} from '../google-map/google-map'; +import {MapMarker} from '../map-marker/map-marker'; +import { + createMapConstructorSpy, + createMapSpy, + createDeprecatedMarkerClustererConstructorSpy, + createDeprecatedMarkerClustererSpy, + createMarkerConstructorSpy, + createMarkerSpy, +} from '../testing/fake-google-map-utils'; +import {DeprecatedMapMarkerClusterer} from './deprecated-map-marker-clusterer'; +import { + AriaLabelFn, + Calculator, + ClusterIconStyle, + MarkerClusterer, + MarkerClustererOptions, +} from './deprecated-marker-clusterer-types'; + +describe('DeprecatedMapMarkerClusterer', () => { + let mapSpy: jasmine.SpyObj; + let markerClustererSpy: jasmine.SpyObj; + let markerClustererConstructorSpy: jasmine.Spy; + let fixture: ComponentFixture; + + const anyMarkerMatcher = jasmine.any(Object) as unknown as google.maps.Marker; + + beforeEach(() => { + mapSpy = createMapSpy(DEFAULT_OPTIONS); + createMapConstructorSpy(mapSpy); + + const markerSpy = createMarkerSpy({}); + // The spy target function cannot be an arrow-function as this breaks when created + // through `new`. + createMarkerConstructorSpy(markerSpy).and.callFake(function () { + return createMarkerSpy({}); + }); + + markerClustererSpy = createDeprecatedMarkerClustererSpy(); + markerClustererConstructorSpy = + createDeprecatedMarkerClustererConstructorSpy(markerClustererSpy); + + fixture = TestBed.createComponent(TestApp); + }); + + afterEach(() => { + (window.google as any) = undefined; + (window as any).MarkerClusterer = undefined; + }); + + it('throws an error if the clustering library has not been loaded', fakeAsync(() => { + (window as any).MarkerClusterer = undefined; + markerClustererConstructorSpy = createDeprecatedMarkerClustererConstructorSpy( + markerClustererSpy, + false, + ); + + expect(() => { + fixture.detectChanges(); + flush(); + }).toThrowError(/MarkerClusterer class not found, cannot construct a marker cluster/); + })); + + it('initializes a Google Map Marker Clusterer', fakeAsync(() => { + fixture.detectChanges(); + flush(); + + 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, + }); + })); + + it('sets marker clusterer inputs', fakeAsync(() => { + 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.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + flush(); + + 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, + }); + })); + + it('sets marker clusterer options', fakeAsync(() => { + fixture.detectChanges(); + flush(); + const options: MarkerClustererOptions = { + enableRetinaIcons: true, + gridSize: 1337, + ignoreHidden: true, + imageExtension: 'png', + }; + fixture.componentInstance.options = options; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(markerClustererSpy.setOptions).toHaveBeenCalledWith(jasmine.objectContaining(options)); + })); + + it('gives precedence to specific inputs over options', fakeAsync(() => { + fixture.detectChanges(); + flush(); + 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; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(markerClustererSpy.setOptions).toHaveBeenCalledWith( + jasmine.objectContaining(expectedOptions), + ); + })); + + it('sets Google Maps Markers in the MarkerClusterer', fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(markerClustererSpy.addMarkers).toHaveBeenCalledWith([ + anyMarkerMatcher, + anyMarkerMatcher, + ]); + })); + + it('updates Google Maps Markers in the Marker Clusterer', fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(markerClustererSpy.addMarkers).toHaveBeenCalledWith([ + anyMarkerMatcher, + anyMarkerMatcher, + ]); + + fixture.componentInstance.state = 'state2'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + flush(); + + expect(markerClustererSpy.addMarkers).toHaveBeenCalledWith([anyMarkerMatcher], true); + expect(markerClustererSpy.removeMarkers).toHaveBeenCalledWith([anyMarkerMatcher], true); + expect(markerClustererSpy.repaint).toHaveBeenCalledTimes(1); + + fixture.componentInstance.state = 'state0'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + flush(); + + expect(markerClustererSpy.addMarkers).toHaveBeenCalledWith([], true); + expect(markerClustererSpy.removeMarkers).toHaveBeenCalledWith( + [anyMarkerMatcher, anyMarkerMatcher], + true, + ); + expect(markerClustererSpy.repaint).toHaveBeenCalledTimes(2); + })); + + it('exposes marker clusterer methods', fakeAsync(() => { + fixture.detectChanges(); + flush(); + 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); + + markerClustererSpy.getZIndex.and.returnValue(12); + expect(markerClustererComponent.getZIndex()).toBe(12); + + markerClustererSpy.getZoomOnClick.and.returnValue(true); + expect(markerClustererComponent.getZoomOnClick()).toBe(true); + })); + + it('initializes marker clusterer event handlers', fakeAsync(() => { + fixture.detectChanges(); + flush(); + + 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)); + })); +}); + +@Component({ + selector: 'test-app', + template: ` + + + @if (state === 'state1') { + + } + @if (state === 'state1' || state === 'state2') { + + } + @if (state === 'state2') { + + } + + + `, + standalone: true, + imports: [GoogleMap, MapMarker, DeprecatedMapMarkerClusterer], +}) +class TestApp { + @ViewChild(DeprecatedMapMarkerClusterer) markerClusterer: DeprecatedMapMarkerClusterer; + + 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; + + state = 'state1'; + + onClusteringBegin() {} + onClusterClick() {} +} diff --git a/src/google-maps/deprecated-map-marker-clusterer/deprecated-map-marker-clusterer.ts b/src/google-maps/deprecated-map-marker-clusterer/deprecated-map-marker-clusterer.ts new file mode 100644 index 000000000000..3c84386e5750 --- /dev/null +++ b/src/google-maps/deprecated-map-marker-clusterer/deprecated-map-marker-clusterer.ts @@ -0,0 +1,540 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// Workaround for: https://github.com/bazelbuild/rules_nodejs/issues/1265 +/// + +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + ContentChildren, + EventEmitter, + Input, + NgZone, + OnChanges, + OnDestroy, + OnInit, + Output, + QueryList, + SimpleChanges, + ViewEncapsulation, + inject, +} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; +import {take, takeUntil} from 'rxjs/operators'; + +import {GoogleMap} from '../google-map/google-map'; +import {MapEventManager} from '../map-event-manager'; +import {MapMarker} from '../map-marker/map-marker'; +import { + AriaLabelFn, + Calculator, + Cluster, + ClusterIconStyle, + MarkerClusterer as MarkerClustererInstance, + MarkerClustererOptions, +} from './deprecated-marker-clusterer-types'; + +/** Default options for a clusterer. */ +const DEFAULT_CLUSTERER_OPTIONS: MarkerClustererOptions = {}; + +/** + * The clusterer has to be defined and referred to as a global variable, + * otherwise it'll cause issues when minified through Closure. + */ +declare const MarkerClusterer: typeof MarkerClustererInstance; + +/** + * Angular component for implementing a Google Maps Marker Clusterer. + * See https://developers.google.com/maps/documentation/javascript/marker-clustering + * + * @deprecated This component is using a deprecated clustering implementation. Use the + * `map-marker-clusterer` component instead. + * @breaking-change 21.0.0 + * + */ +@Component({ + selector: 'deprecated-map-marker-clusterer', + exportAs: 'mapMarkerClusterer', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + encapsulation: ViewEncapsulation.None, +}) +export class DeprecatedMapMarkerClusterer + implements OnInit, AfterContentInit, OnChanges, OnDestroy +{ + private readonly _googleMap = inject(GoogleMap); + private readonly _ngZone = inject(NgZone); + private readonly _currentMarkers = new Set(); + private readonly _eventManager = new MapEventManager(inject(NgZone)); + private readonly _destroy = new Subject(); + + /** Whether the clusterer is allowed to be initialized. */ + private readonly _canInitialize = this._googleMap._isBrowser; + + @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; + + /** + * See + * googlemaps.github.io/v3-utility-library/modules/ + * _google_markerclustererplus.html#clusteringbegin + */ + @Output() readonly clusteringbegin: Observable = + this._eventManager.getLazyEmitter('clusteringbegin'); + + /** + * See + * googlemaps.github.io/v3-utility-library/modules/_google_markerclustererplus.html#clusteringend + */ + @Output() readonly clusteringend: Observable = + this._eventManager.getLazyEmitter('clusteringend'); + + /** Emits when a cluster has been clicked. */ + @Output() + readonly clusterClick: Observable = this._eventManager.getLazyEmitter('click'); + + @ContentChildren(MapMarker, {descendants: true}) _markers: QueryList; + + /** + * The underlying MarkerClusterer object. + * + * See + * googlemaps.github.io/v3-utility-library/classes/ + * _google_markerclustererplus.markerclusterer.html + */ + markerClusterer?: MarkerClustererInstance; + + /** Event emitted when the clusterer is initialized. */ + @Output() readonly markerClustererInitialized: EventEmitter = + new EventEmitter(); + + constructor(...args: unknown[]); + constructor() {} + + ngOnInit() { + if (this._canInitialize) { + this._ngZone.runOutsideAngular(() => { + this._googleMap._resolveMap().then(map => { + if ( + typeof MarkerClusterer !== 'function' && + (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', + ); + } + + // 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.markerClusterer = this._ngZone.runOutsideAngular(() => { + return new MarkerClusterer(map, [], this._combineOptions()); + }); + + this._assertInitialized(); + this._eventManager.setTarget(this.markerClusterer); + this.markerClustererInitialized.emit(this.markerClusterer); + }); + }); + } + } + + ngAfterContentInit() { + if (this._canInitialize) { + if (this.markerClusterer) { + this._watchForMarkerChanges(); + } else { + this.markerClustererInitialized + .pipe(take(1), takeUntil(this._destroy)) + .subscribe(() => this._watchForMarkerChanges()); + } + } + } + + 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(); + 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, + }; + } + + private _watchForMarkerChanges() { + this._assertInitialized(); + + this._ngZone.runOutsideAngular(() => { + this._getInternalMarkers(this._markers).then(markers => { + const initialMarkers: google.maps.Marker[] = []; + for (const marker of markers) { + this._currentMarkers.add(marker); + initialMarkers.push(marker); + } + this.markerClusterer.addMarkers(initialMarkers); + }); + }); + + this._markers.changes + .pipe(takeUntil(this._destroy)) + .subscribe((markerComponents: MapMarker[]) => { + this._assertInitialized(); + this._ngZone.runOutsideAngular(() => { + this._getInternalMarkers(markerComponents).then(markers => { + const newMarkers = new Set(markers); + 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.markerClusterer.addMarkers(markersToAdd, true); + this.markerClusterer.removeMarkers(markersToRemove, true); + this.markerClusterer.repaint(); + for (const marker of markersToRemove) { + this._currentMarkers.delete(marker); + } + }); + }); + }); + } + + private _getInternalMarkers( + markers: MapMarker[] | QueryList, + ): Promise { + return Promise.all(markers.map(markerComponent => markerComponent._resolveMarker())); + } + + private _assertInitialized(): asserts this is {markerClusterer: MarkerClustererInstance} { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (!this.markerClusterer) { + throw Error( + 'Cannot interact with a MarkerClusterer before it has been initialized. ' + + 'Please wait for the MarkerClusterer to load before trying to interact with it.', + ); + } + } + } +} diff --git a/src/google-maps/map-marker-clusterer/marker-clusterer-types.ts b/src/google-maps/deprecated-map-marker-clusterer/deprecated-marker-clusterer-types.ts similarity index 100% rename from src/google-maps/map-marker-clusterer/marker-clusterer-types.ts rename to src/google-maps/deprecated-map-marker-clusterer/deprecated-marker-clusterer-types.ts diff --git a/src/google-maps/google-maps-module.ts b/src/google-maps/google-maps-module.ts index bd82f7eecc45..d8fad333f37c 100644 --- a/src/google-maps/google-maps-module.ts +++ b/src/google-maps/google-maps-module.ts @@ -17,7 +17,7 @@ import {MapGroundOverlay} from './map-ground-overlay/map-ground-overlay'; import {MapInfoWindow} from './map-info-window/map-info-window'; import {MapKmlLayer} from './map-kml-layer/map-kml-layer'; import {MapMarker} from './map-marker/map-marker'; -import {MapMarkerClusterer} from './map-marker-clusterer/map-marker-clusterer'; +import {DeprecatedMapMarkerClusterer} from './deprecated-map-marker-clusterer/deprecated-map-marker-clusterer'; import {MapPolygon} from './map-polygon/map-polygon'; import {MapPolyline} from './map-polyline/map-polyline'; import {MapRectangle} from './map-rectangle/map-rectangle'; @@ -25,6 +25,7 @@ import {MapTrafficLayer} from './map-traffic-layer/map-traffic-layer'; import {MapTransitLayer} from './map-transit-layer/map-transit-layer'; import {MapHeatmapLayer} from './map-heatmap-layer/map-heatmap-layer'; import {MapAdvancedMarker} from './map-advanced-marker/map-advanced-marker'; +import {MapMarkerClusterer} from './map-marker-clusterer/map-marker-clusterer'; const COMPONENTS = [ GoogleMap, @@ -38,12 +39,13 @@ const COMPONENTS = [ MapKmlLayer, MapMarker, MapAdvancedMarker, - MapMarkerClusterer, + DeprecatedMapMarkerClusterer, MapPolygon, MapPolyline, MapRectangle, MapTrafficLayer, MapTransitLayer, + MapMarkerClusterer, ]; @NgModule({ diff --git a/src/google-maps/map-advanced-marker/map-advanced-marker.ts b/src/google-maps/map-advanced-marker/map-advanced-marker.ts index 812807d7479d..246b955b0815 100644 --- a/src/google-maps/map-advanced-marker/map-advanced-marker.ts +++ b/src/google-maps/map-advanced-marker/map-advanced-marker.ts @@ -24,8 +24,10 @@ import { import {GoogleMap} from '../google-map/google-map'; import {MapEventManager} from '../map-event-manager'; -import {Observable} from 'rxjs'; import {MapAnchorPoint} from '../map-anchor-point'; +import {MAP_MARKER, MarkerDirective} from '../marker-utilities'; +import {Observable} from 'rxjs'; +import {take} from 'rxjs/operators'; /** * Default options for the Google Maps marker component. Displays a marker @@ -43,8 +45,16 @@ export const DEFAULT_MARKER_OPTIONS = { @Directive({ selector: 'map-advanced-marker', exportAs: 'mapAdvancedMarker', + providers: [ + { + provide: MAP_MARKER, + useExisting: MapAdvancedMarker, + }, + ], }) -export class MapAdvancedMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint { +export class MapAdvancedMarker + implements OnInit, OnChanges, OnDestroy, MapAnchorPoint, MarkerDirective +{ private readonly _googleMap = inject(GoogleMap); private _ngZone = inject(NgZone); private _eventManager = new MapEventManager(inject(NgZone)); @@ -262,6 +272,13 @@ export class MapAdvancedMarker implements OnInit, OnChanges, OnDestroy, MapAncho return this.advancedMarker; } + /** Returns a promise that resolves when the marker has been initialized. */ + _resolveMarker(): Promise { + return this.advancedMarker + ? Promise.resolve(this.advancedMarker) + : this.markerInitialized.pipe(take(1)).toPromise(); + } + /** Creates a combined options object using the passed-in options and the individual inputs. */ private _combineOptions(): google.maps.marker.AdvancedMarkerElementOptions { const options = this._options || DEFAULT_MARKER_OPTIONS; diff --git a/src/google-maps/map-marker-clusterer/README.md b/src/google-maps/map-marker-clusterer/README.md index 717b88a7e7d0..a901018c8bde 100644 --- a/src/google-maps/map-marker-clusterer/README.md +++ b/src/google-maps/map-marker-clusterer/README.md @@ -1,13 +1,13 @@ -#MapMarkerClusterer +# MapMarkerClusterer -The `MapMarkerClusterer` component wraps the [`MarkerClusterer` class](https://googlemaps.github.io/js-markerclustererplus/classes/markerclusterer.html) from the [Google Maps JavaScript MarkerClustererPlus Library](https://github.com/googlemaps/js-markerclustererplus). The `MapMarkerClusterer` component displays a cluster of markers that are children of the `` tag. Unlike the other Google Maps components, MapMarkerClusterer does not have an `options` input, so any input (listed in the [documentation](https://googlemaps.github.io/js-markerclustererplus/index.html) for the `MarkerClusterer` class) should be set directly. +The `MapMarkerClusterer` component wraps the [`MarkerClusterer` class](https://googlemaps.github.io/js-markerclusterer/classes/MarkerClusterer.html) from the [Google Maps JavaScript MarkerClusterer Library](https://github.com/googlemaps/js-markerclusterer). The `MapMarkerClusterer` component displays a cluster of markers that are children of the `` tag. Unlike the other Google Maps components, MapMarkerClusterer does not have an `options` input, so any input (listed in the [documentation](https://googlemaps.github.io/js-markerclusterer/) for the `MarkerClusterer` class) should be set directly. ## Loading the Library -Like the Google Maps JavaScript API, the MarkerClustererPlus library needs to be loaded separately. This can be accomplished by using this script tag: +Like the Google Maps JavaScript API, the MarkerClusterer library needs to be loaded separately. This can be accomplished by using this script tag: ```html - + ``` 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. @@ -17,19 +17,17 @@ Additional information can be found by looking at [Marker Clustering](https://de ```typescript // google-map-demo.component.ts import {Component} from '@angular/core'; -import {GoogleMap, MapMarker, MapMarkerClusterer} from '@angular/google-maps'; +import {GoogleMap, MapMarkerClusterer, MapAdvancedMarker} from '@angular/google-maps'; @Component({ selector: 'google-map-demo', templateUrl: 'google-map-demo.html', - imports: [GoogleMap, MapMarker, MapMarkerClusterer], + imports: [GoogleMap, MapMarkerClusterer, MapAdvancedMarker], }) 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()); @@ -38,17 +36,16 @@ export class GoogleMapDemo { ``` ```html - - - @for (position of markerPositions; track position) { - - } - + + @for (markerPosition of markerPositions; track $index) { + + } + ``` diff --git a/src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts b/src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts new file mode 100644 index 000000000000..3983f0b80210 --- /dev/null +++ b/src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/// + +import {Marker} from '../marker-utilities'; + +// This file duplicates the necessary types from the `@googlemaps/markerclusterer` +// package which isn't available for use internally. + +// tslint:disable + +export interface ClusterOptions { + position?: google.maps.LatLng | google.maps.LatLngLiteral; + markers?: Marker[]; +} + +export declare class Cluster { + marker?: Marker; + readonly markers?: Marker[]; + protected _position: google.maps.LatLng; + 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: Marker): void; + /** + * Cleanup references and remove marker from map. + */ + delete(): void; +} + +export declare class MarkerClusterer extends google.maps.OverlayView { + onClusterClick: onClusterClickHandler; + protected algorithm: Algorithm; + protected clusters: Cluster[]; + protected markers: Marker[]; + protected renderer: Renderer; + protected map: google.maps.Map | null; + protected idleListener: google.maps.MapsEventListener; + constructor({ + map, + markers, + algorithmOptions, + algorithm, + renderer, + onClusterClick, + }: MarkerClustererOptions); + addMarker(marker: Marker, noDraw?: boolean): void; + addMarkers(markers: Marker[], noDraw?: boolean): void; + removeMarker(marker: Marker, noDraw?: boolean): boolean; + removeMarkers(markers: Marker[], noDraw?: boolean): boolean; + clearMarkers(noDraw?: boolean): void; + render(): void; + onAdd(): void; + onRemove(): void; + protected reset(): void; + protected renderClusters(): void; +} + +export type onClusterClickHandler = ( + event: google.maps.MapMouseEvent, + cluster: Cluster, + map: google.maps.Map, +) => void; + +export interface MarkerClustererOptions { + markers?: 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; + algorithmOptions?: AlgorithmOptions; + 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; +} + +export declare enum MarkerClustererEvents { + CLUSTERING_BEGIN = 'clusteringbegin', + CLUSTERING_END = 'clusteringend', + CLUSTER_CLICK = 'click', +} + +export declare const defaultOnClusterClickHandler: onClusterClickHandler; + +export interface Renderer { + /** + * Turn a {@link Cluster} into a `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, map: google.maps.Map): Marker; +} + +export declare class ClusterStats { + readonly markers: { + sum: number; + }; + readonly clusters: { + count: number; + markers: { + mean: number; + sum: number; + min: number; + max: number; + }; + }; + constructor(markers: Marker[], clusters: Cluster[]); +} + +export interface Algorithm { + /** + * Calculates an array of {@link Cluster}. + */ + calculate: ({markers, map}: AlgorithmInput) => AlgorithmOutput; +} + +export interface AlgorithmOptions { + maxZoom?: number; +} + +export interface AlgorithmInput { + /** + * The map containing the markers and clusters. + */ + map: google.maps.Map; + /** + * An array of markers to be clustered. + * + * There are some specific edge cases to be aware of including the following: + * * Markers that are not visible. + */ + markers: Marker[]; + /** + * The `mapCanvasProjection` enables easy conversion from lat/lng to pixel. + * + * @see [MapCanvasProjection](https://developers.google.com/maps/documentation/javascript/reference/overlay-view#MapCanvasProjection) + */ + mapCanvasProjection: google.maps.MapCanvasProjection; +} + +export interface AlgorithmOutput { + /** + * The clusters returned based upon the {@link AlgorithmInput}. + */ + clusters: Cluster[]; + /** + * A boolean flag indicating that the clusters have not changed. + */ + changed?: boolean; +} 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 132927ba0f06..01629f6ed952 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 @@ -1,58 +1,57 @@ import {Component, ViewChild} from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing'; +import type {MarkerClusterer, Renderer, Algorithm} from './map-marker-clusterer-types'; -import {DEFAULT_OPTIONS, GoogleMap} from '../google-map/google-map'; -import {MapMarker} from '../map-marker/map-marker'; +import {DEFAULT_OPTIONS} from '../google-map/google-map'; +import {GoogleMapsModule} from '../google-maps-module'; import { createMapConstructorSpy, createMapSpy, createMarkerClustererConstructorSpy, createMarkerClustererSpy, - createMarkerConstructorSpy, - createMarkerSpy, + createAdvancedMarkerSpy, + createAdvancedMarkerConstructorSpy, } from '../testing/fake-google-map-utils'; import {MapMarkerClusterer} from './map-marker-clusterer'; -import { - AriaLabelFn, - Calculator, - ClusterIconStyle, - MarkerClusterer, - MarkerClustererOptions, -} from './marker-clusterer-types'; describe('MapMarkerClusterer', () => { let mapSpy: jasmine.SpyObj; let markerClustererSpy: jasmine.SpyObj; let markerClustererConstructorSpy: jasmine.Spy; let fixture: ComponentFixture; - - const anyMarkerMatcher = jasmine.any(Object) as unknown as google.maps.Marker; + const anyMarkerMatcher = jasmine.any( + Object, + ) as unknown as google.maps.marker.AdvancedMarkerElement; beforeEach(() => { mapSpy = createMapSpy(DEFAULT_OPTIONS); - createMapConstructorSpy(mapSpy); + createMapConstructorSpy(mapSpy).and.callThrough(); - const markerSpy = createMarkerSpy({}); + const markerSpy = createAdvancedMarkerSpy({}); // The spy target function cannot be an arrow-function as this breaks when created // through `new`. - createMarkerConstructorSpy(markerSpy).and.callFake(function () { - return createMarkerSpy({}); + createAdvancedMarkerConstructorSpy(markerSpy).and.callFake(function () { + return createAdvancedMarkerSpy({}); }); markerClustererSpy = createMarkerClustererSpy(); - markerClustererConstructorSpy = createMarkerClustererConstructorSpy(markerClustererSpy); + markerClustererConstructorSpy = + createMarkerClustererConstructorSpy(markerClustererSpy).and.callThrough(); fixture = TestBed.createComponent(TestApp); }); 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', fakeAsync(() => { - (window as any).MarkerClusterer = undefined; - markerClustererConstructorSpy = createMarkerClustererConstructorSpy(markerClustererSpy, false); + (window as any).markerClusterer = undefined; + markerClustererConstructorSpy = createMarkerClustererConstructorSpy( + markerClustererSpy, + false, + ).and.callThrough(); expect(() => { fixture.detectChanges(); @@ -64,112 +63,52 @@ describe('MapMarkerClusterer', () => { fixture.detectChanges(); flush(); - 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', fakeAsync(() => { - 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.changeDetectorRef.markForCheck(); + fixture.componentInstance.algorithm = {name: 'custom'} as any; + fixture.componentInstance.renderer = {render: () => null!}; fixture.detectChanges(); flush(); - 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', fakeAsync(() => { - fixture.detectChanges(); - flush(); - const options: MarkerClustererOptions = { - enableRetinaIcons: true, - gridSize: 1337, - ignoreHidden: true, - imageExtension: 'png', - }; - fixture.componentInstance.options = options; + it('recreates the clusterer if the options change', fakeAsync(() => { + fixture.componentInstance.algorithm = {name: 'custom1'} as any; fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - expect(markerClustererSpy.setOptions).toHaveBeenCalledWith(jasmine.objectContaining(options)); - })); - - it('gives precedence to specific inputs over options', fakeAsync(() => { fixture.detectChanges(); flush(); - 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'} as any; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); + flush(); - 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', fakeAsync(() => { @@ -198,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.changeDetectorRef.markForCheck(); @@ -210,162 +149,61 @@ describe('MapMarkerClusterer', () => { [anyMarkerMatcher, anyMarkerMatcher], true, ); - expect(markerClustererSpy.repaint).toHaveBeenCalledTimes(2); + expect(markerClustererSpy.render).toHaveBeenCalledTimes(2); })); - it('exposes marker clusterer methods', fakeAsync(() => { + it('initializes event handlers on the map related to clustering', fakeAsync(() => { fixture.detectChanges(); flush(); - 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', fakeAsync(() => { + fixture.detectChanges(); + flush(); - markerClustererSpy.getZoomOnClick.and.returnValue(true); - expect(markerClustererComponent.getZoomOnClick()).toBe(true); - })); + expect(fixture.componentInstance.onClusterClick).not.toHaveBeenCalled(); - it('initializes marker clusterer event handlers', fakeAsync(() => { + const callback = markerClustererConstructorSpy.calls.mostRecent().args[0].onClusterClick; + callback({}, {}, {}); fixture.detectChanges(); flush(); - 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', + standalone: true, + imports: [GoogleMapsModule], template: ` - @if (state === 'state1') { - - } - @if (state === 'state1' || state === 'state2') { - - } - @if (state === 'state2') { - - } + (clusterClick)="onClusterClick()" + [renderer]="renderer" + [algorithm]="algorithm"> + @if (state === 'state1') { + + } + @if (state === 'state1' || state === 'state2') { + + } + @if (state === 'state2') { + + } `, - standalone: true, - imports: [GoogleMap, MapMarker, MapMarkerClusterer], }) 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 78d2130527d4..8113ab961e9c 100644 --- a/src/google-maps/map-marker-clusterer/map-marker-clusterer.ts +++ b/src/google-maps/map-marker-clusterer/map-marker-clusterer.ts @@ -10,11 +10,11 @@ /// import { - AfterContentInit, ChangeDetectionStrategy, Component, ContentChildren, EventEmitter, + inject, Input, NgZone, OnChanges, @@ -24,31 +24,24 @@ import { QueryList, SimpleChanges, ViewEncapsulation, - inject, } from '@angular/core'; -import {Observable, Subject} from 'rxjs'; -import {take, takeUntil} from 'rxjs/operators'; +import {Observable, Subscription} from 'rxjs'; +import type { + Cluster, + MarkerClusterer, + onClusterClickHandler, + Renderer, + Algorithm, +} from './map-marker-clusterer-types'; import {GoogleMap} from '../google-map/google-map'; import {MapEventManager} from '../map-event-manager'; -import {MapMarker} from '../map-marker/map-marker'; -import { - AriaLabelFn, - Calculator, - Cluster, - ClusterIconStyle, - MarkerClusterer as MarkerClustererInstance, - MarkerClustererOptions, -} from './marker-clusterer-types'; +import {MAP_MARKER, Marker, MarkerDirective} from '../marker-utilities'; -/** Default options for a clusterer. */ -const DEFAULT_CLUSTERER_OPTIONS: MarkerClustererOptions = {}; - -/** - * The clusterer has to be defined and referred to as a global variable, - * otherwise it'll cause issues when minified through Closure. - */ -declare const MarkerClusterer: typeof MarkerClustererInstance; +declare const markerClusterer: { + MarkerClusterer: typeof MarkerClusterer; + defaultOnClusterClickHandler: onClusterClickHandler; +}; /** * Angular component for implementing a Google Maps Marker Clusterer. @@ -62,467 +55,169 @@ declare const MarkerClusterer: typeof MarkerClustererInstance; template: '', encapsulation: ViewEncapsulation.None, }) -export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, OnDestroy { +export class MapMarkerClusterer implements OnInit, OnChanges, OnDestroy { private readonly _googleMap = inject(GoogleMap); private readonly _ngZone = inject(NgZone); - private readonly _currentMarkers = new Set(); - private readonly _eventManager = new MapEventManager(inject(NgZone)); - private readonly _destroy = new Subject(); + private readonly _currentMarkers = new Set(); + private readonly _closestMapEventManager = new MapEventManager(this._ngZone); + private _markersSubscription = Subscription.EMPTY; /** Whether the clusterer is allowed to be initialized. */ private readonly _canInitialize = this._googleMap._isBrowser; + /** + * 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; + /** Event emitted when the marker clusterer is initialized. */ + @Output() readonly markerClustererInitialized: EventEmitter = + new EventEmitter(); - /** - * The underlying MarkerClusterer object. - * - * See - * googlemaps.github.io/v3-utility-library/classes/ - * _google_markerclustererplus.markerclusterer.html - */ - markerClusterer?: MarkerClustererInstance; - - /** Event emitted when the clusterer is initialized. */ - @Output() readonly markerClustererInitialized: EventEmitter = - new EventEmitter(); + @ContentChildren(MAP_MARKER, {descendants: true}) _markers: QueryList; - constructor(...args: unknown[]); - constructor() {} + /** Underlying MarkerClusterer object used to interact with Google Maps. */ + markerClusterer?: MarkerClusterer; - ngOnInit() { + async ngOnInit() { if (this._canInitialize) { - this._ngZone.runOutsideAngular(() => { - this._googleMap._resolveMap().then(map => { - if ( - typeof MarkerClusterer !== 'function' && - (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', - ); - } - - // 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.markerClusterer = this._ngZone.runOutsideAngular(() => { - return new MarkerClusterer(map, [], this._combineOptions()); - }); + await this._createCluster(); - this._assertInitialized(); - this._eventManager.setTarget(this.markerClusterer); - this.markerClustererInitialized.emit(this.markerClusterer); - }); - }); - } - } - - ngAfterContentInit() { - if (this._canInitialize) { - if (this.markerClusterer) { - this._watchForMarkerChanges(); - } else { - this.markerClustererInitialized - .pipe(take(1), takeUntil(this._destroy)) - .subscribe(() => this._watchForMarkerChanges()); - } + // 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!); } } - ngOnChanges(changes: SimpleChanges) { - const { - markerClusterer: clusterer, - ariaLabelFn, - _averageCenter, - _batchSizeIE, - _calculator, - _styles, - _clusterClass, - _enableRetinaIcons, - _gridSize, - _ignoreHidden, - _imageExtension, - _imagePath, - _imageSizes, - _maxZoom, - _minimumClusterSize, - _title, - _zIndex, - _zoomOnClick, - } = this; + async ngOnChanges(changes: SimpleChanges) { + const change = changes['renderer'] || changes['algorithm']; - 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); - } + // Since the options are set in the constructor, we have to recreate the cluster if they change. + if (this.markerClusterer && change && !change.isFirstChange()) { + await this._createCluster(); } } ngOnDestroy() { - this._destroy.next(); - this._destroy.complete(); - this._eventManager.destroy(); - 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(); - } + this._markersSubscription.unsubscribe(); + this._closestMapEventManager.destroy(); + this._destroyCluster(); + } + + private async _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', + ); + } - getTotalClusters(): number { - this._assertInitialized(); - return this.markerClusterer.getTotalClusters(); - } + const map = await this._googleMap._resolveMap(); + this._destroyCluster(); - getTotalMarkers(): number { - this._assertInitialized(); - return this.markerClusterer.getTotalMarkers(); - } + // 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, + 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); + } + }, + }); + this.markerClustererInitialized.emit(this.markerClusterer); + }); - getZIndex(): number { - this._assertInitialized(); - return this.markerClusterer.getZIndex(); + await this._watchForMarkerChanges(); } - getZoomOnClick(): boolean { + private async _watchForMarkerChanges() { this._assertInitialized(); - return this.markerClusterer.getZoomOnClick(); - } + const initialMarkers: Marker[] = []; + const markers = await this._getInternalMarkers(this._markers.toArray()); - 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, - }; - } - - private _watchForMarkerChanges() { - this._assertInitialized(); + for (const marker of markers) { + this._currentMarkers.add(marker); + initialMarkers.push(marker); + } + this.markerClusterer.addMarkers(initialMarkers); - this._ngZone.runOutsideAngular(() => { - this._getInternalMarkers(this._markers).then(markers => { - const initialMarkers: google.maps.Marker[] = []; - for (const marker of markers) { - this._currentMarkers.add(marker); - initialMarkers.push(marker); + this._markersSubscription.unsubscribe(); + this._markersSubscription = this._markers.changes.subscribe( + async (markerComponents: MarkerDirective[]) => { + this._assertInitialized(); + const newMarkers = new Set(await this._getInternalMarkers(markerComponents)); + const markersToAdd: Marker[] = []; + const markersToRemove: Marker[] = []; + for (const marker of Array.from(newMarkers)) { + if (!this._currentMarkers.has(marker)) { + this._currentMarkers.add(marker); + markersToAdd.push(marker); + } } - this.markerClusterer.addMarkers(initialMarkers); - }); - }); + 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); + } + }, + ); + } - this._markers.changes - .pipe(takeUntil(this._destroy)) - .subscribe((markerComponents: MapMarker[]) => { - this._assertInitialized(); - this._ngZone.runOutsideAngular(() => { - this._getInternalMarkers(markerComponents).then(markers => { - const newMarkers = new Set(markers); - 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.markerClusterer.addMarkers(markersToAdd, true); - this.markerClusterer.removeMarkers(markersToRemove, true); - this.markerClusterer.repaint(); - for (const marker of markersToRemove) { - this._currentMarkers.delete(marker); - } - }); - }); - }); + 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[] | QueryList, - ): Promise { - return Promise.all(markers.map(markerComponent => markerComponent._resolveMarker())); + private _getInternalMarkers(markers: MarkerDirective[]): Promise { + return Promise.all(markers.map(marker => marker._resolveMarker())); } - private _assertInitialized(): asserts this is {markerClusterer: MarkerClustererInstance} { + private _assertInitialized(): asserts this is {markerClusterer: MarkerClusterer} { if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (!this._googleMap.googleMap) { + throw Error( + 'Cannot access Google Map information before the API has been initialized. ' + + 'Please wait for the API to load before trying to interact with it.', + ); + } if (!this.markerClusterer) { throw Error( 'Cannot interact with a MarkerClusterer before it has been initialized. ' + diff --git a/src/google-maps/map-marker/map-marker.ts b/src/google-maps/map-marker/map-marker.ts index 66f07f9d227c..b1d1c22fc164 100644 --- a/src/google-maps/map-marker/map-marker.ts +++ b/src/google-maps/map-marker/map-marker.ts @@ -27,6 +27,7 @@ import {take} from 'rxjs/operators'; import {GoogleMap} from '../google-map/google-map'; import {MapEventManager} from '../map-event-manager'; import {MapAnchorPoint} from '../map-anchor-point'; +import {MAP_MARKER, MarkerDirective} from '../marker-utilities'; /** * Default options for the Google Maps marker component. Displays a marker @@ -44,8 +45,14 @@ export const DEFAULT_MARKER_OPTIONS = { @Directive({ selector: 'map-marker', exportAs: 'mapMarker', + providers: [ + { + provide: MAP_MARKER, + useExisting: MapMarker, + }, + ], }) -export class MapMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint { +export class MapMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint, MarkerDirective { private readonly _googleMap = inject(GoogleMap); private _ngZone = inject(NgZone); private _eventManager = new MapEventManager(inject(NgZone)); diff --git a/src/google-maps/marker-utilities.ts b/src/google-maps/marker-utilities.ts new file mode 100644 index 000000000000..5a37cc4f42c8 --- /dev/null +++ b/src/google-maps/marker-utilities.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {InjectionToken} from '@angular/core'; + +/** Marker types from the Google Maps API. */ +export type Marker = google.maps.Marker | google.maps.marker.AdvancedMarkerElement; + +/** Interface that should be implemented by directives that wrap marker APIs. */ +export interface MarkerDirective { + _resolveMarker(): Promise; +} + +/** Token that marker directives can use to expose themselves to the clusterer. */ +export const MAP_MARKER = new InjectionToken('MAP_MARKER'); diff --git a/src/google-maps/package.json b/src/google-maps/package.json index a03e1f78dbbe..5f38bff98174 100644 --- a/src/google-maps/package.json +++ b/src/google-maps/package.json @@ -27,5 +27,10 @@ }, "sideEffects": false, "schematics": "./schematics/collection.json", - "ng-update": {} + "ng-update": { + "migrations": "./schematics/migration.json", + "packageGroup": [ + "@angular/google-maps" + ] + } } diff --git a/src/google-maps/public-api.ts b/src/google-maps/public-api.ts index ef7f2e3ff36c..98cc770f2a1a 100644 --- a/src/google-maps/public-api.ts +++ b/src/google-maps/public-api.ts @@ -22,6 +22,7 @@ export {MapInfoWindow} from './map-info-window/map-info-window'; export {MapKmlLayer} from './map-kml-layer/map-kml-layer'; export {MapMarker} from './map-marker/map-marker'; export {MapAdvancedMarker} from './map-advanced-marker/map-advanced-marker'; +export {DeprecatedMapMarkerClusterer} from './deprecated-map-marker-clusterer/deprecated-map-marker-clusterer'; export {MapMarkerClusterer} from './map-marker-clusterer/map-marker-clusterer'; export {MapPolygon} from './map-polygon/map-polygon'; export {MapPolyline} from './map-polyline/map-polyline'; @@ -35,5 +36,5 @@ export { ClusterIconStyle, AriaLabelFn, Calculator, -} from './map-marker-clusterer/marker-clusterer-types'; +} from './deprecated-map-marker-clusterer/deprecated-marker-clusterer-types'; export {MapEventManager} from './map-event-manager'; diff --git a/src/google-maps/schematics/BUILD.bazel b/src/google-maps/schematics/BUILD.bazel index 84fafe225944..72c95a675a7d 100644 --- a/src/google-maps/schematics/BUILD.bazel +++ b/src/google-maps/schematics/BUILD.bazel @@ -30,5 +30,6 @@ pkg_npm( deps = [ ":schematics", ":schematics_assets", + "//src/google-maps/schematics/ng-update:ng_update_index", ], ) diff --git a/src/google-maps/schematics/migration.json b/src/google-maps/schematics/migration.json new file mode 100644 index 000000000000..742d51a5bcb5 --- /dev/null +++ b/src/google-maps/schematics/migration.json @@ -0,0 +1,9 @@ +{ + "schematics": { + "migration-v19": { + "version": "19.0.0-0", + "description": "Updates the Angular Google Maps package to v19", + "factory": "./ng-update/index_bundled#updateToV19" + } + } +} diff --git a/src/google-maps/schematics/ng-update/BUILD.bazel b/src/google-maps/schematics/ng-update/BUILD.bazel new file mode 100644 index 000000000000..3228023d033b --- /dev/null +++ b/src/google-maps/schematics/ng-update/BUILD.bazel @@ -0,0 +1,79 @@ +load("//tools:defaults.bzl", "esbuild", "jasmine_node_test", "spec_bundle", "ts_library") + +## THIS ONE IS ESM +# By default everything is ESM +# ESBUild needs ESM for bundling. Cannot reliably use CJS as input. +ts_library( + name = "ng_update_lib", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + # Schematics can not yet run in ESM module. For now we continue to use CommonJS. + # TODO(ESM): remove this once the Angular CLI supports ESM schematics. + devmode_module = "commonjs", + deps = [ + "@npm//@angular-devkit/core", + "@npm//@angular-devkit/schematics", + "@npm//@schematics/angular", + "@npm//@types/node", + "@npm//typescript", + ], +) + +esbuild( + name = "ng_update_index", + entry_point = ":index.ts", + external = [ + "@schematics/angular", + "@angular-devkit/schematics", + "@angular-devkit/core", + "typescript", + ], + # TODO: Switch to ESM when Angular CLI supports it. + format = "cjs", + output = "index_bundled.js", + platform = "node", + target = "es2015", + visibility = ["//src/google-maps/schematics:__pkg__"], + deps = [":ng_update_lib"], +) + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":ng_update_lib", + "@npm//@angular-devkit/core", + "@npm//@angular-devkit/schematics", + "@npm//@bazel/runfiles", + "@npm//@types/jasmine", + "@npm//@types/node", + "@npm//@types/shelljs", + ], +) + +spec_bundle( + name = "spec_bundle", + external = [ + "*/paths.js", + "shelljs", + "@angular-devkit/core/node", + ], + platform = "cjs-legacy", + target = "es2020", + deps = [":test_lib"], +) + +jasmine_node_test( + name = "test", + data = [ + ":ng_update_index", + "//src/google-maps/schematics:schematics_assets", + "@npm//shelljs", + ], + deps = [ + ":spec_bundle", + ], +) diff --git a/src/google-maps/schematics/ng-update/index.ts b/src/google-maps/schematics/ng-update/index.ts new file mode 100644 index 000000000000..9fcd69c841c0 --- /dev/null +++ b/src/google-maps/schematics/ng-update/index.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Path} from '@angular-devkit/core'; +import {Rule, Tree} from '@angular-devkit/schematics'; +import ts from 'typescript'; + +/** Tag name of the clusterer component. */ +const TAG_NAME = 'map-marker-clusterer'; + +/** Module from which the clusterer is being imported. */ +const MODULE_NAME = '@angular/google-maps'; + +/** Old name of the clusterer class. */ +const CLASS_NAME = 'MapMarkerClusterer'; + +/** New name of the clusterer class. */ +const DEPRECATED_CLASS_NAME = 'DeprecatedMapMarkerClusterer'; + +/** Entry point for the migration schematics with target of Angular Material v19 */ +export function updateToV19(): Rule { + return tree => { + tree.visit(path => { + if (path.endsWith('.html')) { + const content = tree.readText(path); + + if (content.includes('<' + TAG_NAME)) { + tree.overwrite(path, migrateHtml(content)); + } + } else if (path.endsWith('.ts') && !path.endsWith('.d.ts')) { + migrateTypeScript(path, tree); + } + }); + }; +} + +/** Migrates an HTML template from the old tag name to the new one. */ +function migrateHtml(content: string): string { + return content + .replace(/ { + const symbolName = element.propertyName || element.name; + + if (ts.isIdentifier(symbolName) && symbolName.text === CLASS_NAME) { + results.push(element); + } + }); + } + } else { + node.forEachChild(walk); + } + }); + + // Sort the results in reverse order to make applying the updates easier. + return results.sort((a, b) => b.getStart() - a.getStart()); +} diff --git a/src/google-maps/schematics/ng-update/tsconfig.json b/src/google-maps/schematics/ng-update/tsconfig.json new file mode 100644 index 000000000000..78f1753e68b0 --- /dev/null +++ b/src/google-maps/schematics/ng-update/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "esnext", + "target": "es2015" + } +} diff --git a/src/google-maps/schematics/ng-update/v19-ng-update.spec.ts b/src/google-maps/schematics/ng-update/v19-ng-update.spec.ts new file mode 100644 index 000000000000..57ff61787fe9 --- /dev/null +++ b/src/google-maps/schematics/ng-update/v19-ng-update.spec.ts @@ -0,0 +1,213 @@ +import {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import {runfiles} from '@bazel/runfiles'; +import shx from 'shelljs'; + +describe('v19 migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematic('migration-v19', {}, tree); + } + + function stripWhitespace(value: string) { + return value.replace(/\s/g, ''); + } + + beforeEach(() => { + runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../migration.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile( + '/angular.json', + JSON.stringify({ + version: 1, + projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}, + }), + ); + + tmpDirPath = getSystemPath(host.root); + shx.cd(tmpDirPath); + }); + + it('should migrate the clusterer in HTML files', async () => { + writeFile( + '/my-comp.html', + ` +
+ + + + + +
+ +
+
+ + @for (marker of markers; track $index) { + + } + +
+
+ `, + ); + + await runMigration(); + + const content = tree.readContent('/my-comp.html'); + + expect(stripWhitespace(content)).toBe( + stripWhitespace(` +
+ + + + + +
+ +
+
+ + @for (marker of markers; track $index) { + + } + +
+
+ `), + ); + }); + + it('should migrate the clusterer in a TypeScript file', async () => { + writeFile( + '/my-comp.ts', + ` + import {Component} from '@angular/core'; + import {MapMarkerClusterer, MapMarker} from '@angular/google-maps'; + + @Component({ + template: '', + imports: [MapMarkerClusterer, MapMarker] + }) + export class MyComp {} + `, + ); + + await runMigration(); + + const content = tree.readContent('/my-comp.ts'); + + expect(stripWhitespace(content)).toBe( + stripWhitespace(` + import {Component} from '@angular/core'; + import {DeprecatedMapMarkerClusterer as MapMarkerClusterer, MapMarker} from '@angular/google-maps'; + + @Component({ + template: '', + imports: [MapMarkerClusterer, MapMarker] + }) + export class MyComp {} + `), + ); + }); + + it('should migrate an aliased clusterer in a TypeScript file', async () => { + writeFile( + '/my-comp.ts', + ` + import {Component} from '@angular/core'; + import {MapMarkerClusterer as MyClusterer, MapMarker} from '@angular/google-maps'; + + @Component({ + template: '', + imports: [MyClusterer, MapMarker] + }) + export class MyComp {} + `, + ); + + await runMigration(); + + const content = tree.readContent('/my-comp.ts'); + + expect(stripWhitespace(content)).toBe( + stripWhitespace(` + import {Component} from '@angular/core'; + import {DeprecatedMapMarkerClusterer as MyClusterer, MapMarker} from '@angular/google-maps'; + + @Component({ + template: '', + imports: [MyClusterer, MapMarker] + }) + export class MyComp {} + `), + ); + }); + + it('should migrate a re-exported clusterer', async () => { + writeFile( + '/index.ts', + ` + export {MapMarkerClusterer} from '@angular/google-maps'; + export {MapMarkerClusterer as AliasedMapMarkerClusterer} from '@angular/google-maps'; + `, + ); + + await runMigration(); + + const content = tree.readContent('/index.ts'); + + expect(stripWhitespace(content)).toBe( + stripWhitespace(` + export {DeprecatedMapMarkerClusterer as MapMarkerClusterer} from '@angular/google-maps'; + export {DeprecatedMapMarkerClusterer as AliasedMapMarkerClusterer} from '@angular/google-maps'; + `), + ); + }); + + it('should not migrate an import outside of the Angular module', async () => { + writeFile( + '/my-comp.ts', + ` + import {Component} from '@angular/core'; + import {MapMarkerClusterer} from '@not-angular/google-maps'; + + @Component({ + template: '', + imports: [MapMarkerClusterer] + }) + export class MyComp {} + `, + ); + + await runMigration(); + + const content = tree.readContent('/my-comp.ts'); + + expect(stripWhitespace(content)).toBe( + stripWhitespace(` + import {Component} from '@angular/core'; + import {MapMarkerClusterer} from '@not-angular/google-maps'; + + @Component({ + template: '', + imports: [MapMarkerClusterer] + }) + export class MyComp {} + `), + ); + }); +}); diff --git a/src/google-maps/testing/fake-google-map-utils.ts b/src/google-maps/testing/fake-google-map-utils.ts index 0f7f59fbdf00..89b32072ca6f 100644 --- a/src/google-maps/testing/fake-google-map-utils.ts +++ b/src/google-maps/testing/fake-google-map-utils.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import {MarkerClusterer} from '../map-marker-clusterer/marker-clusterer-types'; +import type {MarkerClusterer} from '../map-marker-clusterer/map-marker-clusterer-types'; +import {MarkerClusterer as DeprecatedMarkerClusterer} from '../deprecated-map-marker-clusterer/deprecated-marker-clusterer-types'; // The global `window` variable is typed as an intersection of `Window` and `globalThis`. // We re-declare `window` here and omit `globalThis` as it is typed with the actual Google @@ -43,6 +44,10 @@ export interface TestingWindow extends Window { }; }; MarkerClusterer?: jasmine.Spy; + markerClusterer?: { + MarkerClusterer?: jasmine.Spy; + defaultOnClusterClickHandler?: jasmine.Spy; + }; } /** Creates a jasmine.SpyObj for a google.maps.Map. */ @@ -575,7 +580,43 @@ export function createBicyclingLayerConstructorSpy( /** Creates a jasmine.SpyObj for a MarkerClusterer */ export function createMarkerClustererSpy(): jasmine.SpyObj { - const markerClustererSpy = jasmine.createSpyObj('MarkerClusterer', [ + return jasmine.createSpyObj('MarkerClusterer', [ + 'addMarker', + 'addMarkers', + 'removeMarker', + 'removeMarkers', + 'clearMarkers', + 'render', + 'onAdd', + 'onRemove', + ]); +} + +/** Creates a jasmine.Spy to watch for the constructor of a MarkerClusterer */ +export function createMarkerClustererConstructorSpy( + markerClustererSpy: jasmine.SpyObj, + apiLoaded = true, +): jasmine.Spy { + // The spy target function cannot be an arrow-function as this breaks when created through `new`. + const markerClustererConstructorSpy = jasmine.createSpy( + 'MarkerClusterer constructor', + function () { + return markerClustererSpy; + }, + ); + if (apiLoaded) { + const testingWindow: TestingWindow = window; + testingWindow.markerClusterer = { + MarkerClusterer: markerClustererConstructorSpy, + defaultOnClusterClickHandler: jasmine.createSpy('defaultOnClusterClickHandler'), + }; + } + return markerClustererConstructorSpy; +} + +/** Creates a jasmine.SpyObj for a MarkerClusterer */ +export function createDeprecatedMarkerClustererSpy(): jasmine.SpyObj { + const deprecatedMarkerClustererSpy = jasmine.createSpyObj('DeprecatedMarkerClusterer', [ 'addListener', 'addMarkers', 'fitMapToMarkers', @@ -619,26 +660,26 @@ export function createMarkerClustererSpy(): jasmine.SpyObj { 'setZoomOnClick', 'setOptions', ]); - markerClustererSpy.addListener.and.returnValue({remove: () => {}}); - return markerClustererSpy; + deprecatedMarkerClustererSpy.addListener.and.returnValue({remove: () => {}}); + return deprecatedMarkerClustererSpy; } /** Creates a jasmine.Spy to watch for the constructor of a MarkerClusterer */ -export function createMarkerClustererConstructorSpy( - markerClustererSpy: jasmine.SpyObj, +export function createDeprecatedMarkerClustererConstructorSpy( + deprecatedMarkerClustererSpy: jasmine.SpyObj, apiLoaded = true, ): jasmine.Spy { // The spy target function cannot be an arrow-function as this breaks when created through `new`. - const markerClustererConstructorSpy = jasmine - .createSpy('MarkerClusterer constructor', function () { - return markerClustererSpy; + const deprecatedMarkerClustererConstructorSpy = jasmine + .createSpy('DeprecatedMarkerClusterer constructor', function () { + return deprecatedMarkerClustererSpy; }) .and.callThrough(); if (apiLoaded) { const testingWindow: TestingWindow = window; - testingWindow['MarkerClusterer'] = markerClustererConstructorSpy; + testingWindow['MarkerClusterer'] = deprecatedMarkerClustererConstructorSpy; } - return markerClustererConstructorSpy; + return deprecatedMarkerClustererConstructorSpy; } /** Creates a jasmine.SpyObj for DirectionsRenderer */ diff --git a/src/tsconfig-legacy.json b/src/tsconfig-legacy.json index 9decccd4c33c..d667d62584e4 100644 --- a/src/tsconfig-legacy.json +++ b/src/tsconfig-legacy.json @@ -18,6 +18,11 @@ "./cdk/schematics/**/*", "./material/schematics/**/*", "./components-examples/**/*", + "./google-maps/schematics/**/*", + "./youtube-player/schematics/**/*", + "./material-moment-adapter/schematics/**/*", + "./material-luxon-adapter/schematics/**/*", + "./material-date-fns-adapter/schematics/**/*", "./material/core/theming/tests/**/*" ], "angularCompilerOptions": { diff --git a/src/universal-app/kitchen-sink/kitchen-sink.html b/src/universal-app/kitchen-sink/kitchen-sink.html index 793698ed6df2..52cfff367412 100644 --- a/src/universal-app/kitchen-sink/kitchen-sink.html +++ b/src/universal-app/kitchen-sink/kitchen-sink.html @@ -641,13 +641,13 @@

Google Map

]" > - - +

Popover edit

diff --git a/src/universal-app/kitchen-sink/kitchen-sink.ts b/src/universal-app/kitchen-sink/kitchen-sink.ts index 20317b2f0b14..f4050a3d4a16 100644 --- a/src/universal-app/kitchen-sink/kitchen-sink.ts +++ b/src/universal-app/kitchen-sink/kitchen-sink.ts @@ -16,7 +16,7 @@ import { MapInfoWindow, MapKmlLayer, MapMarker, - MapMarkerClusterer, + DeprecatedMapMarkerClusterer, MapPolygon, MapPolyline, MapRectangle, @@ -196,7 +196,7 @@ export class TestEntryComponent {} MapKmlLayer, MapMarker, MapAdvancedMarker, - MapMarkerClusterer, + DeprecatedMapMarkerClusterer, MapPolygon, MapPolyline, MapRectangle, diff --git a/tools/public_api_guard/google-maps/google-maps.md b/tools/public_api_guard/google-maps/google-maps.md index a49299bbaa01..28c956a8ed3c 100644 --- a/tools/public_api_guard/google-maps/google-maps.md +++ b/tools/public_api_guard/google-maps/google-maps.md @@ -55,6 +55,108 @@ export interface ClusterIconStyle { width: number; } +// @public @deprecated +export class DeprecatedMapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, OnDestroy { + constructor(...args: unknown[]); + // (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; + 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; + readonly markerClustererInitialized: EventEmitter; + // (undocumented) + _markers: QueryList; + // (undocumented) + set maxZoom(maxZoom: number); + // (undocumented) + set minimumClusterSize(minimumClusterSize: number); + // (undocumented) + ngAfterContentInit(): void; + // (undocumented) + ngOnChanges(changes: SimpleChanges): void; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; + // (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; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export class GoogleMap implements OnChanges, OnInit, OnDestroy { constructor(...args: unknown[]); @@ -127,14 +229,14 @@ export class GoogleMapsModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public export type HeatmapData = google.maps.MVCArray | (google.maps.LatLng | google.maps.visualization.WeightedLocation | google.maps.LatLngLiteral)[]; // @public -export class MapAdvancedMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint { +export class MapAdvancedMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint, MarkerDirective { constructor(...args: unknown[]); advancedMarker: google.maps.marker.AdvancedMarkerElement; set content(content: Node | google.maps.marker.PinElement | null); @@ -159,6 +261,7 @@ export class MapAdvancedMarker implements OnInit, OnChanges, OnDestroy, MapAncho ngOnInit(): void; set options(options: google.maps.marker.AdvancedMarkerElementOptions); set position(position: google.maps.LatLngLiteral | google.maps.LatLng | google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral); + _resolveMarker(): Promise; set title(title: string); set zIndex(zIndex: number); // (undocumented) @@ -440,7 +543,7 @@ export class MapKmlLayer implements OnInit, OnDestroy { } // @public -export class MapMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint { +export class MapMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint, MarkerDirective { constructor(...args: unknown[]); readonly animationChanged: Observable; set clickable(clickable: boolean); @@ -499,103 +602,24 @@ export class MapMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint { } // @public -export class MapMarkerClusterer implements OnInit, AfterContentInit, OnChanges, OnDestroy { - constructor(...args: unknown[]); - // (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; +export class MapMarkerClusterer implements OnInit, OnChanges, OnDestroy { + algorithm: Algorithm_2; + readonly clusterClick: EventEmitter; readonly clusteringbegin: Observable; readonly clusteringend: Observable; + markerClusterer?: MarkerClusterer_2; + readonly markerClustererInitialized: EventEmitter; // (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; - readonly markerClustererInitialized: EventEmitter; - // (undocumented) - _markers: QueryList; - // (undocumented) - set maxZoom(maxZoom: number); - // (undocumented) - set minimumClusterSize(minimumClusterSize: number); + _markers: QueryList; // (undocumented) - ngAfterContentInit(): void; - // (undocumented) - ngOnChanges(changes: SimpleChanges): void; + ngOnChanges(changes: SimpleChanges): Promise; // (undocumented) ngOnDestroy(): void; // (undocumented) - ngOnInit(): void; - // (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); + ngOnInit(): Promise; + renderer: Renderer; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; }