/* 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]) } }