/* global google */
/* eslint-disable filenames/match-regex */
import { Cluster } from './Cluster'
import type { ClusterIcon } from './ClusterIcon'
import type {
MarkerExtended,
ClustererOptions,
ClusterIconStyle,
TCalculator,
ClusterIconInfo,
} from './types'
/**
* Supports up to 9007199254740991 (Number.MAX_SAFE_INTEGER) markers
* which is not a problem as max array length is 4294967296 (2**32)
*/
function CALCULATOR(
markers: MarkerExtended[],
numStyles: number
): ClusterIconInfo {
const count = markers.length
const numberOfDigits = count.toString().length
const index = Math.min(numberOfDigits, numStyles)
return {
text: count.toString(),
index,
title: '',
}
}
const BATCH_SIZE = 2000
const BATCH_SIZE_IE = 500
const IMAGE_PATH =
'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m'
const IMAGE_EXTENSION = 'png'
const IMAGE_SIZES = [53, 56, 66, 78, 90]
const CLUSTERER_CLASS = 'cluster'
export class Clusterer implements google.maps.OverlayView {
markers: MarkerExtended[]
clusters: Cluster[]
listeners: google.maps.MapsEventListener[]
activeMap: google.maps.Map | google.maps.StreetViewPanorama | null
ready: boolean
gridSize: number
minClusterSize: number
maxZoom: number | null
styles: ClusterIconStyle[]
title: string
zoomOnClick: boolean
averageCenter: boolean
ignoreHidden: boolean
enableRetinaIcons: boolean
imagePath: string
imageExtension: string
imageSizes: number[]
calculator: TCalculator
batchSize: number
batchSizeIE: number
clusterClass: string
timerRefStatic: number | null
constructor(
map: google.maps.Map,
optMarkers: MarkerExtended[] = [],
optOptions: ClustererOptions = {}
) {
this.getMinimumClusterSize = this.getMinimumClusterSize.bind(this)
this.setMinimumClusterSize = this.setMinimumClusterSize.bind(this)
this.getEnableRetinaIcons = this.getEnableRetinaIcons.bind(this)
this.setEnableRetinaIcons = this.setEnableRetinaIcons.bind(this)
this.addToClosestCluster = this.addToClosestCluster.bind(this)
this.getImageExtension = this.getImageExtension.bind(this)
this.setImageExtension = this.setImageExtension.bind(this)
this.getExtendedBounds = this.getExtendedBounds.bind(this)
this.getAverageCenter = this.getAverageCenter.bind(this)
this.setAverageCenter = this.setAverageCenter.bind(this)
this.getTotalClusters = this.getTotalClusters.bind(this)
this.fitMapToMarkers = this.fitMapToMarkers.bind(this)
this.getIgnoreHidden = this.getIgnoreHidden.bind(this)
this.setIgnoreHidden = this.setIgnoreHidden.bind(this)
this.getClusterClass = this.getClusterClass.bind(this)
this.setClusterClass = this.setClusterClass.bind(this)
this.getTotalMarkers = this.getTotalMarkers.bind(this)
this.getZoomOnClick = this.getZoomOnClick.bind(this)
this.setZoomOnClick = this.setZoomOnClick.bind(this)
this.getBatchSizeIE = this.getBatchSizeIE.bind(this)
this.setBatchSizeIE = this.setBatchSizeIE.bind(this)
this.createClusters = this.createClusters.bind(this)
this.onZoomChanged = this.onZoomChanged.bind(this)
this.getImageSizes = this.getImageSizes.bind(this)
this.setImageSizes = this.setImageSizes.bind(this)
this.getCalculator = this.getCalculator.bind(this)
this.setCalculator = this.setCalculator.bind(this)
this.removeMarkers = this.removeMarkers.bind(this)
this.resetViewport = this.resetViewport.bind(this)
this.getImagePath = this.getImagePath.bind(this)
this.setImagePath = this.setImagePath.bind(this)
this.pushMarkerTo = this.pushMarkerTo.bind(this)
this.removeMarker = this.removeMarker.bind(this)
this.clearMarkers = this.clearMarkers.bind(this)
this.setupStyles = this.setupStyles.bind(this)
this.getGridSize = this.getGridSize.bind(this)
this.setGridSize = this.setGridSize.bind(this)
this.getClusters = this.getClusters.bind(this)
this.getMaxZoom = this.getMaxZoom.bind(this)
this.setMaxZoom = this.setMaxZoom.bind(this)
this.getMarkers = this.getMarkers.bind(this)
this.addMarkers = this.addMarkers.bind(this)
this.getStyles = this.getStyles.bind(this)
this.setStyles = this.setStyles.bind(this)
this.addMarker = this.addMarker.bind(this)
this.onRemove = this.onRemove.bind(this)
this.getTitle = this.getTitle.bind(this)
this.setTitle = this.setTitle.bind(this)
this.repaint = this.repaint.bind(this)
this.onIdle = this.onIdle.bind(this)
this.redraw = this.redraw.bind(this)
this.onAdd = this.onAdd.bind(this)
this.draw = this.draw.bind(this)
this.extend = this.extend.bind(this)
this.extend(Clusterer, google.maps.OverlayView)
this.markers = []
this.clusters = []
this.listeners = []
this.activeMap = null
this.ready = false
this.gridSize = optOptions.gridSize || 60
this.minClusterSize = optOptions.minimumClusterSize || 2
this.maxZoom = optOptions.maxZoom || null
this.styles = optOptions.styles || []
this.title = optOptions.title || ''
this.zoomOnClick = true
if (optOptions.zoomOnClick !== undefined) {
this.zoomOnClick = optOptions.zoomOnClick
}
this.averageCenter = false
if (optOptions.averageCenter !== undefined) {
this.averageCenter = optOptions.averageCenter
}
this.ignoreHidden = false
if (optOptions.ignoreHidden !== undefined) {
this.ignoreHidden = optOptions.ignoreHidden
}
this.enableRetinaIcons = false
if (optOptions.enableRetinaIcons !== undefined) {
this.enableRetinaIcons = optOptions.enableRetinaIcons
}
this.imagePath = optOptions.imagePath || IMAGE_PATH
this.imageExtension = optOptions.imageExtension || IMAGE_EXTENSION
this.imageSizes = optOptions.imageSizes || IMAGE_SIZES
this.calculator = optOptions.calculator || CALCULATOR
this.batchSize = optOptions.batchSize || BATCH_SIZE
this.batchSizeIE = optOptions.batchSizeIE || BATCH_SIZE_IE
this.clusterClass = optOptions.clusterClass || CLUSTERER_CLASS
if (navigator.userAgent.toLowerCase().indexOf('msie') !== -1) {
// Try to avoid IE timeout when processing a huge number of markers:
this.batchSize = this.batchSizeIE
}
this.timerRefStatic = null
this.setupStyles()
this.addMarkers(optMarkers, true);
(this as unknown as google.maps.OverlayView).setMap(map) // Note: this causes onAdd to be called
}
onZoomChanged(): void {
this.resetViewport(false)
// Workaround for this Google bug: when map is at level 0 and "-" of
// zoom slider is clicked, a "zoom_changed" event is fired even though
// the map doesn't zoom out any further. In this situation, no "idle"
// event is triggered so the cluster markers that have been removed
// do not get redrawn. Same goes for a zoom in at maxZoom.
if (
(this as unknown as google.maps.OverlayView).getMap()?.getZoom() === ((this as unknown as google.maps.OverlayView).get('minZoom') || 0) ||
(this as unknown as google.maps.OverlayView).getMap()?.getZoom() === (this as unknown as google.maps.OverlayView).get('maxZoom')
) {
google.maps.event.trigger(this, 'idle')
}
}
onIdle(): void {
this.redraw()
}
onAdd(): void {
const map = (this as unknown as google.maps.OverlayView).getMap()
this.activeMap = map
this.ready = true
this.repaint()
if (map !== null) {
// Add the map event listeners
this.listeners = [
google.maps.event.addListener(
map,
'zoom_changed',
this.onZoomChanged
),
google.maps.event.addListener(
map,
'idle',
this.onIdle
),
]
}
}
onRemove(): void {
// Put all the managed markers back on the map:
for (const marker of this.markers) {
if (marker.getMap() !== this.activeMap) {
marker.setMap(this.activeMap)
}
}
// Remove all clusters:
for (const cluster of this.clusters) {
cluster.remove()
}
this.clusters = []
// Remove map event listeners:
for (const listener of this.listeners) {
google.maps.event.removeListener(listener)
}
this.listeners = []
this.activeMap = null
this.ready = false
}
draw(): void { return }
getMap(): null { return null }
getPanes(): null { return null }
getProjection() {
return {
fromContainerPixelToLatLng(): null { return null },
fromDivPixelToLatLng(): null { return null},
fromLatLngToContainerPixel(): null { return null},
fromLatLngToDivPixel(): null { return null},
getVisibleRegion(): null { return null },
getWorldWidth(): number { return 0 }
}
}
setMap(): void { return }
addListener() {
return {
remove() { return }
}
}
bindTo(): void { return }
get(): void { return }
notify(): void { return }
set(): void { return }
setValues(): void { return }
unbind(): void { return }
unbindAll(): void { return }
setupStyles(): void {
if (this.styles.length > 0) {
return
}
for (let i = 0; i < this.imageSizes.length; i++) {
this.styles.push({
url: `${this.imagePath + (i + 1)}.${this.imageExtension}`,
height: this.imageSizes[i] || 0,
width: this.imageSizes[i] || 0,
})
}
}
fitMapToMarkers(): void {
const markers = this.getMarkers()
const bounds = new google.maps.LatLngBounds()
for (const marker of markers) {
const position = marker.getPosition()
if (position) {
bounds.extend(position)
}
}
const map = (this as unknown as google.maps.OverlayView).getMap()
if (map !== null && 'fitBounds' in map) {
map.fitBounds(bounds)
}
}
getGridSize(): number {
return this.gridSize
}
setGridSize(gridSize: number) {
this.gridSize = gridSize
}
getMinimumClusterSize(): number {
return this.minClusterSize
}
setMinimumClusterSize(minimumClusterSize: number) {
this.minClusterSize = minimumClusterSize
}
getMaxZoom(): number | null {
return this.maxZoom
}
setMaxZoom(maxZoom: number) {
this.maxZoom = maxZoom
}
getStyles(): ClusterIconStyle[] {
return this.styles
}
setStyles(styles: ClusterIconStyle[]) {
this.styles = styles
}
getTitle(): string {
return this.title
}
setTitle(title: string) {
this.title = title
}
getZoomOnClick(): boolean {
return this.zoomOnClick
}
setZoomOnClick(zoomOnClick: boolean) {
this.zoomOnClick = zoomOnClick
}
getAverageCenter(): boolean {
return this.averageCenter
}
setAverageCenter(averageCenter: boolean) {
this.averageCenter = averageCenter
}
getIgnoreHidden(): boolean {
return this.ignoreHidden
}
setIgnoreHidden(ignoreHidden: boolean) {
this.ignoreHidden = ignoreHidden
}
getEnableRetinaIcons(): boolean {
return this.enableRetinaIcons
}
setEnableRetinaIcons(enableRetinaIcons: boolean) {
this.enableRetinaIcons = enableRetinaIcons
}
getImageExtension(): string {
return this.imageExtension
}
setImageExtension(imageExtension: string) {
this.imageExtension = imageExtension
}
getImagePath(): string {
return this.imagePath
}
setImagePath(imagePath: string) {
this.imagePath = imagePath
}
getImageSizes(): number[] {
return this.imageSizes
}
setImageSizes(imageSizes: number[]) {
this.imageSizes = imageSizes
}
getCalculator(): TCalculator {
return this.calculator
}
setCalculator(calculator: TCalculator) {
this.calculator = calculator
}
getBatchSizeIE(): number {
return this.batchSizeIE
}
setBatchSizeIE(batchSizeIE: number) {
this.batchSizeIE = batchSizeIE
}
getClusterClass(): string {
return this.clusterClass
}
setClusterClass(clusterClass: string) {
this.clusterClass = clusterClass
}
getMarkers(): MarkerExtended[] {
return this.markers
}
getTotalMarkers(): number {
return this.markers.length
}
getClusters(): Cluster[] {
return this.clusters
}
getTotalClusters(): number {
return this.clusters.length
}
addMarker(marker: MarkerExtended, optNoDraw: boolean) {
this.pushMarkerTo(marker)
if (!optNoDraw) {
this.redraw()
}
}
addMarkers(markers: MarkerExtended[], optNoDraw: boolean) {
for (const key in markers) {
if (Object.prototype.hasOwnProperty.call(markers, key)) {
const marker = markers[key]
if (marker) {
this.pushMarkerTo(marker)
}
}
}
if (!optNoDraw) {
this.redraw()
}
}
pushMarkerTo(marker: MarkerExtended) {
// If the marker is draggable add a listener so we can update the clusters on the dragend:
if (marker.getDraggable()) {
google.maps.event.addListener(marker, 'dragend', () => {
if (this.ready) {
marker.isAdded = false
this.repaint()
}
})
}
marker.isAdded = false
this.markers.push(marker)
}
removeMarker_(marker: MarkerExtended): boolean {
let index = -1
if (this.markers.indexOf) {
index = this.markers.indexOf(marker)
} else {
for (let i = 0; i < this.markers.length; i++) {
if (marker === this.markers[i]) {
index = i
break
}
}
}
if (index === -1) {
// Marker is not in our list of markers, so do nothing:
return false
}
marker.setMap(null)
this.markers.splice(index, 1) // Remove the marker from the list of managed markers
return true
}
removeMarker(marker: MarkerExtended, optNoDraw: boolean): boolean {
const removed = this.removeMarker_(marker)
if (!optNoDraw && removed) {
this.repaint()
}
return removed
}
removeMarkers(markers: MarkerExtended[], optNoDraw: boolean): boolean {
let removed = false
for (const marker of markers) {
removed = removed || this.removeMarker_(marker)
}
if (!optNoDraw && removed) {
this.repaint()
}
return removed
}
clearMarkers() {
this.resetViewport(true)
this.markers = []
}
repaint() {
const oldClusters = this.clusters.slice()
this.clusters = []
this.resetViewport(false)
this.redraw()
// Remove the old clusters.
// Do it in a timeout to prevent blinking effect.
setTimeout(function timeout() {
for (const oldCluster of oldClusters) {
oldCluster.remove()
}
}, 0)
}
getExtendedBounds(bounds: google.maps.LatLngBounds): google.maps.LatLngBounds {
const projection = (this as unknown as google.maps.OverlayView).getProjection()
// Convert the points to pixels and the extend out by the grid size.
const trPix = projection.fromLatLngToDivPixel(
// Turn the bounds into latlng.
new google.maps.LatLng(bounds.getNorthEast().lat(), bounds.getNorthEast().lng())
)
if (trPix !== null) {
trPix.x += this.gridSize
trPix.y -= this.gridSize
}
const blPix = projection.fromLatLngToDivPixel(
// Turn the bounds into latlng.
new google.maps.LatLng(bounds.getSouthWest().lat(), bounds.getSouthWest().lng())
)
if (blPix !== null) {
blPix.x -= this.gridSize
blPix.y += this.gridSize
}
// Extend the bounds to contain the new bounds.
if (trPix !== null) {
// Convert the pixel points back to LatLng nw
const point1 = projection.fromDivPixelToLatLng(trPix)
if (point1 !== null) {
bounds.extend(point1)
}
}
if (blPix !== null) {
// Convert the pixel points back to LatLng sw
const point2 = projection.fromDivPixelToLatLng(blPix)
if (point2 !== null) {
bounds.extend(
point2
)
}
}
return bounds
}
redraw() {
// Redraws all the clusters.
this.createClusters(0)
}
resetViewport(optHide: boolean) {
// Remove all the clusters
for (const cluster of this.clusters) {
cluster.remove()
}
this.clusters = []
// Reset the markers to not be added and to be removed from the map.
for (const marker of this.markers) {
marker.isAdded = false
if (optHide) {
marker.setMap(null)
}
}
}
distanceBetweenPoints(p1: google.maps.LatLng, p2: google.maps.LatLng): number {
const R = 6371 // Radius of the Earth in km
const dLat = ((p2.lat() - p1.lat()) * Math.PI) / 180
const dLon = ((p2.lng() - p1.lng()) * Math.PI) / 180
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((p1.lat() * Math.PI) / 180) *
Math.cos((p2.lat() * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2)
return R * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)))
}
isMarkerInBounds(marker: MarkerExtended, bounds: google.maps.LatLngBounds): boolean {
const position = marker.getPosition()
if (position) {
return bounds.contains(position)
}
return false
}
addToClosestCluster(marker: MarkerExtended) {
let cluster
let distance = 40000 // Some large number
let clusterToAddTo = null
for (const clusterElement of this.clusters) {
cluster = clusterElement
const center = cluster.getCenter()
const position = marker.getPosition()
if (center && position) {
const d = this.distanceBetweenPoints(center, position)
if (d < distance) {
distance = d
clusterToAddTo = cluster
}
}
}
if (clusterToAddTo && clusterToAddTo.isMarkerInClusterBounds(marker)) {
clusterToAddTo.addMarker(marker)
} else {
cluster = new Cluster(this)
cluster.addMarker(marker)
this.clusters.push(cluster)
}
}
createClusters(iFirst: number) {
if (!this.ready) {
return
}
// Cancel previous batch processing if we're working on the first batch:
if (iFirst === 0) {
/**
* This event is fired when the Clusterer begins
* clustering markers.
* @name Clusterer#clusteringbegin
* @param {Clusterer} mc The Clusterer whose markers are being clustered.
* @event
*/
google.maps.event.trigger(this, 'clusteringbegin', this)
if (this.timerRefStatic !== null) {
window.clearTimeout(this.timerRefStatic)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete this.timerRefStatic
}
}
const map = (this as unknown as google.maps.OverlayView).getMap()
const bounds = map !== null && 'getBounds' in map ? map.getBounds() : null
const zoom = map?.getZoom() || 0
// Get our current map view bounds.
// Create a new bounds object so we don't affect the map.
//
// See Comments 9 & 11 on Issue 3651 relating to this workaround for a Google Maps bug:
const mapBounds = zoom > 3
? new google.maps.LatLngBounds(
bounds?.getSouthWest(),
bounds?.getNorthEast()
)
: new google.maps.LatLngBounds(
new google.maps.LatLng(85.02070771743472, -178.48388434375),
new google.maps.LatLng(-85.08136444384544, 178.00048865625)
)
const extendedMapBounds = this.getExtendedBounds(mapBounds)
const iLast = Math.min(iFirst + this.batchSize, this.markers.length)
for (let i = iFirst; i < iLast; i++) {
const marker = this.markers[i]
if (marker && !marker.isAdded && this.isMarkerInBounds(marker, extendedMapBounds) && (!this.ignoreHidden || (this.ignoreHidden && marker.getVisible()))) {
this.addToClosestCluster(marker)
}
}
if (iLast < this.markers.length) {
this.timerRefStatic = window.setTimeout(
() => {
this.createClusters(iLast)
},
0
)
} else {
this.timerRefStatic = null
/**
* This event is fired when the Clusterer stops
* clustering markers.
* @name Clusterer#clusteringend
* @param {Clusterer} mc The Clusterer whose markers are being clustered.
* @event
*/
google.maps.event.trigger(this, 'clusteringend', this)
for (const cluster of this.clusters) {
cluster.updateIcon()
}
}
}
extend(obj1: A, obj2: typeof google.maps.OverlayView): A {
return function applyExtend(this: A, object: typeof google.maps.OverlayView): A {
for (const property in object.prototype) {
// eslint-disable-next-line @typescript-eslint/ban-types
const prop = property as keyof google.maps.OverlayView & (string & {})
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.prototype[prop] = object.prototype[prop]
}
return this
}.apply(obj1, [obj2])
}
}