@react-google-maps/marker-clusterer 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,727 @@
1
+ /* global google */
2
+ /* eslint-disable filenames/match-regex */
3
+ import { Cluster } from './Cluster'
4
+
5
+ import {
6
+ MarkerExtended,
7
+ ClustererOptions,
8
+ ClusterIconStyle,
9
+ TCalculator,
10
+ ClusterIconInfo,
11
+ } from './types'
12
+
13
+ /**
14
+ * Supports up to 9007199254740991 (Number.MAX_SAFE_INTEGER) markers
15
+ * which is not a problem as max array length is 4294967296 (2**32)
16
+ */
17
+ const CALCULATOR = function CALCULATOR(
18
+ markers: MarkerExtended[],
19
+ numStyles: number
20
+ ): ClusterIconInfo {
21
+ const count = markers.length
22
+
23
+ const numberOfDigits = count.toString().length
24
+
25
+ const index = Math.min(numberOfDigits, numStyles)
26
+
27
+ return {
28
+ text: count.toString(),
29
+ index: index,
30
+ title: '',
31
+ }
32
+ }
33
+
34
+ const BATCH_SIZE = 2000
35
+
36
+ const BATCH_SIZE_IE = 500
37
+
38
+ const IMAGE_PATH =
39
+ 'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m'
40
+
41
+ const IMAGE_EXTENSION = 'png'
42
+
43
+ const IMAGE_SIZES = [53, 56, 66, 78, 90]
44
+
45
+ const CLUSTERER_CLASS = 'cluster'
46
+
47
+ export class Clusterer {
48
+ markers: MarkerExtended[]
49
+ clusters: Cluster[]
50
+ listeners: google.maps.MapsEventListener[]
51
+ activeMap: google.maps.Map | google.maps.StreetViewPanorama | null
52
+ ready: boolean
53
+ gridSize: number
54
+ minClusterSize: number
55
+ maxZoom: number | null
56
+ styles: ClusterIconStyle[]
57
+ title: string
58
+ zoomOnClick: boolean
59
+ averageCenter: boolean
60
+ ignoreHidden: boolean
61
+ enableRetinaIcons: boolean
62
+ imagePath: string
63
+ imageExtension: string
64
+ imageSizes: number[]
65
+ calculator: TCalculator
66
+ batchSize: number
67
+ batchSizeIE: number
68
+ clusterClass: string
69
+ timerRefStatic: number | null
70
+
71
+ constructor(
72
+ map: google.maps.Map,
73
+ optMarkers: MarkerExtended[] = [],
74
+ optOptions: ClustererOptions = {}
75
+ ) {
76
+ this.extend(Clusterer, google.maps.OverlayView)
77
+
78
+ this.markers = []
79
+ this.clusters = []
80
+ this.listeners = []
81
+ this.activeMap = null
82
+ this.ready = false
83
+ this.gridSize = optOptions.gridSize || 60
84
+ this.minClusterSize = optOptions.minimumClusterSize || 2
85
+ this.maxZoom = optOptions.maxZoom || null
86
+ this.styles = optOptions.styles || []
87
+
88
+ this.title = optOptions.title || ''
89
+
90
+ this.zoomOnClick = true
91
+
92
+ if (optOptions.zoomOnClick !== undefined) {
93
+ this.zoomOnClick = optOptions.zoomOnClick
94
+ }
95
+
96
+ this.averageCenter = false
97
+
98
+ if (optOptions.averageCenter !== undefined) {
99
+ this.averageCenter = optOptions.averageCenter
100
+ }
101
+
102
+ this.ignoreHidden = false
103
+
104
+ if (optOptions.ignoreHidden !== undefined) {
105
+ this.ignoreHidden = optOptions.ignoreHidden
106
+ }
107
+
108
+ this.enableRetinaIcons = false
109
+
110
+ if (optOptions.enableRetinaIcons !== undefined) {
111
+ this.enableRetinaIcons = optOptions.enableRetinaIcons
112
+ }
113
+ this.imagePath = optOptions.imagePath || IMAGE_PATH
114
+
115
+ this.imageExtension = optOptions.imageExtension || IMAGE_EXTENSION
116
+
117
+ this.imageSizes = optOptions.imageSizes || IMAGE_SIZES
118
+
119
+ this.calculator = optOptions.calculator || CALCULATOR
120
+
121
+ this.batchSize = optOptions.batchSize || BATCH_SIZE
122
+
123
+ this.batchSizeIE = optOptions.batchSizeIE || BATCH_SIZE_IE
124
+
125
+ this.clusterClass = optOptions.clusterClass || CLUSTERER_CLASS
126
+
127
+ if (navigator.userAgent.toLowerCase().indexOf('msie') !== -1) {
128
+ // Try to avoid IE timeout when processing a huge number of markers:
129
+ this.batchSize = this.batchSizeIE
130
+ }
131
+
132
+ this.timerRefStatic = null
133
+
134
+ this.setupStyles()
135
+
136
+ this.addMarkers(optMarkers, true)
137
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
138
+ // @ts-ignore
139
+ this.setMap(map) // Note: this causes onAdd to be called
140
+ }
141
+
142
+ onAdd() {
143
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
144
+ // @ts-ignore
145
+ this.activeMap = this.getMap()
146
+
147
+ this.ready = true
148
+
149
+ this.repaint()
150
+
151
+ // Add the map event listeners
152
+ this.listeners = [
153
+ google.maps.event.addListener(
154
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
155
+ // @ts-ignore
156
+ this.getMap(),
157
+ 'zoom_changed',
158
+ // eslint-disable-next-line @getify/proper-arrows/this, @getify/proper-arrows/name
159
+ () => {
160
+ this.resetViewport(false)
161
+ // Workaround for this Google bug: when map is at level 0 and "-" of
162
+ // zoom slider is clicked, a "zoom_changed" event is fired even though
163
+ // the map doesn't zoom out any further. In this situation, no "idle"
164
+ // event is triggered so the cluster markers that have been removed
165
+ // do not get redrawn. Same goes for a zoom in at maxZoom.
166
+ if (
167
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
168
+ // @ts-ignore
169
+ this.getMap().getZoom() === (this.get('minZoom') || 0) ||
170
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
171
+ // @ts-ignore
172
+ this.getMap().getZoom() === this.get('maxZoom')
173
+ ) {
174
+ google.maps.event.trigger(this, 'idle')
175
+ }
176
+ }
177
+ ),
178
+ google.maps.event.addListener(
179
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
180
+ // @ts-ignore
181
+ this.getMap(),
182
+ 'idle',
183
+ // eslint-disable-next-line @getify/proper-arrows/this, @getify/proper-arrows/name
184
+ () => {
185
+ this.redraw()
186
+ }
187
+ ),
188
+ ]
189
+ }
190
+
191
+ // eslint-disable-next-line @getify/proper-arrows/this
192
+ onRemove() {
193
+ // Put all the managed markers back on the map:
194
+ for (let i = 0; i < this.markers.length; i++) {
195
+ if (this.markers[i].getMap() !== this.activeMap) {
196
+ this.markers[i].setMap(this.activeMap)
197
+ }
198
+ }
199
+
200
+ // Remove all clusters:
201
+ for (let i = 0; i < this.clusters.length; i++) {
202
+ this.clusters[i].remove()
203
+ }
204
+
205
+ this.clusters = []
206
+
207
+ // Remove map event listeners:
208
+ for (let i = 0; i < this.listeners.length; i++) {
209
+ google.maps.event.removeListener(this.listeners[i])
210
+ }
211
+
212
+ this.listeners = []
213
+
214
+ this.activeMap = null
215
+
216
+ this.ready = false
217
+ }
218
+
219
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
220
+ draw() {}
221
+
222
+ setupStyles() {
223
+ if (this.styles.length > 0) {
224
+ return
225
+ }
226
+
227
+ for (let i = 0; i < this.imageSizes.length; i++) {
228
+ this.styles.push({
229
+ url: this.imagePath + (i + 1) + '.' + this.imageExtension,
230
+ height: this.imageSizes[i],
231
+ width: this.imageSizes[i],
232
+ })
233
+ }
234
+ }
235
+
236
+ fitMapToMarkers() {
237
+ const markers = this.getMarkers()
238
+
239
+ const bounds = new google.maps.LatLngBounds()
240
+
241
+ for (let i = 0; i < markers.length; i++) {
242
+ const position = markers[i].getPosition()
243
+ if (position) {
244
+ bounds.extend(position)
245
+ }
246
+ }
247
+
248
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
249
+ // @ts-ignore
250
+ this.getMap().fitBounds(bounds)
251
+ }
252
+
253
+ getGridSize(): number {
254
+ return this.gridSize
255
+ }
256
+
257
+ setGridSize(gridSize: number) {
258
+ this.gridSize = gridSize
259
+ }
260
+
261
+ getMinimumClusterSize(): number {
262
+ return this.minClusterSize
263
+ }
264
+
265
+ setMinimumClusterSize(minimumClusterSize: number) {
266
+ this.minClusterSize = minimumClusterSize
267
+ }
268
+
269
+ getMaxZoom(): number | null {
270
+ return this.maxZoom
271
+ }
272
+
273
+ setMaxZoom(maxZoom: number) {
274
+ this.maxZoom = maxZoom
275
+ }
276
+
277
+ getStyles(): ClusterIconStyle[] {
278
+ return this.styles
279
+ }
280
+
281
+ setStyles(styles: ClusterIconStyle[]) {
282
+ this.styles = styles
283
+ }
284
+
285
+ getTitle(): string {
286
+ return this.title
287
+ }
288
+
289
+ setTitle(title: string) {
290
+ this.title = title
291
+ }
292
+
293
+ getZoomOnClick(): boolean {
294
+ return this.zoomOnClick
295
+ }
296
+
297
+ setZoomOnClick(zoomOnClick: boolean) {
298
+ this.zoomOnClick = zoomOnClick
299
+ }
300
+
301
+ getAverageCenter(): boolean {
302
+ return this.averageCenter
303
+ }
304
+
305
+ setAverageCenter(averageCenter: boolean) {
306
+ this.averageCenter = averageCenter
307
+ }
308
+
309
+ getIgnoreHidden(): boolean {
310
+ return this.ignoreHidden
311
+ }
312
+
313
+ setIgnoreHidden(ignoreHidden: boolean) {
314
+ this.ignoreHidden = ignoreHidden
315
+ }
316
+
317
+ getEnableRetinaIcons(): boolean {
318
+ return this.enableRetinaIcons
319
+ }
320
+
321
+ setEnableRetinaIcons(enableRetinaIcons: boolean) {
322
+ this.enableRetinaIcons = enableRetinaIcons
323
+ }
324
+
325
+ getImageExtension(): string {
326
+ return this.imageExtension
327
+ }
328
+
329
+ setImageExtension(imageExtension: string) {
330
+ this.imageExtension = imageExtension
331
+ }
332
+
333
+ getImagePath(): string {
334
+ return this.imagePath
335
+ }
336
+
337
+ setImagePath(imagePath: string) {
338
+ this.imagePath = imagePath
339
+ }
340
+
341
+ getImageSizes(): number[] {
342
+ return this.imageSizes
343
+ }
344
+
345
+ setImageSizes(imageSizes: number[]) {
346
+ this.imageSizes = imageSizes
347
+ }
348
+
349
+ getCalculator(): TCalculator {
350
+ return this.calculator
351
+ }
352
+
353
+ setCalculator(calculator: TCalculator) {
354
+ this.calculator = calculator
355
+ }
356
+
357
+ getBatchSizeIE(): number {
358
+ return this.batchSizeIE
359
+ }
360
+
361
+ setBatchSizeIE(batchSizeIE: number) {
362
+ this.batchSizeIE = batchSizeIE
363
+ }
364
+
365
+ getClusterClass(): string {
366
+ return this.clusterClass
367
+ }
368
+
369
+ setClusterClass(clusterClass: string) {
370
+ this.clusterClass = clusterClass
371
+ }
372
+
373
+ getMarkers(): MarkerExtended[] {
374
+ return this.markers
375
+ }
376
+
377
+ getTotalMarkers(): number {
378
+ return this.markers.length
379
+ }
380
+
381
+ getClusters(): Cluster[] {
382
+ return this.clusters
383
+ }
384
+
385
+ getTotalClusters(): number {
386
+ return this.clusters.length
387
+ }
388
+
389
+ addMarker(marker: MarkerExtended, optNoDraw: boolean) {
390
+ this.pushMarkerTo(marker)
391
+
392
+ if (!optNoDraw) {
393
+ this.redraw()
394
+ }
395
+ }
396
+
397
+ addMarkers(markers: MarkerExtended[], optNoDraw: boolean) {
398
+ for (const key in markers) {
399
+ if (markers.hasOwnProperty(key)) {
400
+ this.pushMarkerTo(markers[key])
401
+ }
402
+ }
403
+
404
+ if (!optNoDraw) {
405
+ this.redraw()
406
+ }
407
+ }
408
+
409
+ pushMarkerTo(marker: MarkerExtended) {
410
+ // If the marker is draggable add a listener so we can update the clusters on the dragend:
411
+ if (marker.getDraggable()) {
412
+ // eslint-disable-next-line @getify/proper-arrows/name, @getify/proper-arrows/this
413
+ google.maps.event.addListener(marker, 'dragend', () => {
414
+ if (this.ready) {
415
+ marker.isAdded = false
416
+
417
+ this.repaint()
418
+ }
419
+ })
420
+ }
421
+
422
+ marker.isAdded = false
423
+
424
+ this.markers.push(marker)
425
+ }
426
+
427
+ removeMarker_(marker: MarkerExtended): boolean {
428
+ let index = -1
429
+
430
+ if (this.markers.indexOf) {
431
+ index = this.markers.indexOf(marker)
432
+ } else {
433
+ for (let i = 0; i < this.markers.length; i++) {
434
+ if (marker === this.markers[i]) {
435
+ index = i
436
+
437
+ break
438
+ }
439
+ }
440
+ }
441
+
442
+ if (index === -1) {
443
+ // Marker is not in our list of markers, so do nothing:
444
+ return false
445
+ }
446
+
447
+ marker.setMap(null)
448
+
449
+ this.markers.splice(index, 1) // Remove the marker from the list of managed markers
450
+
451
+ return true
452
+ }
453
+
454
+ removeMarker(marker: MarkerExtended, optNoDraw: boolean): boolean {
455
+ const removed = this.removeMarker_(marker)
456
+
457
+ if (!optNoDraw && removed) {
458
+ this.repaint()
459
+ }
460
+
461
+ return removed
462
+ }
463
+
464
+ removeMarkers(markers: MarkerExtended[], optNoDraw: boolean): boolean {
465
+ let removed = false
466
+
467
+ for (let i = 0; i < markers.length; i++) {
468
+ removed = removed || this.removeMarker_(markers[i])
469
+ }
470
+
471
+ if (!optNoDraw && removed) {
472
+ this.repaint()
473
+ }
474
+
475
+ return removed
476
+ }
477
+
478
+ clearMarkers() {
479
+ this.resetViewport(true)
480
+
481
+ this.markers = []
482
+ }
483
+
484
+ repaint() {
485
+ const oldClusters = this.clusters.slice()
486
+
487
+ this.clusters = []
488
+
489
+ this.resetViewport(false)
490
+
491
+ this.redraw()
492
+
493
+ // Remove the old clusters.
494
+ // Do it in a timeout to prevent blinking effect.
495
+ setTimeout(function timeout() {
496
+ for (let i = 0; i < oldClusters.length; i++) {
497
+ oldClusters[i].remove()
498
+ }
499
+ }, 0)
500
+ }
501
+
502
+ getExtendedBounds(bounds: google.maps.LatLngBounds): google.maps.LatLngBounds {
503
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
504
+ // @ts-ignore
505
+ const projection = this.getProjection()
506
+ // Convert the points to pixels and the extend out by the grid size.
507
+ const trPix = projection.fromLatLngToDivPixel(
508
+ // Turn the bounds into latlng.
509
+ new google.maps.LatLng(bounds.getNorthEast().lat(), bounds.getNorthEast().lng())
510
+ )
511
+
512
+ trPix.x += this.gridSize
513
+ trPix.y -= this.gridSize
514
+
515
+ const blPix = projection.fromLatLngToDivPixel(
516
+ // Turn the bounds into latlng.
517
+ new google.maps.LatLng(bounds.getSouthWest().lat(), bounds.getSouthWest().lng())
518
+ )
519
+
520
+ blPix.x -= this.gridSize
521
+ blPix.y += this.gridSize
522
+
523
+ // Extend the bounds to contain the new bounds.
524
+ bounds.extend(
525
+ // Convert the pixel points back to LatLng nw
526
+ projection.fromDivPixelToLatLng(trPix)
527
+ )
528
+
529
+ bounds.extend(
530
+ // Convert the pixel points back to LatLng sw
531
+ projection.fromDivPixelToLatLng(blPix)
532
+ )
533
+
534
+ return bounds
535
+ }
536
+
537
+ redraw() {
538
+ // Redraws all the clusters.
539
+ this.createClusters(0)
540
+ }
541
+
542
+ resetViewport(optHide: boolean) {
543
+ // Remove all the clusters
544
+ for (let i = 0; i < this.clusters.length; i++) {
545
+ this.clusters[i].remove()
546
+ }
547
+
548
+ this.clusters = []
549
+
550
+ // Reset the markers to not be added and to be removed from the map.
551
+ for (let i = 0; i < this.markers.length; i++) {
552
+ const marker = this.markers[i]
553
+
554
+ marker.isAdded = false
555
+
556
+ if (optHide) {
557
+ marker.setMap(null)
558
+ }
559
+ }
560
+ }
561
+
562
+ distanceBetweenPoints(p1: google.maps.LatLng, p2: google.maps.LatLng): number {
563
+ const R = 6371 // Radius of the Earth in km
564
+
565
+ const dLat = ((p2.lat() - p1.lat()) * Math.PI) / 180
566
+ const dLon = ((p2.lng() - p1.lng()) * Math.PI) / 180
567
+
568
+ const a =
569
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
570
+ Math.cos((p1.lat() * Math.PI) / 180) *
571
+ Math.cos((p2.lat() * Math.PI) / 180) *
572
+ Math.sin(dLon / 2) *
573
+ Math.sin(dLon / 2)
574
+
575
+ return R * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)))
576
+ }
577
+
578
+ isMarkerInBounds(marker: MarkerExtended, bounds: google.maps.LatLngBounds): boolean {
579
+ const position = marker.getPosition()
580
+
581
+ if (position) {
582
+ return bounds.contains(position)
583
+ }
584
+
585
+ return false
586
+ }
587
+
588
+ addToClosestCluster(marker: MarkerExtended) {
589
+ let cluster
590
+
591
+ let distance = 40000 // Some large number
592
+
593
+ let clusterToAddTo = null
594
+
595
+ for (let i = 0; i < this.clusters.length; i++) {
596
+ cluster = this.clusters[i]
597
+
598
+ const center = cluster.getCenter()
599
+
600
+ const position = marker.getPosition()
601
+
602
+ if (center && position) {
603
+ const d = this.distanceBetweenPoints(center, position)
604
+
605
+ if (d < distance) {
606
+ distance = d
607
+
608
+ clusterToAddTo = cluster
609
+ }
610
+ }
611
+ }
612
+
613
+ if (clusterToAddTo && clusterToAddTo.isMarkerInClusterBounds(marker)) {
614
+ clusterToAddTo.addMarker(marker)
615
+ } else {
616
+ cluster = new Cluster(this)
617
+
618
+ cluster.addMarker(marker)
619
+
620
+ this.clusters.push(cluster)
621
+ }
622
+ }
623
+
624
+ createClusters(iFirst: number) {
625
+ if (!this.ready) {
626
+ return
627
+ }
628
+
629
+ // Cancel previous batch processing if we're working on the first batch:
630
+ if (iFirst === 0) {
631
+ /**
632
+ * This event is fired when the <code>Clusterer</code> begins
633
+ * clustering markers.
634
+ * @name Clusterer#clusteringbegin
635
+ * @param {Clusterer} mc The Clusterer whose markers are being clustered.
636
+ * @event
637
+ */
638
+ google.maps.event.trigger(this, 'clusteringbegin', this)
639
+
640
+ if (this.timerRefStatic !== null) {
641
+ window.clearTimeout(this.timerRefStatic)
642
+
643
+ // @ts-ignore
644
+ delete this.timerRefStatic
645
+ }
646
+ }
647
+
648
+ // Get our current map view bounds.
649
+ // Create a new bounds object so we don't affect the map.
650
+ //
651
+ // See Comments 9 & 11 on Issue 3651 relating to this workaround for a Google Maps bug:
652
+ const mapBounds =
653
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
654
+ // @ts-ignore
655
+ this.getMap().getZoom() > 3
656
+ ? new google.maps.LatLngBounds(
657
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
658
+ // @ts-ignore
659
+ this.getMap()
660
+ .getBounds()
661
+ .getSouthWest(),
662
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
663
+ // @ts-ignore
664
+ this.getMap()
665
+ .getBounds()
666
+ .getNorthEast()
667
+ )
668
+ : new google.maps.LatLngBounds(
669
+ new google.maps.LatLng(85.02070771743472, -178.48388434375),
670
+ new google.maps.LatLng(-85.08136444384544, 178.00048865625)
671
+ )
672
+
673
+ const bounds = this.getExtendedBounds(mapBounds)
674
+
675
+ const iLast = Math.min(iFirst + this.batchSize, this.markers.length)
676
+
677
+ for (let i = iFirst; i < iLast; i++) {
678
+ const marker = this.markers[i]
679
+
680
+ if (!marker.isAdded && this.isMarkerInBounds(marker, bounds)) {
681
+ if (!this.ignoreHidden || (this.ignoreHidden && marker.getVisible())) {
682
+ this.addToClosestCluster(marker)
683
+ }
684
+ }
685
+ }
686
+
687
+ if (iLast < this.markers.length) {
688
+ this.timerRefStatic = window.setTimeout(
689
+ // eslint-disable-next-line @getify/proper-arrows/this, @getify/proper-arrows/name
690
+ () => {
691
+ this.createClusters(iLast)
692
+ },
693
+ 0
694
+ )
695
+ } else {
696
+ this.timerRefStatic = null
697
+
698
+ /**
699
+ * This event is fired when the <code>Clusterer</code> stops
700
+ * clustering markers.
701
+ * @name Clusterer#clusteringend
702
+ * @param {Clusterer} mc The Clusterer whose markers are being clustered.
703
+ * @event
704
+ */
705
+ google.maps.event.trigger(this, 'clusteringend', this)
706
+
707
+ for (let i = 0; i < this.clusters.length; i++) {
708
+ this.clusters[i].updateIcon()
709
+ }
710
+ }
711
+ }
712
+
713
+ extend(obj1: any, obj2: any): any {
714
+ return function applyExtend(object: any) {
715
+ // eslint-disable-next-line guard-for-in
716
+ for (const property in object.prototype) {
717
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
718
+ // @ts-ignore
719
+ this.prototype[property] = object.prototype[property]
720
+ }
721
+
722
+ // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
723
+ // @ts-ignore
724
+ return this
725
+ }.apply(obj1, [obj2])
726
+ }
727
+ }