diff --git a/src/algorithms/superviewport.test.ts b/src/algorithms/superviewport.test.ts index cf1e02a5..e8c2cfb2 100644 --- a/src/algorithms/superviewport.test.ts +++ b/src/algorithms/superviewport.test.ts @@ -18,6 +18,7 @@ import { SuperClusterViewportAlgorithm } from "./superviewport"; import { initialize, MapCanvasProjection } from "@googlemaps/jest-mocks"; import { Marker } from "../marker-utils"; import { ClusterFeature } from "supercluster"; +import { Cluster } from "../cluster"; initialize(); const markerClasses = [ @@ -29,13 +30,28 @@ describe.each(markerClasses)( "SuperCluster works with legacy and Advanced Markers", (markerClass) => { let map: google.maps.Map; + let mapCanvasProjection: MapCanvasProjection; beforeEach(() => { map = new google.maps.Map(document.createElement("div")); + mapCanvasProjection = new MapCanvasProjection(); + + mapCanvasProjection.fromLatLngToDivPixel = jest + .fn() + .mockImplementation((latLng: google.maps.LatLng) => ({ + x: latLng.lng() * 100, + y: latLng.lat() * 100, + })); + + mapCanvasProjection.fromDivPixelToLatLng = jest + .fn() + .mockImplementation((point: google.maps.Point) => ({ + lat: () => point.y / 100, + lng: () => point.x / 100, + })); }); test("should only call load if markers change", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); @@ -63,11 +79,20 @@ describe.each(markerClasses)( }); test("should cluster markers", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); map.getZoom = jest.fn().mockReturnValue(0); + + const northEast = { + lat: jest.fn().mockReturnValue(-3), + lng: jest.fn().mockReturnValue(34), + }; + const southWest = { + lat: jest.fn().mockReturnValue(29), + lng: jest.fn().mockReturnValue(103), + }; + map.getBounds = jest.fn().mockReturnValue({ toJSON: () => ({ west: -180, @@ -75,13 +100,10 @@ describe.each(markerClasses)( east: 180, north: 90, }), - getNorthEast: jest - .fn() - .mockReturnValue({ getLat: () => -3, getLng: () => 34 }), - getSouthWest: jest - .fn() - .mockReturnValue({ getLat: () => 29, getLng: () => 103 }), + getNorthEast: jest.fn().mockReturnValue(northEast), + getSouthWest: jest.fn().mockReturnValue(southWest), }); + const { clusters } = superCluster.calculate({ markers, map, @@ -106,7 +128,6 @@ describe.each(markerClasses)( }, }; - // mock out the supercluster implementation jest .spyOn(superCluster["superCluster"], "getLeaves") .mockImplementation(() => [clusterFeature]); @@ -117,7 +138,6 @@ describe.each(markerClasses)( }); test("should not cluster if zoom didn't change", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); @@ -134,12 +154,11 @@ describe.each(markerClasses)( mapCanvasProjection, }); - expect(changed).toBeTruthy(); + expect(changed).toBeFalsy(); expect(clusters).toBe(superCluster["clusters"]); }); test("should not cluster if zoom beyond maxZoom", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); @@ -150,40 +169,46 @@ describe.each(markerClasses)( map.getZoom = jest.fn().mockReturnValue(superCluster["state"].zoom + 1); + const northEast = { + lat: jest.fn().mockReturnValue(0), + lng: jest.fn().mockReturnValue(0), + }; + const southWest = { + lat: jest.fn().mockReturnValue(0), + lng: jest.fn().mockReturnValue(0), + }; + map.getBounds = jest.fn().mockReturnValue({ + getNorthEast: jest.fn().mockReturnValue(northEast), + getSouthWest: jest.fn().mockReturnValue(southWest), + }); + const { clusters, changed } = superCluster.calculate({ markers, map, mapCanvasProjection, }); - expect(changed).toBeTruthy(); + expect(changed).toBeFalsy(); expect(clusters).toBe(superCluster["clusters"]); - expect(superCluster["state"]).toEqual({ zoom: 21, view: [0, 0, 0, 0] }); + expect(superCluster["state"].zoom).toBe(21); + expect(Array.isArray(superCluster["state"].view)).toBeTruthy(); }); test("should round fractional zoom", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; - mapCanvasProjection.fromLatLngToDivPixel = jest - .fn() - .mockImplementation((b: google.maps.LatLng) => ({ - x: b.lat() * 100, - y: b.lng() * 100, - })); - mapCanvasProjection.fromDivPixelToLatLng = jest - .fn() - .mockImplementation( - (p: google.maps.Point) => - new google.maps.LatLng({ lat: p.x / 100, lng: p.y / 100 }) - ); + + const northEast = { + lat: jest.fn().mockReturnValue(-3), + lng: jest.fn().mockReturnValue(34), + }; + const southWest = { + lat: jest.fn().mockReturnValue(29), + lng: jest.fn().mockReturnValue(103), + }; map.getBounds = jest.fn().mockReturnValue({ - getNorthEast: jest - .fn() - .mockReturnValue({ lat: () => -3, lng: () => 34 }), - getSouthWest: jest - .fn() - .mockReturnValue({ lat: () => 29, lng: () => 103 }), + getNorthEast: jest.fn().mockReturnValue(northEast), + getSouthWest: jest.fn().mockReturnValue(southWest), }); const superCluster = new SuperClusterViewportAlgorithm({}); @@ -195,19 +220,190 @@ describe.each(markerClasses)( map.getZoom = jest.fn().mockReturnValue(1.534); expect( superCluster.calculate({ markers, map, mapCanvasProjection }) - ).toEqual({ changed: true, clusters: [] }); + ).toEqual({ changed: false, clusters: [] }); expect(superCluster["superCluster"].getClusters).toHaveBeenCalledWith( - [0, 0, 0, 0], + expect.any(Array), 2 ); map.getZoom = jest.fn().mockReturnValue(3.234); superCluster.calculate({ markers, map, mapCanvasProjection }); expect(superCluster["superCluster"].getClusters).toHaveBeenCalledWith( - [0, 0, 0, 0], + expect.any(Array), 3 ); }); + + test("should return changed=false when viewport changes but clusters remain the same", () => { + const markers: Marker[] = [new markerClass(), new markerClass()]; + + map.getZoom = jest.fn().mockReturnValue(10); + + const initialNorthEast = { + lat: jest.fn().mockReturnValue(10), + lng: jest.fn().mockReturnValue(10), + }; + const initialSouthWest = { + lat: jest.fn().mockReturnValue(0), + lng: jest.fn().mockReturnValue(0), + }; + const initialBounds = { + getNorthEast: jest.fn().mockReturnValue(initialNorthEast), + getSouthWest: jest.fn().mockReturnValue(initialSouthWest), + }; + map.getBounds = jest.fn().mockReturnValue(initialBounds); + + const algorithm = new SuperClusterViewportAlgorithm({ + viewportPadding: 60, + }); + + const sameCluster = [ + new Cluster({ + markers: markers, + position: { lat: 5, lng: 5 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValue(sameCluster); + + const firstResult = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + + expect(firstResult.changed).toBeTruthy(); + + const newNorthEast = { + lat: jest.fn().mockReturnValue(15), + lng: jest.fn().mockReturnValue(15), + }; + const newSouthWest = { + lat: jest.fn().mockReturnValue(5), + lng: jest.fn().mockReturnValue(5), + }; + const newBounds = { + getNorthEast: jest.fn().mockReturnValue(newNorthEast), + getSouthWest: jest.fn().mockReturnValue(newSouthWest), + }; + map.getBounds = jest.fn().mockReturnValue(newBounds); + + const secondResult = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + + expect(secondResult.changed).toBeFalsy(); + expect(secondResult.clusters).toEqual(sameCluster); + }); + + test("should detect cluster changes accurately with areClusterArraysEqual", () => { + const markers: Marker[] = [new markerClass(), new markerClass()]; + + map.getZoom = jest.fn().mockReturnValue(10); + map.getBounds = jest.fn().mockReturnValue({ + getNorthEast: jest + .fn() + .mockReturnValue({ lat: () => 10, lng: () => 10 }), + getSouthWest: jest.fn().mockReturnValue({ lat: () => 0, lng: () => 0 }), + }); + + const algorithm = new SuperClusterViewportAlgorithm({}); + + const cluster1 = [ + new Cluster({ + markers: [markers[0]], + position: { lat: 2, lng: 2 }, + }), + new Cluster({ + markers: [markers[1]], + position: { lat: 8, lng: 8 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValueOnce(cluster1); + + const result1 = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + expect(result1.changed).toBeTruthy(); + + const cluster2 = [ + new Cluster({ + markers: [markers[0]], + position: { lat: 2, lng: 2 }, + }), + new Cluster({ + markers: [markers[1]], + position: { lat: 8, lng: 8 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValueOnce(cluster2); + + const result2 = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + expect(result2.changed).toBeFalsy(); + + const cluster3 = [ + new Cluster({ + markers: markers, + position: { lat: 5, lng: 5 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValueOnce(cluster3); + + const result3 = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + expect(result3.changed).toBeTruthy(); + }); + + test("should correctly calculate viewport state with getPaddedViewport", () => { + const markers: Marker[] = [new markerClass()]; + + map.getZoom = jest.fn().mockReturnValue(10); + + const northEast = { + lat: jest.fn().mockReturnValue(10), + lng: jest.fn().mockReturnValue(20), + }; + const southWest = { + lat: jest.fn().mockReturnValue(0), + lng: jest.fn().mockReturnValue(10), + }; + + const bounds = { + getNorthEast: jest.fn().mockReturnValue(northEast), + getSouthWest: jest.fn().mockReturnValue(southWest), + }; + map.getBounds = jest.fn().mockReturnValue(bounds); + + const algorithm = new SuperClusterViewportAlgorithm({ + viewportPadding: 60, + }); + algorithm.cluster = jest.fn().mockReturnValue([]); + + const result = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + + const state = algorithm["state"]; + + expect(state.view).toEqual([0, 0, 0, 0]); + expect(state.zoom).toBe(10); + }); } ); diff --git a/src/algorithms/superviewport.ts b/src/algorithms/superviewport.ts index df4f92c6..633f652b 100644 --- a/src/algorithms/superviewport.ts +++ b/src/algorithms/superviewport.ts @@ -71,9 +71,9 @@ export class SuperClusterViewportAlgorithm extends AbstractViewportAlgorithm { public calculate(input: AlgorithmInput): AlgorithmOutput { const state = this.getViewportState(input); - let changed = !deepEqual(this.state, state); + let markerChanged = false; if (!deepEqual(input.markers, this.markers)) { - changed = true; + markerChanged = true; // TODO use proxy to avoid copy? this.markers = [...input.markers]; @@ -92,12 +92,19 @@ export class SuperClusterViewportAlgorithm extends AbstractViewportAlgorithm { this.superCluster.load(points); } - if (changed) { - this.clusters = this.cluster(input); - this.state = state; + const newClusters = this.cluster(input); + + const clustersChanged = + !this.areClusterArraysEqual(this.clusters, newClusters) || markerChanged; + + this.state = state; + + if (clustersChanged) { + this.clusters = newClusters; + return { clusters: this.clusters, changed: true }; } - return { clusters: this.clusters, changed }; + return { clusters: this.clusters, changed: false }; } public cluster(input: AlgorithmInput): Cluster[] { @@ -150,4 +157,26 @@ export class SuperClusterViewportAlgorithm extends AbstractViewportAlgorithm { ), }; } + + private areClusterArraysEqual( + clustersA: Cluster[], + clustersB: Cluster[] + ): boolean { + if (!clustersA || !clustersB) return clustersA === clustersB; + if (clustersA.length !== clustersB.length) return false; + + for (let i = 0; i < clustersA.length; i++) { + const a = clustersA[i]; + const b = clustersB[i]; + + if (a.markers.length !== b.markers.length) return false; + + const posA = a.position; + const posB = b.position; + + if (posA.lat() !== posB.lat() || posA.lng() !== posB.lng()) return false; + } + + return true; + } } diff --git a/src/markerclusterer.ts b/src/markerclusterer.ts index 4ae81b70..3495e049 100644 --- a/src/markerclusterer.ts +++ b/src/markerclusterer.ts @@ -215,9 +215,11 @@ export class MarkerClusterer extends OverlayViewSafe { this.renderClusters(); // Delayed removal of the markers of the former groups. - requestAnimationFrame(() => - groupMarkers.forEach((marker) => MarkerUtils.setMap(marker, null)) - ); + setTimeout(() => { + groupMarkers.forEach((marker) => { + MarkerUtils.setMap(marker, null); + }); + }, 35); } google.maps.event.trigger( this,