@@ -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[]);