From c70aae15b095f5d7005b491270866f6647732a26 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 16 Oct 2024 14:43:10 +0200 Subject: [PATCH] feat(google-maps): implement new marker clusterer Adds a new `MapMarkerClusterer` component that is based on the most up-to-date clustering library, and supports both regular and advanced markers. Fixes #23695. --- src/dev-app/google-map/google-map-demo.html | 35 +-- src/dev-app/google-map/google-map-demo.ts | 15 +- src/google-maps/google-maps-module.ts | 2 + .../map-advanced-marker.ts | 21 +- .../map-marker-clusterer/README.md | 51 ++++ .../map-marker-clusterer-types.ts | 176 ++++++++++++++ .../map-marker-clusterer.spec.ts | 209 ++++++++++++++++ .../map-marker-clusterer.ts | 229 ++++++++++++++++++ src/google-maps/map-marker/map-marker.ts | 9 +- src/google-maps/marker-utilities.ts | 20 ++ src/google-maps/public-api.ts | 1 + .../testing/fake-google-map-utils.ts | 41 ++++ .../google-maps/google-maps.md | 30 ++- 13 files changed, 799 insertions(+), 40 deletions(-) create mode 100644 src/google-maps/map-marker-clusterer/README.md create mode 100644 src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts create mode 100644 src/google-maps/map-marker-clusterer/map-marker-clusterer.spec.ts create mode 100644 src/google-maps/map-marker-clusterer/map-marker-clusterer.ts create mode 100644 src/google-maps/marker-utilities.ts diff --git a/src/dev-app/google-map/google-map-demo.html b/src/dev-app/google-map/google-map-demo.html index e4961e0d43cc..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 3c17404cd61e..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, - DeprecatedMapMarkerClusterer, MapPolygon, MapPolyline, MapRectangle, MapTrafficLayer, MapTransitLayer, + MapMarkerClusterer, } from '@angular/google-maps'; const POLYLINE_PATH: google.maps.LatLngLiteral[] = [ @@ -75,8 +74,7 @@ let apiLoadingPromise: Promise | null = null; MapHeatmapLayer, MapInfoWindow, MapKmlLayer, - MapMarker, - DeprecatedMapMarkerClusterer, + MapMarkerClusterer, MapAdvancedMarker, MapPolygon, MapPolyline, @@ -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/google-maps-module.ts b/src/google-maps/google-maps-module.ts index c373e61fd088..d8fad333f37c 100644 --- a/src/google-maps/google-maps-module.ts +++ b/src/google-maps/google-maps-module.ts @@ -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, @@ -44,6 +45,7 @@ const COMPONENTS = [ 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 new file mode 100644 index 000000000000..a901018c8bde --- /dev/null +++ b/src/google-maps/map-marker-clusterer/README.md @@ -0,0 +1,51 @@ +# MapMarkerClusterer + +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 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. + +## Example + +```typescript +// google-map-demo.component.ts +import {Component} from '@angular/core'; +import {GoogleMap, MapMarkerClusterer, MapAdvancedMarker} from '@angular/google-maps'; + +@Component({ + selector: 'google-map-demo', + templateUrl: 'google-map-demo.html', + imports: [GoogleMap, MapMarkerClusterer, MapAdvancedMarker], +}) +export class GoogleMapDemo { + center: google.maps.LatLngLiteral = {lat: 24, lng: 12}; + zoom = 4; + markerPositions: google.maps.LatLngLiteral[] = []; + + addMarker(event: google.maps.MapMouseEvent) { + this.markerPositions.push(event.latLng.toJSON()); + } +} +``` + +```html + + + @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 new file mode 100644 index 000000000000..01629f6ed952 --- /dev/null +++ b/src/google-maps/map-marker-clusterer/map-marker-clusterer.spec.ts @@ -0,0 +1,209 @@ +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} from '../google-map/google-map'; +import {GoogleMapsModule} from '../google-maps-module'; +import { + createMapConstructorSpy, + createMapSpy, + createMarkerClustererConstructorSpy, + createMarkerClustererSpy, + createAdvancedMarkerSpy, + createAdvancedMarkerConstructorSpy, +} from '../testing/fake-google-map-utils'; +import {MapMarkerClusterer} from './map-marker-clusterer'; + +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.AdvancedMarkerElement; + + beforeEach(() => { + mapSpy = createMapSpy(DEFAULT_OPTIONS); + createMapConstructorSpy(mapSpy).and.callThrough(); + + const markerSpy = createAdvancedMarkerSpy({}); + // The spy target function cannot be an arrow-function as this breaks when created + // through `new`. + createAdvancedMarkerConstructorSpy(markerSpy).and.callFake(function () { + return createAdvancedMarkerSpy({}); + }); + + markerClustererSpy = createMarkerClustererSpy(); + markerClustererConstructorSpy = + createMarkerClustererConstructorSpy(markerClustererSpy).and.callThrough(); + + 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 = createMarkerClustererConstructorSpy( + markerClustererSpy, + false, + ).and.callThrough(); + + 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({ + map: mapSpy, + renderer: undefined, + algorithm: undefined, + onClusterClick: jasmine.any(Function), + }); + })); + + it('sets marker clusterer inputs', fakeAsync(() => { + fixture.componentInstance.algorithm = {name: 'custom'} as any; + fixture.componentInstance.renderer = {render: () => null!}; + fixture.detectChanges(); + flush(); + + expect(markerClustererConstructorSpy).toHaveBeenCalledWith({ + map: mapSpy, + algorithm: fixture.componentInstance.algorithm, + renderer: fixture.componentInstance.renderer, + onClusterClick: jasmine.any(Function), + }); + })); + + it('recreates the clusterer if the options change', fakeAsync(() => { + fixture.componentInstance.algorithm = {name: 'custom1'} as any; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + flush(); + + 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(markerClustererConstructorSpy).toHaveBeenCalledWith({ + map: mapSpy, + algorithm: jasmine.objectContaining({name: 'custom2'}), + renderer: undefined, + onClusterClick: jasmine.any(Function), + }); + })); + + 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.render).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.render).toHaveBeenCalledTimes(2); + })); + + it('initializes event handlers on the map related to clustering', fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(mapSpy.addListener).toHaveBeenCalledWith('clusteringbegin', jasmine.any(Function)); + expect(mapSpy.addListener).not.toHaveBeenCalledWith('clusteringend', jasmine.any(Function)); + })); + + it('emits to clusterClick when the `onClusterClick` callback is invoked', fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.onClusterClick).not.toHaveBeenCalled(); + + const callback = markerClustererConstructorSpy.calls.mostRecent().args[0].onClusterClick; + callback({}, {}, {}); + fixture.detectChanges(); + flush(); + + 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') { + + } + + + `, +}) +class TestApp { + @ViewChild(MapMarkerClusterer) markerClusterer: MapMarkerClusterer; + renderer: Renderer; + algorithm: Algorithm; + state = 'state1'; + 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 new file mode 100644 index 000000000000..8113ab961e9c --- /dev/null +++ b/src/google-maps/map-marker-clusterer/map-marker-clusterer.ts @@ -0,0 +1,229 @@ +/** + * @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 { + ChangeDetectionStrategy, + Component, + ContentChildren, + EventEmitter, + inject, + Input, + NgZone, + OnChanges, + OnDestroy, + OnInit, + Output, + QueryList, + SimpleChanges, + ViewEncapsulation, +} from '@angular/core'; +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 {MAP_MARKER, Marker, MarkerDirective} from '../marker-utilities'; + +declare const markerClusterer: { + MarkerClusterer: typeof MarkerClusterer; + defaultOnClusterClickHandler: onClusterClickHandler; +}; + +/** + * Angular component for implementing a Google Maps Marker Clusterer. + * + * See https://developers.google.com/maps/documentation/javascript/marker-clustering + */ +@Component({ + selector: 'map-marker-clusterer', + exportAs: 'mapMarkerClusterer', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + encapsulation: ViewEncapsulation.None, +}) +export class MapMarkerClusterer implements OnInit, OnChanges, OnDestroy { + private readonly _googleMap = inject(GoogleMap); + private readonly _ngZone = inject(NgZone); + 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() + renderer: Renderer; + + /** + * 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._closestMapEventManager.getLazyEmitter('clusteringbegin'); + + /** Emits when clustering is done. */ + @Output() readonly clusteringend: Observable = + this._closestMapEventManager.getLazyEmitter('clusteringend'); + + /** Emits when a cluster has been clicked. */ + @Output() + readonly clusterClick: EventEmitter = new EventEmitter(); + + /** Event emitted when the marker clusterer is initialized. */ + @Output() readonly markerClustererInitialized: EventEmitter = + new EventEmitter(); + + @ContentChildren(MAP_MARKER, {descendants: true}) _markers: QueryList; + + /** Underlying MarkerClusterer object used to interact with Google Maps. */ + markerClusterer?: MarkerClusterer; + + async ngOnInit() { + if (this._canInitialize) { + await this._createCluster(); + + // 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!); + } + } + + async ngOnChanges(changes: SimpleChanges) { + const change = changes['renderer'] || changes['algorithm']; + + // 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._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', + ); + } + + const map = await this._googleMap._resolveMap(); + this._destroyCluster(); + + // Create the object outside the zone so its events don't trigger change detection. + // We'll bring it back in inside the `MapEventManager` only for the events that the + // user has subscribed to. + this._ngZone.runOutsideAngular(() => { + this.markerClusterer = new markerClusterer.MarkerClusterer({ + map, + 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); + }); + + await this._watchForMarkerChanges(); + } + + private async _watchForMarkerChanges() { + this._assertInitialized(); + const initialMarkers: Marker[] = []; + const markers = await this._getInternalMarkers(this._markers.toArray()); + + for (const marker of markers) { + this._currentMarkers.add(marker); + initialMarkers.push(marker); + } + this.markerClusterer.addMarkers(initialMarkers); + + 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); + } + } + for (const marker of Array.from(this._currentMarkers)) { + if (!newMarkers.has(marker)) { + markersToRemove.push(marker); + } + } + this.markerClusterer.addMarkers(markersToAdd, true); + this.markerClusterer.removeMarkers(markersToRemove, true); + this.markerClusterer.render(); + for (const marker of markersToRemove) { + this._currentMarkers.delete(marker); + } + }, + ); + } + + private _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: MarkerDirective[]): Promise { + return Promise.all(markers.map(marker => marker._resolveMarker())); + } + + 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. ' + + 'Please wait for the MarkerClusterer to load before trying to interact with it.', + ); + } + } + } +} 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/public-api.ts b/src/google-maps/public-api.ts index b83f5cfd7587..98cc770f2a1a 100644 --- a/src/google-maps/public-api.ts +++ b/src/google-maps/public-api.ts @@ -23,6 +23,7 @@ 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'; export {MapRectangle} from './map-rectangle/map-rectangle'; diff --git a/src/google-maps/testing/fake-google-map-utils.ts b/src/google-maps/testing/fake-google-map-utils.ts index 1e8f5f293798..89b32072ca6f 100644 --- a/src/google-maps/testing/fake-google-map-utils.ts +++ b/src/google-maps/testing/fake-google-map-utils.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +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`. @@ -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. */ @@ -573,6 +578,42 @@ export function createBicyclingLayerConstructorSpy( return bicylingLayerConstructorSpy; } +/** Creates a jasmine.SpyObj for a MarkerClusterer */ +export function createMarkerClustererSpy(): jasmine.SpyObj { + 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', [ diff --git a/tools/public_api_guard/google-maps/google-maps.md b/tools/public_api_guard/google-maps/google-maps.md index b65c4afdb221..28c956a8ed3c 100644 --- a/tools/public_api_guard/google-maps/google-maps.md +++ b/tools/public_api_guard/google-maps/google-maps.md @@ -229,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); @@ -261,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) @@ -542,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); @@ -600,6 +601,29 @@ export class MapMarker implements OnInit, OnChanges, OnDestroy, MapAnchorPoint { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +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) + _markers: QueryList; + // (undocumented) + ngOnChanges(changes: SimpleChanges): Promise; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): Promise; + renderer: Renderer; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export class MapPolygon implements OnInit, OnDestroy { constructor(...args: unknown[]);