Skip to content

Commit fc8e8dd

Browse files
authored
feat: Adding viewport aware version of SuperCluster algorithm for use with legacy markers (#640)
1 parent d004a3a commit fc8e8dd

File tree

4 files changed

+371
-0
lines changed

4 files changed

+371
-0
lines changed

src/algorithms/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export * from "./core";
1818
export * from "./grid";
1919
export * from "./noop";
2020
export * from "./supercluster";
21+
export * from "./superviewport";
2122
export * from "./utils";

src/algorithms/superviewport.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Copyright 2021 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { SuperClusterViewportAlgorithm } from "./superviewport";
18+
import { initialize, MapCanvasProjection } from "@googlemaps/jest-mocks";
19+
import { Marker } from "../marker-utils";
20+
21+
initialize();
22+
const markerClasses = [
23+
google.maps.Marker,
24+
google.maps.marker.AdvancedMarkerElement,
25+
];
26+
27+
describe.each(markerClasses)(
28+
"SuperCluster works with legacy and Advanced Markers",
29+
(markerClass) => {
30+
let map: google.maps.Map;
31+
32+
beforeEach(() => {
33+
map = new google.maps.Map(document.createElement("div"));
34+
});
35+
36+
test("should only call load if markers change", () => {
37+
const mapCanvasProjection = new MapCanvasProjection();
38+
const markers: Marker[] = [new markerClass()];
39+
40+
const superCluster = new SuperClusterViewportAlgorithm({});
41+
superCluster["superCluster"].load = jest.fn();
42+
superCluster.cluster = jest.fn();
43+
44+
superCluster.calculate({
45+
markers,
46+
map,
47+
mapCanvasProjection,
48+
});
49+
superCluster.calculate({
50+
markers,
51+
map,
52+
mapCanvasProjection,
53+
});
54+
expect(superCluster["superCluster"].load).toHaveBeenCalledTimes(1);
55+
expect(superCluster["superCluster"].load).toHaveBeenCalledWith([
56+
{
57+
type: "Feature",
58+
geometry: { coordinates: [0, 0], type: "Point" },
59+
properties: { marker: markers[0] },
60+
},
61+
]);
62+
});
63+
64+
test("should cluster markers", () => {
65+
const mapCanvasProjection = new MapCanvasProjection();
66+
const markers: Marker[] = [new markerClass(), new markerClass()];
67+
68+
const superCluster = new SuperClusterViewportAlgorithm({});
69+
map.getZoom = jest.fn().mockReturnValue(0);
70+
map.getBounds = jest.fn().mockReturnValue({
71+
toJSON: () => ({
72+
west: -180,
73+
south: -90,
74+
east: 180,
75+
north: 90,
76+
}),
77+
getNorthEast: jest
78+
.fn()
79+
.mockReturnValue({ getLat: () => -3, getLng: () => 34 }),
80+
getSouthWest: jest
81+
.fn()
82+
.mockReturnValue({ getLat: () => 29, getLng: () => 103 }),
83+
});
84+
const { clusters } = superCluster.calculate({
85+
markers,
86+
map,
87+
mapCanvasProjection,
88+
});
89+
90+
expect(clusters).toHaveLength(1);
91+
});
92+
93+
test("should transform to Cluster with single marker if not cluster", () => {
94+
const superCluster = new SuperClusterViewportAlgorithm({});
95+
const marker: Marker = new markerClass();
96+
97+
const cluster = superCluster["transformCluster"]({
98+
type: "Feature",
99+
geometry: { coordinates: [0, 0], type: "Point" },
100+
properties: {
101+
marker,
102+
cluster: null,
103+
cluster_id: null,
104+
point_count: 1,
105+
point_count_abbreviated: 1,
106+
},
107+
});
108+
expect(cluster.markers.length).toEqual(1);
109+
expect(cluster.markers[0]).toBe(marker);
110+
});
111+
112+
test("should not cluster if zoom didn't change", () => {
113+
const mapCanvasProjection = new MapCanvasProjection();
114+
const markers: Marker[] = [new markerClass(), new markerClass()];
115+
116+
const superCluster = new SuperClusterViewportAlgorithm({});
117+
superCluster["markers"] = markers;
118+
superCluster["state"] = { zoom: 12, view: [1, 2, 3, 4] };
119+
superCluster.cluster = jest.fn().mockReturnValue([]);
120+
superCluster["clusters"] = [];
121+
122+
map.getZoom = jest.fn().mockReturnValue(superCluster["state"].zoom);
123+
124+
const { clusters, changed } = superCluster.calculate({
125+
markers,
126+
map,
127+
mapCanvasProjection,
128+
});
129+
130+
expect(changed).toBeTruthy();
131+
expect(clusters).toBe(superCluster["clusters"]);
132+
});
133+
134+
test("should not cluster if zoom beyond maxZoom", () => {
135+
const mapCanvasProjection = new MapCanvasProjection();
136+
const markers: Marker[] = [new markerClass(), new markerClass()];
137+
138+
const superCluster = new SuperClusterViewportAlgorithm({});
139+
superCluster["markers"] = markers;
140+
superCluster["state"] = { zoom: 20, view: [1, 2, 3, 4] };
141+
superCluster.cluster = jest.fn().mockReturnValue([]);
142+
superCluster["clusters"] = [];
143+
144+
map.getZoom = jest.fn().mockReturnValue(superCluster["state"].zoom + 1);
145+
146+
const { clusters, changed } = superCluster.calculate({
147+
markers,
148+
map,
149+
mapCanvasProjection,
150+
});
151+
152+
expect(changed).toBeTruthy();
153+
expect(clusters).toBe(superCluster["clusters"]);
154+
expect(superCluster["state"]).toEqual({ zoom: 21, view: [0, 0, 0, 0] });
155+
});
156+
157+
test("should round fractional zoom", () => {
158+
const mapCanvasProjection = new MapCanvasProjection();
159+
const markers: Marker[] = [new markerClass(), new markerClass()];
160+
mapCanvasProjection.fromLatLngToDivPixel = jest
161+
.fn()
162+
.mockImplementation((b: google.maps.LatLng) => ({
163+
x: b.lat() * 100,
164+
y: b.lng() * 100,
165+
}));
166+
mapCanvasProjection.fromDivPixelToLatLng = jest
167+
.fn()
168+
.mockImplementation(
169+
(p: google.maps.Point) =>
170+
new google.maps.LatLng({ lat: p.x / 100, lng: p.y / 100 })
171+
);
172+
173+
map.getBounds = jest.fn().mockReturnValue({
174+
getNorthEast: jest
175+
.fn()
176+
.mockReturnValue({ lat: () => -3, lng: () => 34 }),
177+
getSouthWest: jest
178+
.fn()
179+
.mockReturnValue({ lat: () => 29, lng: () => 103 }),
180+
});
181+
182+
const superCluster = new SuperClusterViewportAlgorithm({});
183+
superCluster["superCluster"].getClusters = jest.fn().mockReturnValue([]);
184+
superCluster["markers"] = markers;
185+
superCluster["state"] = { zoom: 4, view: [1, 2, 3, 4] };
186+
superCluster["clusters"] = [];
187+
188+
map.getZoom = jest.fn().mockReturnValue(1.534);
189+
expect(
190+
superCluster.calculate({ markers, map, mapCanvasProjection })
191+
).toEqual({ changed: true, clusters: [] });
192+
193+
expect(superCluster["superCluster"].getClusters).toHaveBeenCalledWith(
194+
[0, 0, 0, 0],
195+
2
196+
);
197+
198+
map.getZoom = jest.fn().mockReturnValue(3.234);
199+
superCluster.calculate({ markers, map, mapCanvasProjection });
200+
expect(superCluster["superCluster"].getClusters).toHaveBeenCalledWith(
201+
[0, 0, 0, 0],
202+
3
203+
);
204+
});
205+
}
206+
);

src/algorithms/superviewport.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Copyright 2021 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
AbstractViewportAlgorithm,
19+
AlgorithmInput,
20+
AlgorithmOutput,
21+
ViewportAlgorithmOptions,
22+
} from "./core";
23+
import { SuperClusterOptions } from "./supercluster";
24+
import SuperCluster, { ClusterFeature } from "supercluster";
25+
import { MarkerUtils, Marker } from "../marker-utils";
26+
import { Cluster } from "../cluster";
27+
import { getPaddedViewport } from "./utils";
28+
import equal from "fast-deep-equal";
29+
30+
export interface SuperClusterViewportOptions
31+
extends SuperClusterOptions,
32+
ViewportAlgorithmOptions {}
33+
34+
export interface SuperClusterViewportState {
35+
/* The current zoom level */
36+
zoom: number;
37+
38+
/* The current viewport as a bbox [westLng, southLat, eastLng, northLat] */
39+
view: [number, number, number, number];
40+
}
41+
42+
/**
43+
* A very fast JavaScript algorithm for geospatial point clustering using KD trees.
44+
*
45+
* @see https://www.npmjs.com/package/supercluster for more information on options.
46+
*/
47+
export class SuperClusterViewportAlgorithm extends AbstractViewportAlgorithm {
48+
protected superCluster: SuperCluster;
49+
protected markers: Marker[];
50+
protected clusters: Cluster[];
51+
protected state: SuperClusterViewportState;
52+
53+
constructor({
54+
maxZoom,
55+
radius = 60,
56+
viewportPadding = 60,
57+
...options
58+
}: SuperClusterViewportOptions) {
59+
super({ maxZoom, viewportPadding });
60+
61+
this.superCluster = new SuperCluster({
62+
maxZoom: this.maxZoom,
63+
radius,
64+
...options,
65+
});
66+
67+
this.state = { zoom: -1, view: [0, 0, 0, 0] };
68+
}
69+
70+
public calculate(input: AlgorithmInput): AlgorithmOutput {
71+
const state: SuperClusterViewportState = {
72+
zoom: Math.round(input.map.getZoom()),
73+
view: getPaddedViewport(
74+
input.map.getBounds(),
75+
input.mapCanvasProjection,
76+
this.viewportPadding
77+
),
78+
};
79+
80+
let changed = !equal(this.state, state);
81+
if (!equal(input.markers, this.markers)) {
82+
changed = true;
83+
// TODO use proxy to avoid copy?
84+
this.markers = [...input.markers];
85+
86+
const points = this.markers.map((marker) => {
87+
const position = MarkerUtils.getPosition(marker);
88+
const coordinates = [position.lng(), position.lat()];
89+
return {
90+
type: "Feature" as const,
91+
geometry: {
92+
type: "Point" as const,
93+
coordinates,
94+
},
95+
properties: { marker },
96+
};
97+
});
98+
this.superCluster.load(points);
99+
}
100+
101+
if (changed) {
102+
this.clusters = this.cluster(input);
103+
this.state = state;
104+
}
105+
106+
return { clusters: this.clusters, changed };
107+
}
108+
109+
public cluster({ map, mapCanvasProjection }: AlgorithmInput): Cluster[] {
110+
/* recalculate new state because we can't use the cached version. */
111+
const state: SuperClusterViewportState = {
112+
zoom: Math.round(map.getZoom()),
113+
view: getPaddedViewport(
114+
map.getBounds(),
115+
mapCanvasProjection,
116+
this.viewportPadding
117+
),
118+
};
119+
120+
return this.superCluster
121+
.getClusters(state.view, state.zoom)
122+
.map((feature: ClusterFeature<{ marker: Marker }>) =>
123+
this.transformCluster(feature)
124+
);
125+
}
126+
127+
protected transformCluster({
128+
geometry: {
129+
coordinates: [lng, lat],
130+
},
131+
properties,
132+
}: ClusterFeature<{ marker: Marker }>): Cluster {
133+
if (properties.cluster) {
134+
return new Cluster({
135+
markers: this.superCluster
136+
.getLeaves(properties.cluster_id, Infinity)
137+
.map((leaf) => leaf.properties.marker),
138+
position: { lat, lng },
139+
});
140+
}
141+
142+
const marker = properties.marker;
143+
144+
return new Cluster({
145+
markers: [marker],
146+
position: MarkerUtils.getPosition(marker),
147+
});
148+
}
149+
}

src/algorithms/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ export const extendBoundsToPaddedViewport = (
6060
return pixelBoundsToLatLngBounds(extendedPixelBounds, projection);
6161
};
6262

63+
/**
64+
* Gets the extended bounds as a bbox [westLng, southLat, eastLng, northLat]
65+
*/
66+
export const getPaddedViewport = (
67+
bounds: google.maps.LatLngBounds,
68+
projection: google.maps.MapCanvasProjection,
69+
pixels: number
70+
): [number, number, number, number] => {
71+
const extended = extendBoundsToPaddedViewport(bounds, projection, pixels);
72+
const ne = extended.getNorthEast();
73+
const sw = extended.getSouthWest();
74+
75+
return [sw.lng(), sw.lat(), ne.lng(), ne.lat()];
76+
};
77+
6378
/**
6479
* Returns the distance between 2 positions.
6580
*

0 commit comments

Comments
 (0)