├── .gitignore ├── CODE_OF_CONDUCT.md ├── Gruntfile.js ├── LICENSE ├── LeafletAdapter.ts ├── LeafletSpiderfier.ts ├── LeafletStyleSheet.css ├── PruneCluster.ts ├── README.md ├── bower.json ├── dist ├── LeafletStyleSheet.css ├── PruneCluster.d.ts └── PruneCluster.js ├── examples ├── airplane.png ├── bug-107.html ├── bug-11.html ├── bug-18.html ├── bug-23.html ├── bug-87.html ├── examples.css ├── helicopter.png ├── json example │ ├── diwalipretty.json.zip │ ├── heatmap.html │ └── instructions.rtf ├── moving.1.html ├── moving.10.html ├── random.100-icons.html ├── random.100-popup.html ├── random.100.html ├── random.1000.html ├── random.10000-categories-2.html ├── random.10000-categories.html ├── random.10000-control.html ├── random.10000-delete.html ├── random.10000-filtering.html ├── random.10000-fractionalzoom.html ├── random.10000-getclustermarkers.html ├── random.10000-reseticons.html ├── random.10000-size.html ├── random.10000-spiderfier.html ├── random.10000.html ├── random.1000000.html ├── random.150000.html ├── random.60000.html ├── realworld.50000-categories.html ├── realworld.50000.1.js ├── realworld.50000.2.js └── realworld.50000.html ├── meteor ├── README.md ├── package.js └── test.js ├── package-lock.json ├── package.json └── tsd.json /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | node_modules/ 3 | .tscache/ 4 | .project 5 | .settings/ 6 | typings 7 | obj/ 8 | build/ 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at @yellowiscool. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (grunt) { 4 | require('load-grunt-tasks')(grunt); 5 | require('time-grunt')(grunt); 6 | grunt.initConfig({ 7 | clean: { 8 | dist: { 9 | files: [{ 10 | src: ["dist/*.js", "dist/*.d.ts", "dist/*.css"] 11 | }], 12 | }, 13 | dev: { 14 | files: [{ 15 | src: ["build/*.js", "build/*.d.ts", "build/*.map", "build/*.css"] 16 | }], 17 | } 18 | }, 19 | ts: { 20 | options: { 21 | target: 'es5', 22 | module: 'amd', 23 | declaration: true, 24 | }, 25 | dist: { 26 | src: ["PruneCluster.ts", "LeafletAdapter.ts", "LeafletSpiderfier.ts"], 27 | out: './dist/PruneCluster.js', 28 | options: { 29 | sourceMap: false 30 | } 31 | }, 32 | dev: { 33 | src: ["PruneCluster.ts", "LeafletAdapter.ts", "LeafletSpiderfier.ts"], 34 | out: './build/PruneCluster.js', 35 | options: { 36 | sourceMap: true 37 | } 38 | } 39 | }, 40 | uglify: { 41 | ts: { 42 | options: { 43 | sourceMap: false 44 | }, 45 | files: { 46 | 'dist/PruneCluster.min.js': ['dist/PruneCluster.js'] 47 | } 48 | } 49 | }, 50 | copy: { 51 | dist: { 52 | src: 'LeafletStyleSheet.css', 53 | dest: 'dist/LeafletStyleSheet.css' 54 | }, 55 | dev: { 56 | src: 'LeafletStyleSheet.css', 57 | dest: 'build/LeafletStyleSheet.css' 58 | } 59 | }, 60 | exec: { 61 | 'meteor-init': { 62 | command: [ 63 | //Make sure Meteor is installed, per https://meteor.com/install. 64 | // he curl'ed script is safe; takes 2 minutes to read source & check. 65 | 'type meteor >/dev/null 2>&1 || { curl https://install.meteor.com/ | sh; }', 66 | //Meteor expects package.js to be in the root directory 67 | //of the checkout, so copy it there temporarily 68 | 'cp meteor/package.js .' 69 | ].join(';') 70 | }, 71 | 'meteor-cleanup': { 72 | //remove build files and package.js 73 | command: 'rm -rf .build.* versions.json package.js' 74 | }, 75 | 'meteor-test': { 76 | command: 'node_modules/.bin/spacejam --mongo-url mongodb:// test-packages ./' 77 | }, 78 | 'meteor-publish': { 79 | command: 'meteor publish' 80 | } 81 | 82 | } 83 | }); 84 | 85 | grunt.registerTask('build:dist', [ 86 | 'clean:dist', 87 | 'copy:dist', 88 | 'ts:dist', 89 | 'uglify' 90 | ]); 91 | 92 | grunt.registerTask('build:dev', [ 93 | 'clean:dev', 94 | 'copy:dev', 95 | 'ts:dev' 96 | ]) 97 | 98 | grunt.registerTask('build', ['build:dev']); 99 | grunt.registerTask('default', ['build:dev']); 100 | 101 | // Meteor tasks 102 | grunt.registerTask('meteor-test', ['exec:meteor-init', 'exec:meteor-test', 'exec:meteor-cleanup']); 103 | grunt.registerTask('meteor-publish', ['exec:meteor-init', 'exec:meteor-publish', 'exec:meteor-cleanup']); 104 | grunt.registerTask('meteor', ['exec:meteor-init', 'exec:meteor-test', 'exec:meteor-publish', 'exec:meteor-cleanup']); 105 | }; 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 SINTEF-9012 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /LeafletAdapter.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | namespace PruneCluster { 4 | export declare class LeafletAdapter implements L.ILayer { 5 | Cluster: PruneCluster.PruneCluster; 6 | 7 | onAdd: (map: L.Map) => void; 8 | onRemove: (map: L.Map) => void; 9 | 10 | RegisterMarker: (marker: Marker) => void; 11 | RegisterMarkers: (markers: Marker[]) => void; 12 | RemoveMarkers: (markers: Marker[]) => void; 13 | ProcessView: () => void; 14 | FitBounds: (withFiltered?: boolean) => void; 15 | GetMarkers: () => Marker[]; 16 | RedrawIcons: (processView?: boolean) => void; 17 | 18 | BuildLeafletCluster: (cluster: Cluster, position: L.LatLng) => L.ILayer; 19 | BuildLeafletClusterIcon: (cluster: Cluster) => L.Icon; 20 | BuildLeafletMarker: (marker: Marker, position: L.LatLng) => L.Marker; 21 | PrepareLeafletMarker: (marker: L.Marker, data: {}, category: number) => void; 22 | } 23 | 24 | // The adapter store these properties inside L.Marker objects 25 | export interface LeafletMarker extends L.Marker { 26 | _population?: number; 27 | _hashCode?: number; 28 | _zoomLevel?: number; 29 | _removeFromMap?: boolean; 30 | } 31 | 32 | // What is inside cluster.data objects 33 | export interface ILeafletAdapterData { 34 | _leafletMarker?: LeafletMarker; 35 | _leafletCollision?: boolean; 36 | _leafletOldPopulation?: number; 37 | _leafletOldHashCode?: number; 38 | _leafletPosition?: L.LatLng; 39 | } 40 | } 41 | 42 | 43 | var PruneClusterForLeaflet = ((L).Layer ? (L).Layer : L.Class).extend({ 44 | 45 | initialize: function(size: number = 120, clusterMargin: number = 20) { 46 | this.Cluster = new PruneCluster.PruneCluster(); 47 | this.Cluster.Size = size; 48 | this.clusterMargin = Math.min(clusterMargin, size / 4); 49 | 50 | // Bind the Leaflet project and unproject methods to the cluster 51 | this.Cluster.Project = (lat: number, lng: number) => 52 | this._map.project(new L.LatLng(lat, lng), Math.floor(this._map.getZoom())); 53 | 54 | this.Cluster.UnProject = (x: number, y: number) => 55 | this._map.unproject(new L.Point(x, y), Math.floor(this._map.getZoom())); 56 | 57 | this._objectsOnMap = []; 58 | 59 | // Enable the spiderfier 60 | this.spiderfier = new PruneClusterLeafletSpiderfier(this); 61 | 62 | this._hardMove = false; 63 | this._resetIcons = false; 64 | 65 | this._removeTimeoutId = 0; 66 | this._markersRemoveListTimeout = []; 67 | }, 68 | 69 | RegisterMarker: function(marker: PruneCluster.Marker) { 70 | this.Cluster.RegisterMarker(marker); 71 | }, 72 | 73 | RegisterMarkers: function(markers: PruneCluster.Marker[]) { 74 | this.Cluster.RegisterMarkers(markers); 75 | }, 76 | 77 | RemoveMarkers: function(markers: PruneCluster.Marker[]) { 78 | this.Cluster.RemoveMarkers(markers); 79 | }, 80 | 81 | BuildLeafletCluster: function(cluster: PruneCluster.Cluster, position: L.LatLng): L.ILayer { 82 | var m = new L.Marker(position, { 83 | icon: this.BuildLeafletClusterIcon(cluster) 84 | }); 85 | 86 | (m)._leafletClusterBounds = cluster.bounds; 87 | 88 | m.on('click',() => { 89 | var cbounds = (m)._leafletClusterBounds; 90 | 91 | // Compute the cluster bounds (it's slow : O(n)) 92 | var markersArea: PruneCluster.Marker[] = this.Cluster.FindMarkersInArea(cbounds); 93 | 94 | var b = this.Cluster.ComputeBounds(markersArea); 95 | 96 | if (b) { 97 | 98 | var bounds = new L.LatLngBounds( 99 | new L.LatLng(b.minLat, b.maxLng), 100 | new L.LatLng(b.maxLat, b.minLng)); 101 | 102 | var zoomLevelBefore = this._map.getZoom(), 103 | zoomLevelAfter = this._map.getBoundsZoom(bounds, false, new L.Point(20, 20)); 104 | 105 | // If the zoom level doesn't change 106 | if (zoomLevelAfter === zoomLevelBefore) { 107 | 108 | // We need to filter the markers because the may be contained 109 | // by other clusters on the map (in case of a cluster merge) 110 | var filteredBounds: PruneCluster.Bounds[] = []; 111 | 112 | // The first step is identifying the clusters in the map that are inside the bounds 113 | for (var i = 0, l = this._objectsOnMap.length; i < l; ++i) { 114 | var o = this._objectsOnMap[i]; 115 | 116 | if (o.data._leafletMarker !== m) { 117 | if (o.bounds.minLat >= cbounds.minLat && 118 | o.bounds.maxLat <= cbounds.maxLat && 119 | o.bounds.minLng >= cbounds.minLng && 120 | o.bounds.maxLng <= cbounds.maxLng) { 121 | filteredBounds.push(o.bounds); 122 | } 123 | } 124 | } 125 | 126 | // Filter the markers 127 | if (filteredBounds.length > 0) { 128 | var newMarkersArea = []; 129 | var ll = filteredBounds.length; 130 | for (i = 0, l = markersArea.length; i < l; ++i) { 131 | var markerPos = markersArea[i].position; 132 | var isFiltered = false; 133 | for (var j = 0; j < ll; ++j) { 134 | var currentFilteredBounds = filteredBounds[j]; 135 | if (markerPos.lat >= currentFilteredBounds.minLat && 136 | markerPos.lat <= currentFilteredBounds.maxLat && 137 | markerPos.lng >= currentFilteredBounds.minLng && 138 | markerPos.lng <= currentFilteredBounds.maxLng) { 139 | isFiltered = true; 140 | break; 141 | } 142 | } 143 | if (!isFiltered) { 144 | newMarkersArea.push(markersArea[i]); 145 | } 146 | } 147 | markersArea = newMarkersArea; 148 | } 149 | 150 | // TODO use an option registered somewhere 151 | if (markersArea.length < 200 || zoomLevelAfter >= this._map.getMaxZoom()) { 152 | 153 | // Send an event for the LeafletSpiderfier 154 | this._map.fire('overlappingmarkers', { 155 | cluster: this, 156 | markers: markersArea, 157 | center: m.getLatLng(), 158 | marker: m 159 | }); 160 | 161 | } else { 162 | zoomLevelAfter++; 163 | } 164 | 165 | this._map.setView(m.getLatLng(), zoomLevelAfter); 166 | } else { 167 | this._map.fitBounds(bounds); 168 | } 169 | 170 | } 171 | }); 172 | 173 | return m; 174 | }, 175 | 176 | BuildLeafletClusterIcon: function(cluster: PruneCluster.Cluster): L.Icon { 177 | var c = 'prunecluster prunecluster-'; 178 | var iconSize = 38; 179 | var maxPopulation = this.Cluster.GetPopulation(); 180 | 181 | if (cluster.population < Math.max(10, maxPopulation*0.01)) { 182 | c += 'small'; 183 | } else if (cluster.population < Math.max(100, maxPopulation * 0.05)) { 184 | c += 'medium'; 185 | iconSize = 40; 186 | } else { 187 | c += 'large'; 188 | iconSize = 44; 189 | } 190 | 191 | return new L.DivIcon({ 192 | html: "
" + cluster.population + "
", 193 | className: c, 194 | iconSize: L.point(iconSize, iconSize) 195 | }); 196 | }, 197 | 198 | BuildLeafletMarker: function (marker: PruneCluster.Marker, position: L.LatLng): L.Marker { 199 | var m = new L.Marker(position); 200 | this.PrepareLeafletMarker(m, marker.data, marker.category); 201 | return m; 202 | }, 203 | 204 | PrepareLeafletMarker: (marker: L.Marker, data: any, category: number) => { 205 | if (data.icon) { 206 | if (typeof data.icon === 'function') { 207 | marker.setIcon(data.icon(data, category)); 208 | } else { 209 | marker.setIcon(data.icon); 210 | } 211 | } 212 | 213 | if (data.popup) { 214 | var content = typeof data.popup === 'function' ? data.popup(data, category) : data.popup; 215 | if (marker.getPopup()) { 216 | marker.setPopupContent(content, data.popupOptions); 217 | } else { 218 | marker.bindPopup(content, data.popupOptions); 219 | } 220 | } 221 | }, 222 | 223 | onAdd: function(map: L.Map) { 224 | this._map = map; 225 | map.on('movestart', this._moveStart, this); 226 | map.on('moveend', this._moveEnd, this); 227 | map.on('zoomend', this._zoomStart, this); 228 | map.on('zoomend', this._zoomEnd, this); 229 | this.ProcessView(); 230 | 231 | map.addLayer(this.spiderfier); 232 | }, 233 | 234 | onRemove: function(map: L.Map) { 235 | 236 | map.off('movestart', this._moveStart, this); 237 | map.off('moveend', this._moveEnd, this); 238 | map.off('zoomend', this._zoomStart, this); 239 | map.off('zoomend', this._zoomEnd, this); 240 | 241 | for (var i = 0, l = this._objectsOnMap.length; i < l; ++i) { 242 | map.removeLayer(this._objectsOnMap[i].data._leafletMarker); 243 | } 244 | 245 | this._objectsOnMap = []; 246 | this.Cluster.ResetClusters(); 247 | 248 | map.removeLayer(this.spiderfier); 249 | 250 | this._map = null; 251 | }, 252 | 253 | _moveStart: function() { 254 | this._moveInProgress = true; 255 | }, 256 | 257 | _moveEnd: function(e) { 258 | this._moveInProgress = false; 259 | this._hardMove = e.hard; 260 | this.ProcessView(); 261 | }, 262 | 263 | _zoomStart: function() { 264 | this._zoomInProgress = true; 265 | }, 266 | 267 | _zoomEnd: function() { 268 | this._zoomInProgress = false; 269 | this.ProcessView(); 270 | }, 271 | 272 | ProcessView: function () { 273 | // Don't do anything during the map manipulation 274 | if (!this._map || this._zoomInProgress || this._moveInProgress) { 275 | return; 276 | } 277 | 278 | var map = this._map, 279 | bounds = map.getBounds(), 280 | zoom = Math.floor(map.getZoom()), 281 | marginRatio = this.clusterMargin / this.Cluster.Size, 282 | resetIcons = this._resetIcons; 283 | 284 | var southWest = bounds.getSouthWest(), 285 | northEast = bounds.getNorthEast(); 286 | 287 | // First step : Compute the clusters 288 | var clusters: PruneCluster.Cluster[] = this.Cluster.ProcessView({ 289 | minLat: southWest.lat, 290 | minLng: southWest.lng, 291 | maxLat: northEast.lat, 292 | maxLng: northEast.lng 293 | }); 294 | 295 | var objectsOnMap: PruneCluster.Cluster[] = this._objectsOnMap, 296 | newObjectsOnMap: PruneCluster.Cluster[] = [], 297 | markersOnMap: PruneCluster.LeafletMarker[] = new Array(objectsOnMap.length); 298 | 299 | // Second step : By default, all the leaflet markers should be removed 300 | for (var i = 0, l = objectsOnMap.length; i < l; ++i) { 301 | var marker = (objectsOnMap[i].data)._leafletMarker; 302 | markersOnMap[i] = marker; 303 | marker._removeFromMap = true; 304 | } 305 | 306 | var clusterCreationList: PruneCluster.Cluster[] = []; 307 | var clusterCreationListPopOne: PruneCluster.Cluster[] = []; 308 | 309 | var opacityUpdateList = []; 310 | 311 | // Third step : anti collapsing system 312 | // => merge collapsing cluster using a sweep and prune algorithm 313 | var workingList: PruneCluster.Cluster[] = []; 314 | 315 | for (i = 0, l = clusters.length; i < l; ++i) { 316 | var icluster = clusters[i], 317 | iclusterData = icluster.data; 318 | 319 | var latMargin = (icluster.bounds.maxLat - icluster.bounds.minLat) * marginRatio, 320 | lngMargin = (icluster.bounds.maxLng - icluster.bounds.minLng) * marginRatio; 321 | 322 | for (var j = 0, ll = workingList.length; j < ll; ++j) { 323 | var c = workingList[j]; 324 | if (c.bounds.maxLng < icluster.bounds.minLng) { 325 | workingList.splice(j, 1); 326 | --j; 327 | --ll; 328 | continue; 329 | } 330 | 331 | var oldMaxLng = c.averagePosition.lng + lngMargin, 332 | oldMinLat = c.averagePosition.lat - latMargin, 333 | oldMaxLat = c.averagePosition.lat + latMargin, 334 | newMinLng = icluster.averagePosition.lng - lngMargin, 335 | newMinLat = icluster.averagePosition.lat - latMargin, 336 | newMaxLat = icluster.averagePosition.lat + latMargin; 337 | 338 | // Collapsing detected 339 | if (oldMaxLng > newMinLng && oldMaxLat > newMinLat && oldMinLat < newMaxLat) { 340 | iclusterData._leafletCollision = true; 341 | c.ApplyCluster(icluster); 342 | break; 343 | } 344 | } 345 | 346 | // If the object is not in collision, we keep it in the process 347 | if (!iclusterData._leafletCollision) { 348 | workingList.push(icluster); 349 | } 350 | 351 | } 352 | 353 | // Fourth step : update the already existing leaflet markers and create 354 | // a list of required new leaflet markers 355 | clusters.forEach((cluster: PruneCluster.Cluster) => { 356 | var m = undefined; 357 | var data = cluster.data; 358 | 359 | // Ignore collapsing clusters detected by the previous step 360 | if (data._leafletCollision) { 361 | // Reset these clusters 362 | data._leafletCollision = false; 363 | data._leafletOldPopulation = 0; 364 | data._leafletOldHashCode = 0; 365 | return; 366 | } 367 | 368 | var position = new L.LatLng(cluster.averagePosition.lat, cluster.averagePosition.lng); 369 | 370 | // If the cluster is already attached to a leaflet marker 371 | var oldMarker = data._leafletMarker; 372 | if (oldMarker) { 373 | 374 | // If it's a single marker and it doesn't have changed 375 | if (cluster.population === 1 && data._leafletOldPopulation === 1 && cluster.hashCode === oldMarker._hashCode) { 376 | // Update if the zoom level has changed or if we need to reset the icon 377 | if (resetIcons || oldMarker._zoomLevel !== zoom || cluster.lastMarker.data.forceIconRedraw) { 378 | this.PrepareLeafletMarker( 379 | oldMarker, 380 | cluster.lastMarker.data, 381 | cluster.lastMarker.category); 382 | if (cluster.lastMarker.data.forceIconRedraw) { 383 | cluster.lastMarker.data.forceIconRedraw = false; 384 | } 385 | } 386 | // Update the position 387 | oldMarker.setLatLng(position); 388 | m = oldMarker; 389 | 390 | // If it's a cluster marker on the same position 391 | } else if (cluster.population > 1 && data._leafletOldPopulation > 1 && (oldMarker._zoomLevel === zoom || 392 | data._leafletPosition.equals(position))) { 393 | 394 | // Update the position 395 | oldMarker.setLatLng(position); 396 | 397 | // Update the icon if the population of his content has changed or if we need to reset the icon 398 | if (resetIcons || cluster.population != data._leafletOldPopulation || 399 | cluster.hashCode !== data._leafletOldHashCode) { 400 | var boundsCopy = {}; 401 | L.Util.extend(boundsCopy, cluster.bounds); 402 | (oldMarker)._leafletClusterBounds = boundsCopy; 403 | oldMarker.setIcon(this.BuildLeafletClusterIcon(cluster)); 404 | } 405 | 406 | data._leafletOldPopulation = cluster.population; 407 | data._leafletOldHashCode = cluster.hashCode; 408 | m = oldMarker; 409 | } 410 | 411 | } 412 | 413 | // If a leaflet marker is unfound, 414 | // register it in the creation waiting list 415 | if (!m) { 416 | // Clusters with a single marker are placed at the beginning 417 | // of the cluster creation list, to recycle them in priority 418 | if (cluster.population === 1) { 419 | clusterCreationListPopOne.push(cluster); 420 | } else { 421 | clusterCreationList.push(cluster); 422 | } 423 | 424 | data._leafletPosition = position; 425 | data._leafletOldPopulation = cluster.population; 426 | data._leafletOldHashCode = cluster.hashCode; 427 | } else { 428 | // The leafet marker is used, we don't need to remove it anymore 429 | m._removeFromMap = false; 430 | newObjectsOnMap.push(cluster); 431 | 432 | // Update the properties 433 | m._zoomLevel = zoom; 434 | m._hashCode = cluster.hashCode; 435 | m._population = cluster.population; 436 | data._leafletMarker = m; 437 | data._leafletPosition = position; 438 | } 439 | 440 | }); 441 | 442 | // Fifth step : recycle leaflet markers using a sweep and prune algorithm 443 | // The purpose of this step is to make smooth transition when a cluster or a marker 444 | // is moving on the map and its grid cell changes 445 | clusterCreationList = clusterCreationListPopOne.concat(clusterCreationList); 446 | 447 | for (i = 0, l = objectsOnMap.length; i < l; ++i) { 448 | icluster = objectsOnMap[i]; 449 | var idata = icluster.data; 450 | marker = idata._leafletMarker; 451 | 452 | // We do not recycle markers already in use 453 | if (idata._leafletMarker._removeFromMap) { 454 | 455 | // If the sweep and prune algorithm doesn't find anything, 456 | // the leaflet marker can't be recycled and it will be removed 457 | var remove = true; 458 | 459 | // Recycle marker only with the same zoom level 460 | if (marker._zoomLevel === zoom) { 461 | var pa = icluster.averagePosition; 462 | 463 | latMargin = (icluster.bounds.maxLat - icluster.bounds.minLat) * marginRatio, 464 | lngMargin = (icluster.bounds.maxLng - icluster.bounds.minLng) * marginRatio; 465 | 466 | for (j = 0, ll = clusterCreationList.length; j < ll; ++j) { 467 | var jcluster = clusterCreationList[j], 468 | jdata = jcluster.data; 469 | 470 | 471 | // If luckily it's the same single marker 472 | if (marker._population === 1 && jcluster.population === 1 && 473 | marker._hashCode === jcluster.hashCode) { 474 | 475 | // I we need to reset the icon 476 | if (resetIcons || jcluster.lastMarker.data.forceIconRedraw) { 477 | this.PrepareLeafletMarker( 478 | marker, 479 | jcluster.lastMarker.data, 480 | jcluster.lastMarker.category); 481 | 482 | if (jcluster.lastMarker.data.forceIconRedraw) { 483 | jcluster.lastMarker.data.forceIconRedraw = false; 484 | } 485 | } 486 | 487 | // Update the position 488 | marker.setLatLng(jdata._leafletPosition); 489 | remove = false; 490 | 491 | } else { 492 | 493 | var pb = jcluster.averagePosition; 494 | var oldMinLng = pa.lng - lngMargin, 495 | newMaxLng = pb.lng + lngMargin; 496 | 497 | oldMaxLng = pa.lng + lngMargin; 498 | oldMinLat = pa.lat - latMargin; 499 | oldMaxLat = pa.lat + latMargin; 500 | newMinLng = pb.lng - lngMargin; 501 | newMinLat = pb.lat - latMargin; 502 | newMaxLat = pb.lat + latMargin; 503 | 504 | // If it's a cluster marker 505 | // and if a collapsing leaflet marker is found, it may be recycled 506 | if ((marker._population > 1 && jcluster.population > 1) && 507 | (oldMaxLng > newMinLng && oldMinLng < newMaxLng && oldMaxLat > newMinLat && oldMinLat < newMaxLat)) { 508 | // Update everything 509 | marker.setLatLng(jdata._leafletPosition); 510 | marker.setIcon(this.BuildLeafletClusterIcon(jcluster)); 511 | var poisson = {}; 512 | L.Util.extend(poisson, jcluster.bounds); 513 | (marker)._leafletClusterBounds = poisson; 514 | jdata._leafletOldPopulation = jcluster.population; 515 | jdata._leafletOldHashCode = jcluster.hashCode; 516 | marker._population = jcluster.population; 517 | remove = false; 518 | } 519 | } 520 | 521 | // If the leaflet marker is recycled 522 | if (!remove) { 523 | 524 | // Register the new marker 525 | jdata._leafletMarker = marker; 526 | marker._removeFromMap = false; 527 | newObjectsOnMap.push(jcluster); 528 | 529 | // Remove it from the sweep and prune working list 530 | clusterCreationList.splice(j, 1); 531 | --j; 532 | --ll; 533 | 534 | break; 535 | } 536 | } 537 | } 538 | 539 | // If sadly the leaflet marker can't be recycled 540 | if (remove) { 541 | if (!marker._removeFromMap) console.error("wtf"); 542 | } 543 | } 544 | } 545 | 546 | // Sixth step : Create the new leaflet markers 547 | for (i = 0, l = clusterCreationList.length; i < l; ++i) { 548 | icluster = clusterCreationList[i], 549 | idata = icluster.data; 550 | 551 | var iposition = idata._leafletPosition; 552 | 553 | var creationMarker: any; 554 | if (icluster.population === 1) { 555 | creationMarker = this.BuildLeafletMarker(icluster.lastMarker, iposition); 556 | } else { 557 | creationMarker = this.BuildLeafletCluster(icluster, iposition); 558 | } 559 | 560 | creationMarker.addTo(map); 561 | 562 | // Fading in transition 563 | // (disabled by default with no-anim) 564 | // if(creationMarker._icon) L.DomUtil.addClass(creationMarker._icon, "no-anim"); 565 | creationMarker.setOpacity(0); 566 | opacityUpdateList.push(creationMarker); 567 | 568 | idata._leafletMarker = creationMarker; 569 | creationMarker._zoomLevel = zoom; 570 | creationMarker._hashCode = icluster.hashCode; 571 | creationMarker._population = icluster.population; 572 | 573 | newObjectsOnMap.push(icluster); 574 | } 575 | 576 | // Start the fading in transition 577 | window.setTimeout(() => { 578 | for (i = 0, l = opacityUpdateList.length; i < l; ++i) { 579 | var m = opacityUpdateList[i]; 580 | if(m._icon) L.DomUtil.addClass(m._icon, "prunecluster-anim"); 581 | if(m._shadow) L.DomUtil.addClass(m._shadow, "prunecluster-anim"); 582 | m.setOpacity(1); 583 | } 584 | }, 1); 585 | 586 | // Remove the remaining unused markers 587 | if (this._hardMove) { 588 | for (i = 0, l = markersOnMap.length; i < l; ++i) { 589 | marker = markersOnMap[i]; 590 | if (marker._removeFromMap) { 591 | map.removeLayer(marker); 592 | } 593 | } 594 | } else { 595 | if (this._removeTimeoutId !== 0) { 596 | window.clearTimeout(this._removeTimeoutId); 597 | for (i = 0, l = this._markersRemoveListTimeout.length; i < l; ++i) { 598 | map.removeLayer(this._markersRemoveListTimeout[i]); 599 | } 600 | } 601 | 602 | var toRemove = []; 603 | for (i = 0, l = markersOnMap.length; i < l; ++i) { 604 | marker = markersOnMap[i]; 605 | if (marker._removeFromMap) { 606 | marker.setOpacity(0); 607 | toRemove.push(marker); 608 | } 609 | } 610 | if (toRemove.length > 0) { 611 | this._removeTimeoutId = window.setTimeout(() => { 612 | for (i = 0, l = toRemove.length; i < l; ++i) { 613 | map.removeLayer(toRemove[i]); 614 | } 615 | this._removeTimeoutId = 0; 616 | }, 300); 617 | } 618 | this._markersRemoveListTimeout = toRemove; 619 | } 620 | 621 | this._objectsOnMap = newObjectsOnMap; 622 | this._hardMove = false; 623 | this._resetIcons = false; 624 | }, 625 | 626 | FitBounds: function(withFiltered: boolean = true) { 627 | var bounds: PruneCluster.Bounds = this.Cluster.ComputeGlobalBounds(withFiltered); 628 | if (bounds) { 629 | this._map.fitBounds(new L.LatLngBounds( 630 | new L.LatLng(bounds.minLat, bounds.maxLng), 631 | new L.LatLng(bounds.maxLat, bounds.minLng))); 632 | } 633 | }, 634 | 635 | GetMarkers: function() { 636 | return this.Cluster.GetMarkers(); 637 | }, 638 | 639 | RedrawIcons: function (processView: boolean = true) { 640 | this._resetIcons = true; 641 | if (processView) { 642 | this.ProcessView(); 643 | } 644 | } 645 | }); 646 | -------------------------------------------------------------------------------- /LeafletSpiderfier.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Based on https://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet and 4 | // https://github.com/Leaflet/Leaflet.markercluster because it works very perfectly 5 | 6 | 7 | var PruneClusterLeafletSpiderfier = ((L).Layer ? (L).Layer : L.Class).extend({ 8 | _2PI: Math.PI * 2, 9 | _circleFootSeparation: 25, //related to circumference of circle 10 | _circleStartAngle: Math.PI / 6, 11 | 12 | _spiralFootSeparation: 28, //related to size of spiral (experiment!) 13 | _spiralLengthStart: 11, 14 | _spiralLengthFactor: 5, 15 | 16 | _spiralCountTrigger: 8, 17 | 18 | spiderfyDistanceMultiplier: 1, 19 | 20 | initialize: function(cluster: PruneCluster.LeafletAdapter) { 21 | this._cluster = cluster; 22 | this._currentMarkers = []; 23 | 24 | this._multiLines = !!L.multiPolyline; 25 | this._lines = this._multiLines ? 26 | L.multiPolyline([], { weight: 1.5, color: '#222' }) : 27 | L.polyline([], { weight: 1.5, color: '#222' }); 28 | }, 29 | 30 | onAdd: function(map: L.Map) { 31 | this._map = map; 32 | 33 | this._map.on('overlappingmarkers', this.Spiderfy, this); 34 | 35 | this._map.on('click', this.Unspiderfy, this); 36 | this._map.on('zoomend', this.Unspiderfy, this); 37 | }, 38 | 39 | Spiderfy: function (data) { 40 | // Ignore events from other PruneCluster instances 41 | if (data.cluster !== this._cluster) { 42 | return; 43 | } 44 | 45 | this.Unspiderfy(); 46 | var markers = data.markers.filter(function(marker) { 47 | return !marker.filtered; 48 | }); 49 | 50 | this._currentCenter = data.center; 51 | 52 | var centerPoint = this._map.latLngToLayerPoint(data.center); 53 | 54 | var points: L.Point[]; 55 | if (markers.length >= this._spiralCountTrigger) { 56 | points = this._generatePointsSpiral(markers.length, centerPoint); 57 | } else { 58 | if (this._multiLines) { // if multilines, leaflet < 0.8 59 | centerPoint.y += 10; // center fix 60 | } 61 | points = this._generatePointsCircle(markers.length, centerPoint); 62 | } 63 | 64 | var polylines: L.LatLng[][] = []; 65 | 66 | 67 | var leafletMarkers: L.Marker[] = []; 68 | var projectedPoints: L.LatLng[] = []; 69 | 70 | for (var i = 0, l = points.length; i < l; ++i) { 71 | var pos = this._map.layerPointToLatLng(points[i]); 72 | var m = this._cluster.BuildLeafletMarker(markers[i], data.center); 73 | m.setZIndexOffset(5000); 74 | m.setOpacity(0); 75 | 76 | // polylines.push([data.center, pos]); 77 | 78 | this._currentMarkers.push(m); 79 | this._map.addLayer(m); 80 | 81 | leafletMarkers.push(m); 82 | projectedPoints.push(pos); 83 | } 84 | 85 | window.setTimeout(() => { 86 | for (i = 0, l = points.length; i < l; ++i) { 87 | leafletMarkers[i].setLatLng(projectedPoints[i]) 88 | .setOpacity(1); 89 | } 90 | 91 | var startTime = +new Date(); 92 | 93 | var interval = 42, duration = 290; 94 | var anim = window.setInterval(() => { 95 | 96 | polylines = []; 97 | 98 | var now = +new Date(); 99 | var d = now - startTime; 100 | if (d >= duration) { 101 | window.clearInterval(anim); 102 | stepRatio = 1.0; 103 | } else { 104 | var stepRatio = d / duration; 105 | } 106 | 107 | var center = data.center; 108 | 109 | for (i = 0, l = points.length; i < l; ++i) { 110 | var p = projectedPoints[i], 111 | diffLat = p.lat - center.lat, 112 | diffLng = p.lng - center.lng; 113 | 114 | polylines.push([center, new L.LatLng(center.lat + diffLat * stepRatio, center.lng + diffLng * stepRatio)]); 115 | } 116 | 117 | this._lines.setLatLngs(polylines); 118 | 119 | }, interval); 120 | }, 1); 121 | 122 | this._lines.setLatLngs(polylines); 123 | this._map.addLayer(this._lines); 124 | 125 | if (data.marker) { 126 | this._clusterMarker = data.marker.setOpacity(0.3); 127 | } 128 | }, 129 | 130 | _generatePointsCircle: function(count: number, centerPt: L.Point): L.Point[] { 131 | var circumference = this.spiderfyDistanceMultiplier * this._circleFootSeparation * (2 + count), 132 | legLength = circumference / this._2PI, //radius from circumference 133 | angleStep = this._2PI / count, 134 | res = [], 135 | i, 136 | angle; 137 | 138 | res.length = count; 139 | 140 | for (i = count - 1; i >= 0; i--) { 141 | angle = this._circleStartAngle + i * angleStep; 142 | res[i] = new L.Point( 143 | Math.round(centerPt.x + legLength * Math.cos(angle)), 144 | Math.round(centerPt.y + legLength * Math.sin(angle))); 145 | } 146 | 147 | return res; 148 | }, 149 | 150 | _generatePointsSpiral: function(count: number, centerPt: L.Point): L.Point[] { 151 | var legLength = this.spiderfyDistanceMultiplier * this._spiralLengthStart, 152 | separation = this.spiderfyDistanceMultiplier * this._spiralFootSeparation, 153 | lengthFactor = this.spiderfyDistanceMultiplier * this._spiralLengthFactor, 154 | angle = 0, 155 | res = [], 156 | i; 157 | 158 | res.length = count; 159 | 160 | for (i = count - 1; i >= 0; i--) { 161 | angle += separation / legLength + i * 0.0005; 162 | res[i] = new L.Point( 163 | Math.round(centerPt.x + legLength * Math.cos(angle)), 164 | Math.round(centerPt.y + legLength * Math.sin(angle))); 165 | legLength += this._2PI * lengthFactor / angle; 166 | } 167 | return res; 168 | }, 169 | 170 | Unspiderfy: function() { 171 | for (var i = 0, l = this._currentMarkers.length; i < l; ++i) { 172 | this._currentMarkers[i].setLatLng(this._currentCenter).setOpacity(0); 173 | } 174 | 175 | var map = this._map; 176 | var markers = this._currentMarkers; 177 | window.setTimeout(() => { 178 | for (i = 0, l = markers.length; i < l; ++i) { 179 | map.removeLayer(markers[i]); 180 | } 181 | 182 | }, 300); 183 | 184 | this._currentMarkers = []; 185 | 186 | this._map.removeLayer(this._lines); 187 | if (this._clusterMarker) { 188 | this._clusterMarker.setOpacity(1); 189 | } 190 | }, 191 | 192 | onRemove: function(map: L.Map) { 193 | this.Unspiderfy(); 194 | map.off('overlappingmarkers', this.Spiderfy, this); 195 | map.off('click', this.Unspiderfy, this); 196 | map.off('zoomend', this.Unspiderfy, this); 197 | } 198 | }); 199 | -------------------------------------------------------------------------------- /LeafletStyleSheet.css: -------------------------------------------------------------------------------- 1 | .prunecluster { 2 | font-size: 12px; 3 | border-radius: 20px; 4 | transition: all 0.3s linear; 5 | } 6 | .leaflet-marker-icon.prunecluster-anim, 7 | .leaflet-marker-shadow.prunecluster-anim, 8 | .leaflet-markercluster-icon.prunecluster-anim { 9 | transition: all 0.3s linear; 10 | } 11 | 12 | .leaflet-zoom-anim .leaflet-zoom-animated.leaflet-marker-icon, 13 | .leaflet-zoom-anim .leaflet-zoom-animated.leaflet-marker-shadow, 14 | .leaflet-zoom-anim .leaflet-zoom-animated.leaflet-markercluster-icon { 15 | transition: transform 0.25s cubic-bezier(0,0,0.25,1); 16 | } 17 | .prunecluster div { 18 | width: 30px; 19 | height: 30px; 20 | text-align: center; 21 | margin-left: 5px; 22 | margin-top: 5px; 23 | border-radius: 50%; 24 | } 25 | .prunecluster div span { 26 | line-height: 30px; 27 | } 28 | 29 | .prunecluster-small { 30 | background-color: #b5e28c; 31 | background-color: rgba(181, 226, 140, 0.6); 32 | } 33 | 34 | .prunecluster-small div { 35 | width: 28px; 36 | height: 28px; 37 | background-color: #6ecc39; 38 | background-color: rgba(110, 204, 57, 0.6); 39 | } 40 | 41 | .prunecluster-small div span { 42 | line-height: 28px; 43 | } 44 | 45 | .prunecluster-medium { 46 | background-color: #f1d357; 47 | background-color: rgba(241, 211, 87, 0.6); 48 | } 49 | 50 | .prunecluster-medium div { 51 | background-color: #f0c20c; 52 | background-color: rgba(240, 194, 12, 0.6); 53 | } 54 | 55 | .prunecluster-large { 56 | background-color: #fd9c73; 57 | background-color: rgba(253, 156, 115, 0.6); 58 | } 59 | 60 | .prunecluster-large div { 61 | width: 34px; 62 | height: 34px; 63 | background-color: #f18017; 64 | background-color: rgba(241, 128, 23, 0.6); 65 | } 66 | 67 | .prunecluster-large div span { 68 | line-height: 34px; 69 | } 70 | -------------------------------------------------------------------------------- /PruneCluster.ts: -------------------------------------------------------------------------------- 1 | namespace PruneCluster { 2 | 3 | // The position is the real position of the object 4 | // using a standard coordinate system, as WGS 84 5 | export interface Position { 6 | lat: number; 7 | lng: number; 8 | } 9 | 10 | // The point is a project position on the client display 11 | export class Point { 12 | x: number; 13 | y: number; 14 | } 15 | 16 | export interface Bounds { 17 | minLat: number; 18 | maxLat: number; 19 | minLng: number; 20 | maxLng: number; 21 | } 22 | 23 | export class ClusterObject { 24 | // Map position of the object 25 | public position: Position; 26 | 27 | // An attached javascript object, storing user data 28 | public data: any; 29 | 30 | // An hashCode identifing the object 31 | public hashCode: number; 32 | } 33 | 34 | // Hidden variable counting the number of created hashcode 35 | var hashCodeCounter: number = 1; 36 | 37 | // Number.MAX_SAFE_INTEGER 38 | var maxHashCodeValue = Math.pow(2, 53) - 1; 39 | 40 | export class Marker extends ClusterObject { 41 | 42 | // The category of the Marker, ideally a number between 0 and 7 43 | // can also be a string 44 | public category: number; 45 | 46 | // The weight of a Marker can influence the cluster icon or the cluster position 47 | public weight: number; 48 | 49 | // If filtered is true, the marker is not included in the clustering 50 | // With some datasets, it's faster to keep the markers inside PruneCluster and to 51 | // use the filtering feature. With some other datasets, it's better to remove the 52 | // markers 53 | public filtered: boolean; 54 | 55 | constructor(lat: number, lng: number, data: {} = {}, 56 | category?: number, weight: number = 1, filtered: boolean = false) { 57 | super(); 58 | this.data = data; 59 | this.position = { lat: +lat, lng: +lng }; 60 | this.weight = weight; 61 | this.category = category; 62 | this.filtered = filtered; 63 | 64 | // The hashCode is used to identify the Cluster object 65 | this.hashCode = hashCodeCounter++; 66 | } 67 | 68 | public Move(lat: number, lng: number) { 69 | this.position.lat = +lat; 70 | this.position.lng = +lng; 71 | } 72 | 73 | // Apply the data object 74 | public SetData(data: any) { 75 | for (var key in data) { 76 | this.data[key] = data[key]; 77 | } 78 | } 79 | } 80 | 81 | export class Cluster extends ClusterObject { 82 | // Cluster area 83 | public bounds: Bounds; 84 | 85 | // Number of markers clustered 86 | public population: number; 87 | 88 | // Average position of the cluster, 89 | // taking into account the cluster weight 90 | public averagePosition: Position; 91 | 92 | // Statistics table 93 | // The key is the category and the value is the sum 94 | // of the weights 95 | public stats: number[]; 96 | 97 | // The total weight of the cluster 98 | public totalWeight: number; 99 | 100 | // The last marker added in the cluster 101 | // Usefull when the cluster contains only one marker 102 | public lastMarker: Marker; 103 | 104 | // If enabled, the cluster contains a list of his marker 105 | // It implies a performance cost, but you can use it 106 | // for building the icon, if your dataset is not too big 107 | public static ENABLE_MARKERS_LIST: boolean = false; 108 | 109 | // The list of markers in the cluster 110 | private _clusterMarkers: Marker[]; 111 | 112 | constructor(marker?: Marker) { 113 | super(); 114 | 115 | // Create a stats table optimized for categories between 0 and 7 116 | this.stats = [0, 0, 0, 0, 0, 0, 0, 0]; 117 | this.data = {}; 118 | 119 | 120 | // You can provide a marker directly in the constructor 121 | // It's like using AddMarker, but a bit faster 122 | if (!marker) { 123 | this.hashCode = 1; 124 | if (Cluster.ENABLE_MARKERS_LIST) { 125 | this._clusterMarkers = []; 126 | } 127 | return; 128 | } 129 | 130 | if (Cluster.ENABLE_MARKERS_LIST) { 131 | this._clusterMarkers = [marker]; 132 | } 133 | 134 | this.lastMarker = marker; 135 | 136 | this.hashCode = 31 + marker.hashCode; 137 | 138 | this.population = 1; 139 | 140 | if (marker.category !== undefined) { 141 | this.stats[marker.category] = 1; 142 | } 143 | 144 | this.totalWeight = marker.weight; 145 | 146 | this.position = { 147 | lat: marker.position.lat, 148 | lng: marker.position.lng 149 | }; 150 | 151 | this.averagePosition = { 152 | lat: marker.position.lat, 153 | lng: marker.position.lng 154 | }; 155 | 156 | } 157 | 158 | public AddMarker(marker: Marker) { 159 | 160 | if (Cluster.ENABLE_MARKERS_LIST) { 161 | this._clusterMarkers.push(marker); 162 | } 163 | 164 | var h = this.hashCode; 165 | h = ((h << 5) - h) + marker.hashCode; 166 | if (h >= maxHashCodeValue) { 167 | this.hashCode = h % maxHashCodeValue; 168 | } else { 169 | this.hashCode = h; 170 | } 171 | 172 | this.lastMarker = marker; 173 | 174 | // Compute the weighted arithmetic mean 175 | var weight = marker.weight, 176 | currentTotalWeight = this.totalWeight, 177 | newWeight = weight + currentTotalWeight; 178 | 179 | this.averagePosition.lat = 180 | (this.averagePosition.lat * currentTotalWeight + 181 | marker.position.lat * weight) / newWeight; 182 | 183 | this.averagePosition.lng = 184 | (this.averagePosition.lng * currentTotalWeight + 185 | marker.position.lng * weight) / newWeight; 186 | 187 | ++this.population; 188 | this.totalWeight = newWeight; 189 | 190 | // Update the statistics if needed 191 | if (marker.category !== undefined) { 192 | this.stats[marker.category] = (this.stats[marker.category] + 1) || 1; 193 | } 194 | } 195 | 196 | public Reset() { 197 | this.hashCode = 1; 198 | this.lastMarker = undefined; 199 | this.population = 0; 200 | this.totalWeight = 0; 201 | this.stats = [0, 0, 0, 0, 0, 0, 0, 0]; 202 | 203 | if (Cluster.ENABLE_MARKERS_LIST) { 204 | this._clusterMarkers = []; 205 | } 206 | } 207 | 208 | // Compute the bounds 209 | // Settle the cluster to the projected grid 210 | public ComputeBounds(cluster: PruneCluster.PruneCluster) { 211 | 212 | var proj = cluster.Project(this.position.lat, this.position.lng); 213 | 214 | var size = cluster.Size; 215 | 216 | // Compute the position of the cluster 217 | var nbX = Math.floor(proj.x / size), 218 | nbY = Math.floor(proj.y / size), 219 | startX = nbX * size, 220 | startY = nbY * size; 221 | 222 | // Project it to lat/lng values 223 | var a = cluster.UnProject(startX, startY), 224 | b = cluster.UnProject(startX + size, startY + size); 225 | 226 | this.bounds = { 227 | minLat: b.lat, 228 | maxLat: a.lat, 229 | minLng: a.lng, 230 | maxLng: b.lng 231 | }; 232 | } 233 | 234 | public GetClusterMarkers() { 235 | return this._clusterMarkers; 236 | } 237 | 238 | public ApplyCluster(newCluster: Cluster) { 239 | 240 | this.hashCode = this.hashCode * 41 + newCluster.hashCode * 43; 241 | if (this.hashCode > maxHashCodeValue) { 242 | this.hashCode = this.hashCode = maxHashCodeValue; 243 | } 244 | 245 | var weight = newCluster.totalWeight, 246 | currentTotalWeight = this.totalWeight, 247 | newWeight = weight + currentTotalWeight; 248 | 249 | this.averagePosition.lat = 250 | (this.averagePosition.lat * currentTotalWeight + 251 | newCluster.averagePosition.lat * weight) / newWeight; 252 | 253 | this.averagePosition.lng = 254 | (this.averagePosition.lng * currentTotalWeight + 255 | newCluster.averagePosition.lng * weight) / newWeight; 256 | 257 | this.population += newCluster.population; 258 | this.totalWeight = newWeight; 259 | 260 | // Merge the bounds 261 | this.bounds.minLat = Math.min(this.bounds.minLat, newCluster.bounds.minLat); 262 | this.bounds.minLng = Math.min(this.bounds.minLng, newCluster.bounds.minLng); 263 | this.bounds.maxLat = Math.max(this.bounds.maxLat, newCluster.bounds.maxLat); 264 | this.bounds.maxLng = Math.max(this.bounds.maxLng, newCluster.bounds.maxLng); 265 | 266 | // Merge the statistics 267 | for (var category in newCluster.stats) { 268 | if (newCluster.stats.hasOwnProperty(category)) { 269 | if (this.stats.hasOwnProperty(category)) { 270 | this.stats[category] += newCluster.stats[category]; 271 | } else { 272 | this.stats[category] = newCluster.stats[category]; 273 | } 274 | } 275 | } 276 | 277 | // Merge the clusters lists 278 | if (Cluster.ENABLE_MARKERS_LIST) { 279 | this._clusterMarkers = this._clusterMarkers.concat(newCluster.GetClusterMarkers()); 280 | } 281 | } 282 | } 283 | 284 | function checkPositionInsideBounds(a: Position, b: Bounds): boolean { 285 | return (a.lat >= b.minLat && a.lat <= b.maxLat) && 286 | a.lng >= b.minLng && a.lng <= b.maxLng; 287 | } 288 | 289 | function insertionSort(list: ClusterObject[]) { 290 | for (var i: number = 1, 291 | j: number, 292 | tmp: ClusterObject, 293 | tmpLng: number, 294 | length = list.length; i < length; ++i) { 295 | tmp = list[i]; 296 | tmpLng = tmp.position.lng; 297 | for (j = i - 1; j >= 0 && list[j].position.lng > tmpLng; --j) { 298 | list[j + 1] = list[j]; 299 | } 300 | list[j + 1] = tmp; 301 | } 302 | } 303 | 304 | // PruneCluster must work on a sorted collection 305 | // the insertion sort is preferred for its stability and its performances 306 | // on sorted or almost sorted collections. 307 | // 308 | // However the insertion sort's worst case is extreme and we should avoid it. 309 | function shouldUseInsertionSort(total: number, nbChanges: number): boolean { 310 | if (nbChanges > 300) { 311 | return false; 312 | } else { 313 | return (nbChanges / total) < 0.2; 314 | } 315 | } 316 | 317 | export class PruneCluster { 318 | private _markers: Marker[] = []; 319 | 320 | // Represent the number of marker added or deleted since the last sort 321 | private _nbChanges: number = 0; 322 | 323 | private _clusters: Cluster[] = []; 324 | 325 | // Cluster size in (in pixels) 326 | public Size: number = 166; 327 | 328 | // View padding (extended size of the view) 329 | public ViewPadding: number = 0.2; 330 | 331 | // These methods should be defined by the user 332 | public Project: (lat: number, lng: number) => Point; 333 | public UnProject: (x: number, y: number) => Position; 334 | 335 | public RegisterMarker(marker: Marker) { 336 | if ((marker)._removeFlag) { 337 | delete (marker)._removeFlag; 338 | } 339 | this._markers.push(marker); 340 | this._nbChanges += 1; 341 | } 342 | 343 | public RegisterMarkers(markers: Marker[]) { 344 | markers.forEach((marker: Marker) => { 345 | this.RegisterMarker(marker); 346 | }); 347 | } 348 | 349 | private _sortMarkers() { 350 | var markers = this._markers, 351 | length = markers.length; 352 | 353 | if (this._nbChanges && !shouldUseInsertionSort(length, this._nbChanges)) { 354 | // native (n log n) sort 355 | this._markers.sort((a: Marker, b: Marker) => a.position.lng - b.position.lng); 356 | } else { 357 | insertionSort(markers); // faster for almost-sorted arrays 358 | } 359 | 360 | // Now the list is sorted, we can reset the counter 361 | this._nbChanges = 0; 362 | } 363 | 364 | private _sortClusters() { 365 | // Insertion sort because the list is often almost sorted 366 | // and we want to have a stable list of clusters 367 | insertionSort(this._clusters); 368 | } 369 | 370 | private _indexLowerBoundLng(lng: number): number { 371 | // Inspired by std::lower_bound 372 | 373 | // It's a binary search algorithm 374 | var markers = this._markers, 375 | it, 376 | step, 377 | first = 0, 378 | count = markers.length; 379 | 380 | while (count > 0) { 381 | step = Math.floor(count / 2); 382 | it = first + step; 383 | if (markers[it].position.lng < lng) { 384 | first = ++it; 385 | count -= step + 1; 386 | } else { 387 | count = step; 388 | } 389 | } 390 | 391 | return first; 392 | } 393 | 394 | private _resetClusterViews() { 395 | // Reset all the clusters 396 | for (var i = 0, l = this._clusters.length; i < l; ++i) { 397 | var cluster = this._clusters[i]; 398 | cluster.Reset(); 399 | 400 | // The projection changes in accordance with the view's zoom level 401 | // (at least with Leaflet.js) 402 | cluster.ComputeBounds(this); 403 | } 404 | } 405 | 406 | public ProcessView(bounds: Bounds): Cluster[] { 407 | 408 | // Compute the extended bounds of the view 409 | var heightBuffer = Math.abs(bounds.maxLat - bounds.minLat) * this.ViewPadding, 410 | widthBuffer = Math.abs(bounds.maxLng - bounds.minLng) * this.ViewPadding; 411 | 412 | var extendedBounds: Bounds = { 413 | minLat: bounds.minLat - heightBuffer - heightBuffer, 414 | maxLat: bounds.maxLat + heightBuffer + heightBuffer, 415 | minLng: bounds.minLng - widthBuffer - widthBuffer, 416 | maxLng: bounds.maxLng + widthBuffer + widthBuffer 417 | }; 418 | 419 | // We keep the list of all markers sorted 420 | // It's faster to keep the list sorted so we can use 421 | // a insertion sort algorithm which is faster for sorted lists 422 | this._sortMarkers(); 423 | 424 | // Reset the cluster for the new view 425 | this._resetClusterViews(); 426 | 427 | // Binary search for the first interesting marker 428 | var firstIndex = this._indexLowerBoundLng(extendedBounds.minLng); 429 | 430 | // Just some shortcuts 431 | var markers = this._markers, 432 | clusters = this._clusters; 433 | 434 | 435 | var workingClusterList = clusters.slice(0); 436 | 437 | // For every markers in the list 438 | for (var i = firstIndex, l = markers.length; i < l; ++i) { 439 | 440 | var marker = markers[i], 441 | markerPosition = marker.position; 442 | 443 | // If the marker longitute is higher than the view longitude, 444 | // we can stop to iterate 445 | if (markerPosition.lng > extendedBounds.maxLng) { 446 | break; 447 | } 448 | 449 | 450 | // If the marker is inside the view and is not filtered 451 | if (markerPosition.lat > extendedBounds.minLat && 452 | markerPosition.lat < extendedBounds.maxLat && 453 | !marker.filtered) { 454 | 455 | var clusterFound = false, cluster: Cluster; 456 | 457 | // For every active cluster 458 | for (var j = 0, ll = workingClusterList.length; j < ll; ++j) { 459 | cluster = workingClusterList[j]; 460 | 461 | // If the cluster is far away the current marker 462 | // we can remove it from the list of active clusters 463 | // because we will never reach it again 464 | if (cluster.bounds.maxLng < marker.position.lng) { 465 | workingClusterList.splice(j, 1); 466 | --j; 467 | --ll; 468 | continue; 469 | } 470 | 471 | if (checkPositionInsideBounds(markerPosition, cluster.bounds)) { 472 | cluster.AddMarker(marker); 473 | // We found a marker, we don't need to go further 474 | clusterFound = true; 475 | break; 476 | } 477 | } 478 | 479 | 480 | // If the marker doesn't fit in any cluster, 481 | // we must create a brand new cluster. 482 | if (!clusterFound) { 483 | cluster = new Cluster(marker); 484 | cluster.ComputeBounds(this); 485 | clusters.push(cluster); 486 | workingClusterList.push(cluster); 487 | } 488 | } 489 | } 490 | 491 | 492 | // Time to remove empty clusters 493 | var newClustersList: Cluster[] = []; 494 | for (i = 0, l = clusters.length; i < l; ++i) { 495 | cluster = clusters[i]; 496 | if (cluster.population > 0) { 497 | newClustersList.push(cluster); 498 | } 499 | } 500 | 501 | this._clusters = newClustersList; 502 | 503 | // We keep the list of markers sorted, it's faster 504 | this._sortClusters(); 505 | 506 | return this._clusters; 507 | } 508 | 509 | public RemoveMarkers(markers?: Marker[]) { 510 | 511 | // if markers are undefined, remove all 512 | if (!markers) { 513 | this._markers = []; 514 | return; 515 | } 516 | 517 | // Mark the markers to be deleted 518 | for (var i = 0, l = markers.length; i < l; ++i) { 519 | (markers[i])._removeFlag = true; 520 | } 521 | 522 | // Create a new list without the marked markers 523 | var newMarkersList = []; 524 | for (i = 0, l = this._markers.length; i < l; ++i) { 525 | if (!(this._markers[i])._removeFlag) { 526 | newMarkersList.push(this._markers[i]); 527 | } 528 | else{ 529 | delete (this._markers[i])._removeFlag; 530 | } 531 | } 532 | 533 | this._markers = newMarkersList; 534 | } 535 | 536 | // This method is a bit slow ( O(n)) because it's not worth to make 537 | // system which will slow down all the clusters just to have 538 | // this one fast 539 | public FindMarkersInArea(area: Bounds): Marker[] { 540 | var aMinLat = area.minLat, 541 | aMaxLat = area.maxLat, 542 | aMinLng = area.minLng, 543 | aMaxLng = area.maxLng, 544 | 545 | markers = this._markers, 546 | 547 | result = []; 548 | 549 | var firstIndex = this._indexLowerBoundLng(aMinLng); 550 | 551 | for (var i = firstIndex, l = markers.length; i < l; ++i) { 552 | var pos = markers[i].position; 553 | 554 | if (pos.lng > aMaxLng) { 555 | break; 556 | } 557 | 558 | if (pos.lat >= aMinLat && pos.lat <= aMaxLat && 559 | pos.lng >= aMinLng) { 560 | 561 | result.push(markers[i]); 562 | } 563 | } 564 | 565 | return result; 566 | } 567 | 568 | // Compute the bounds of the list of markers 569 | // It's slow O(n) 570 | public ComputeBounds(markers: Marker[], withFiltered: boolean = true): Bounds { 571 | 572 | if (!markers || !markers.length) { 573 | return null; 574 | } 575 | 576 | var rMinLat = Number.MAX_VALUE, 577 | rMaxLat = -Number.MAX_VALUE, 578 | rMinLng = Number.MAX_VALUE, 579 | rMaxLng = -Number.MAX_VALUE; 580 | 581 | for (var i = 0, l = markers.length; i < l; ++i) { 582 | if (!withFiltered && markers[i].filtered) { 583 | continue; 584 | } 585 | var pos = markers[i].position; 586 | 587 | if (pos.lat < rMinLat) rMinLat = pos.lat; 588 | if (pos.lat > rMaxLat) rMaxLat = pos.lat; 589 | if (pos.lng < rMinLng) rMinLng = pos.lng; 590 | if (pos.lng > rMaxLng) rMaxLng = pos.lng; 591 | } 592 | 593 | return { 594 | minLat: rMinLat, 595 | maxLat: rMaxLat, 596 | minLng: rMinLng, 597 | maxLng: rMaxLng 598 | }; 599 | } 600 | 601 | public FindMarkersBoundsInArea(area: Bounds): Bounds { 602 | return this.ComputeBounds(this.FindMarkersInArea(area)); 603 | } 604 | 605 | public ComputeGlobalBounds(withFiltered: boolean = true): Bounds { 606 | return this.ComputeBounds(this._markers, withFiltered); 607 | } 608 | 609 | public GetMarkers(): Marker[] { 610 | return this._markers; 611 | } 612 | 613 | public GetPopulation(): number { 614 | return this._markers.length; 615 | } 616 | 617 | public ResetClusters() { 618 | this._clusters = []; 619 | } 620 | 621 | } 622 | } 623 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PruneCluster](https://sintef-9012.github.io/PruneCluster/logo.png) 2 | ============ 3 | 4 | PruneCluster is a fast and realtime marker clustering library. 5 | 6 | *Example 1:* [150 000 randomly moving markers](http://sintef-9012.github.io/PruneCluster/examples/random.150000.html). 7 | 8 | ![](https://sintef-9012.github.io/PruneCluster/twittermap.jpg) 9 | *Example 2: Realtime clusters of tweets.* 10 | 11 | It's working with [Leaflet](http://leafletjs.com/) as an alternative to [Leaflet.markercluster](https://github.com/Leaflet/Leaflet.markercluster). 12 | 13 | 14 | *The library is designed for large datasets or live situations.* The memory consumption is kept low and the library is fast on mobile devices, thanks to a new algorithm inspired by collision detection in physical engines. 15 | 16 | 17 | 18 | ### Features 19 | 20 | #### Realtime 21 | The clusters can be updated in realtime. It's perfect for live datasets or datasets you want to filter at runtime. 22 | 23 | #### Fast 24 | 25 | Number of markers|First step|Update (low zoom level)|Update (high zoom level) 26 | ---------|------------------|------------------------|------------------ 27 | [100](http://sintef-9012.github.io/PruneCluster/examples/random.100.html)|instant|instant|instant 28 | [1 000](http://sintef-9012.github.io/PruneCluster/examples/random.1000.html)|instant|instant|instant 29 | [10 000](http://sintef-9012.github.io/PruneCluster/examples/random.10000.html)|14ms|3ms|2ms 30 | [60 000](http://sintef-9012.github.io/PruneCluster/examples/random.60000.html)|70ms|23ms|9ms 31 | [150 000](http://sintef-9012.github.io/PruneCluster/examples/random.150000.html)|220ms|60ms|20ms 32 | [1 000 000](http://sintef-9012.github.io/PruneCluster/examples/random.1000000.html)|1.9s|400ms|135ms 33 | 34 | These values are tested with random positions, on a recent laptop, using Chrome 38. One half of markers is moving randomly and the other half is static. It is also fast enough for mobile devices. 35 | 36 | If you prefer real world data, the [50k Leaflet.markercluster example](http://sintef-9012.github.io/PruneCluster/examples/realworld.50000.html) is computed in 60ms *([original](http://sintef-9012.github.io/Leaflet.markercluster/example/marker-clustering-realworld.50000.html))*. 37 | 38 | #### Weight 39 | You can specify the weight of each marker. 40 | 41 | For example, you may want to add more importance to a marker representing an incident, than a marker representing a tweet. 42 | 43 | #### Categories 44 | 45 | You can specify a category for the markers. Then a small object representing the number of markers for each category is attached to the clusters. This way, you can create cluster icons adapted to their content. 46 | 47 | [![](https://sintef-9012.github.io/PruneCluster/clustering_a.png)](http://sintef-9012.github.io/PruneCluster/examples/random.10000-categories.html) [![](https://sintef-9012.github.io/PruneCluster/clustering_b.png)](http://sintef-9012.github.io/PruneCluster/examples/random.10000-categories-2.html) 48 | 49 | #### Dynamic cluster size 50 | 51 | The size of a cluster can be adjusted on the fly *([Example](http://sintef-9012.github.io/PruneCluster/examples/random.10000-size.html))* 52 | 53 | #### Filtering 54 | The markers can be filtered easily with no performance cost. 55 | 56 | 57 | ### Usage 58 | 59 | #### Classic Way 60 | ```html 61 | 62 | 65 | 66 | 67 | 70 | 71 | ``` 72 | 73 | #### Webpack & NPM 74 | 75 | `npm install exports-loader prunecluster` 76 | 77 | ```javascript 78 | import { PruneCluster, PruneClusterForLeaflet } from 'exports-loader?PruneCluster,PruneClusterForLeaflet!prunecluster/dist/PruneCluster.js' 79 | 80 | ``` 81 | 82 | #### Example 83 | 84 | ```javascript 85 | var pruneCluster = new PruneClusterForLeaflet(); 86 | 87 | ... 88 | var marker = new PruneCluster.Marker(59.8717, 11.1909); 89 | pruneCluster.RegisterMarker(marker); 90 | ... 91 | 92 | leafletMap.addLayer(pruneCluster); 93 | ``` 94 | 95 | ### PruneClusterForLeaflet constructor 96 | 97 | ```javascript 98 | PruneClusterForLeaflet([size](#set-the-clustering-size), margin); 99 | ``` 100 | 101 | You can specify the size and margin which affect when your clusters and markers will be merged. 102 | 103 | size defaults to 120 and margin to 20. 104 | 105 | #### Update a position 106 | ```javascript 107 | marker.Move(lat, lng); 108 | ``` 109 | 110 | #### Deletions 111 | ```javascript 112 | // Remove all the markers 113 | pruneCluster.RemoveMarkers(); 114 | 115 | // Remove a list of markers 116 | pruneCluster.RemoveMarkers([markerA,markerB,...]); 117 | ``` 118 | 119 | #### Set the category 120 | The category can be a number or a string, but in order to minimize the performance cost, it is recommended to use numbers between 0 and 7. 121 | ```javascript 122 | marker.category = 5; 123 | ``` 124 | 125 | #### Set the weight 126 | ```javascript 127 | marker.weight = 4; 128 | ``` 129 | 130 | #### Filtering 131 | ```javascript 132 | marker.filtered = true|false; 133 | ``` 134 | 135 | #### Set the clustering size 136 | You can specify a number indicating the area of the cluster. Higher number means more markers "merged". *([Example](http://sintef-9012.github.io/PruneCluster/examples/random.10000-size.html))* 137 | ```javascript 138 | pruneCluster.Cluster.Size = 87; 139 | ``` 140 | 141 | #### Apply the changes 142 | 143 | **Must be called when ANY changes are made.** 144 | 145 | ```javascript 146 | pruneCluster.ProcessView(); 147 | ``` 148 | 149 | #### Add custom data to marker object 150 | 151 | Each marker has a data object where you can specify your data. 152 | ```javascript 153 | marker.data.name = 'Roger'; 154 | marker.data.ID = '76ez'; 155 | ``` 156 | 157 | #### Setting up a Leaflet icon or a Leaflet popup 158 | 159 | You can attach to the markers an icon object and a popup content 160 | ```javascript 161 | marker.data.icon = L.icon(...); // See http://leafletjs.com/reference.html#icon 162 | marker.data.popup = 'Popup content'; 163 | ``` 164 | 165 | #### Faster leaflet icons 166 | 167 | If you have a lot of markers, you can create the icons and popups on the fly in order to improve their performance. 168 | 169 | ```javascript 170 | function createIcon(data, category) { 171 | return L.icon(...); 172 | } 173 | 174 | ... 175 | 176 | marker.data.icon = createIcon; 177 | ``` 178 | 179 | You can also override the PreapareLeafletMarker method. You can apply listeners to the markers here. 180 | 181 | ```javascript 182 | pruneCluster.PrepareLeafletMarker = function(leafletMarker, data) { 183 | leafletMarker.setIcon(/*... */); // See http://leafletjs.com/reference.html#icon 184 | //listeners can be applied to markers in this function 185 | leafletMarker.on('click', function(){ 186 | //do click event logic here 187 | }); 188 | // A popup can already be attached to the marker 189 | // bindPopup can override it, but it's faster to update the content instead 190 | if (leafletMarker.getPopup()) { 191 | leafletMarker.setPopupContent(data.name); 192 | } else { 193 | leafletMarker.bindPopup(data.name); 194 | } 195 | }; 196 | ``` 197 | 198 | #### Setting up a custom cluster icon 199 | ```javascript 200 | pruneCluster.BuildLeafletClusterIcon = function(cluster) { 201 | var population = cluster.population, // the number of markers inside the cluster 202 | stats = cluster.stats; // if you have categories on your markers 203 | 204 | // If you want list of markers inside the cluster 205 | // (you must enable the option using PruneCluster.Cluster.ENABLE_MARKERS_LIST = true) 206 | var markers = cluster.GetClusterMarkers() 207 | 208 | ... 209 | 210 | return icon; // L.Icon object (See http://leafletjs.com/reference.html#icon); 211 | }; 212 | ``` 213 | 214 | #### Listening to events on a cluster 215 | 216 | To listen to events on the cluster, you will need to override the ```BuildLeafletCluster``` method. A click event is already specified on m, but you can add other events like mouseover, mouseout, etc. Any events that a Leaflet marker supports, the cluster also supports, since it is just a modified marker. A full list of events can be found [here](http://leafletjs.com/reference.html#marker-click). 217 | 218 | Below is an example of how to implement mouseover and mousedown for the cluster, but any events can be used in place of those. 219 | ```javascript 220 | pruneCluster.BuildLeafletCluster = function(cluster, position) { 221 | var m = new L.Marker(position, { 222 | icon: pruneCluster.BuildLeafletClusterIcon(cluster) 223 | }); 224 | 225 | m.on('click', function() { 226 | // Compute the cluster bounds (it's slow : O(n)) 227 | var markersArea = pruneCluster.Cluster.FindMarkersInArea(cluster.bounds); 228 | var b = pruneCluster.Cluster.ComputeBounds(markersArea); 229 | 230 | if (b) { 231 | var bounds = new L.LatLngBounds( 232 | new L.LatLng(b.minLat, b.maxLng), 233 | new L.LatLng(b.maxLat, b.minLng)); 234 | 235 | var zoomLevelBefore = pruneCluster._map.getZoom(); 236 | var zoomLevelAfter = pruneCluster._map.getBoundsZoom(bounds, false, new L.Point(20, 20, null)); 237 | 238 | // If the zoom level doesn't change 239 | if (zoomLevelAfter === zoomLevelBefore) { 240 | // Send an event for the LeafletSpiderfier 241 | pruneCluster._map.fire('overlappingmarkers', { 242 | cluster: pruneCluster, 243 | markers: markersArea, 244 | center: m.getLatLng(), 245 | marker: m 246 | }); 247 | 248 | pruneCluster._map.setView(position, zoomLevelAfter); 249 | } 250 | else { 251 | pruneCluster._map.fitBounds(bounds); 252 | } 253 | } 254 | }); 255 | m.on('mouseover', function() { 256 | //do mouseover stuff here 257 | }); 258 | m.on('mouseout', function() { 259 | //do mouseout stuff here 260 | }); 261 | 262 | return m; 263 | }; 264 | }; 265 | ``` 266 | 267 | #### Redraw the icons 268 | 269 | Marker icon redrawing with a flag: 270 | 271 | ```javascript 272 | marker.data.forceIconRedraw = true; 273 | 274 | ... 275 | 276 | pruneCluster.ProcessView(); 277 | ``` 278 | 279 | Redraw all the icons: 280 | ```javascript 281 | pruneCluster.RedrawIcons(); 282 | ``` 283 | 284 | ### Acknowledgements 285 | 286 | This library was developed in context of the BRIDGE project. It is now supported by the community and we thank [the contributors](https://github.com/SINTEF-9012/PruneCluster/graphs/contributors). 287 | 288 | ### Licence 289 | 290 | The source code of this library is licensed under the MIT License. 291 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PruneCluster", 3 | "dependencies": { 4 | "leaflet": "0.7 - 2" 5 | }, 6 | "devDependencies": { 7 | "leaflet-dist": "~0.7.2" 8 | }, 9 | "ignore": [ 10 | "*.ts", 11 | "*.md", 12 | "package.json", 13 | "meteor", 14 | "examples", 15 | "Gruntfile.js", 16 | "LICENSE", 17 | "bower.json" 18 | ], 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/SINTEF-9012/PruneCluster.git" 23 | }, 24 | "main": ["./dist/LeafletStyleSheet.css", "./dist/PruneCluster.js"] 25 | } 26 | -------------------------------------------------------------------------------- /dist/LeafletStyleSheet.css: -------------------------------------------------------------------------------- 1 | .prunecluster { 2 | font-size: 12px; 3 | border-radius: 20px; 4 | transition: all 0.3s linear; 5 | } 6 | .leaflet-marker-icon.prunecluster-anim, 7 | .leaflet-marker-shadow.prunecluster-anim, 8 | .leaflet-markercluster-icon.prunecluster-anim { 9 | transition: all 0.3s linear; 10 | } 11 | 12 | .leaflet-zoom-anim .leaflet-zoom-animated.leaflet-marker-icon, 13 | .leaflet-zoom-anim .leaflet-zoom-animated.leaflet-marker-shadow, 14 | .leaflet-zoom-anim .leaflet-zoom-animated.leaflet-markercluster-icon { 15 | transition: transform 0.25s cubic-bezier(0,0,0.25,1); 16 | } 17 | .prunecluster div { 18 | width: 30px; 19 | height: 30px; 20 | text-align: center; 21 | margin-left: 5px; 22 | margin-top: 5px; 23 | border-radius: 50%; 24 | } 25 | .prunecluster div span { 26 | line-height: 30px; 27 | } 28 | 29 | .prunecluster-small { 30 | background-color: #b5e28c; 31 | background-color: rgba(181, 226, 140, 0.6); 32 | } 33 | 34 | .prunecluster-small div { 35 | width: 28px; 36 | height: 28px; 37 | background-color: #6ecc39; 38 | background-color: rgba(110, 204, 57, 0.6); 39 | } 40 | 41 | .prunecluster-small div span { 42 | line-height: 28px; 43 | } 44 | 45 | .prunecluster-medium { 46 | background-color: #f1d357; 47 | background-color: rgba(241, 211, 87, 0.6); 48 | } 49 | 50 | .prunecluster-medium div { 51 | background-color: #f0c20c; 52 | background-color: rgba(240, 194, 12, 0.6); 53 | } 54 | 55 | .prunecluster-large { 56 | background-color: #fd9c73; 57 | background-color: rgba(253, 156, 115, 0.6); 58 | } 59 | 60 | .prunecluster-large div { 61 | width: 34px; 62 | height: 34px; 63 | background-color: #f18017; 64 | background-color: rgba(241, 128, 23, 0.6); 65 | } 66 | 67 | .prunecluster-large div span { 68 | line-height: 34px; 69 | } 70 | -------------------------------------------------------------------------------- /dist/PruneCluster.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare namespace PruneCluster { 3 | interface Position { 4 | lat: number; 5 | lng: number; 6 | } 7 | class Point { 8 | x: number; 9 | y: number; 10 | } 11 | interface Bounds { 12 | minLat: number; 13 | maxLat: number; 14 | minLng: number; 15 | maxLng: number; 16 | } 17 | class ClusterObject { 18 | position: Position; 19 | data: any; 20 | hashCode: number; 21 | } 22 | class Marker extends ClusterObject { 23 | category: number; 24 | weight: number; 25 | filtered: boolean; 26 | constructor(lat: number, lng: number, data?: {}, category?: number, weight?: number, filtered?: boolean); 27 | Move(lat: number, lng: number): void; 28 | SetData(data: any): void; 29 | } 30 | class Cluster extends ClusterObject { 31 | bounds: Bounds; 32 | population: number; 33 | averagePosition: Position; 34 | stats: number[]; 35 | totalWeight: number; 36 | lastMarker: Marker; 37 | static ENABLE_MARKERS_LIST: boolean; 38 | private _clusterMarkers; 39 | constructor(marker?: Marker); 40 | AddMarker(marker: Marker): void; 41 | Reset(): void; 42 | ComputeBounds(cluster: PruneCluster.PruneCluster): void; 43 | GetClusterMarkers(): Marker[]; 44 | ApplyCluster(newCluster: Cluster): void; 45 | } 46 | class PruneCluster { 47 | private _markers; 48 | private _nbChanges; 49 | private _clusters; 50 | Size: number; 51 | ViewPadding: number; 52 | Project: (lat: number, lng: number) => Point; 53 | UnProject: (x: number, y: number) => Position; 54 | RegisterMarker(marker: Marker): void; 55 | RegisterMarkers(markers: Marker[]): void; 56 | private _sortMarkers(); 57 | private _sortClusters(); 58 | private _indexLowerBoundLng(lng); 59 | private _resetClusterViews(); 60 | ProcessView(bounds: Bounds): Cluster[]; 61 | RemoveMarkers(markers?: Marker[]): void; 62 | FindMarkersInArea(area: Bounds): Marker[]; 63 | ComputeBounds(markers: Marker[], withFiltered?: boolean): Bounds; 64 | FindMarkersBoundsInArea(area: Bounds): Bounds; 65 | ComputeGlobalBounds(withFiltered?: boolean): Bounds; 66 | GetMarkers(): Marker[]; 67 | GetPopulation(): number; 68 | ResetClusters(): void; 69 | } 70 | } 71 | declare namespace PruneCluster { 72 | class LeafletAdapter implements L.ILayer { 73 | Cluster: PruneCluster.PruneCluster; 74 | onAdd: (map: L.Map) => void; 75 | onRemove: (map: L.Map) => void; 76 | RegisterMarker: (marker: Marker) => void; 77 | RegisterMarkers: (markers: Marker[]) => void; 78 | RemoveMarkers: (markers: Marker[]) => void; 79 | ProcessView: () => void; 80 | FitBounds: (withFiltered?: boolean) => void; 81 | GetMarkers: () => Marker[]; 82 | RedrawIcons: (processView?: boolean) => void; 83 | BuildLeafletCluster: (cluster: Cluster, position: L.LatLng) => L.ILayer; 84 | BuildLeafletClusterIcon: (cluster: Cluster) => L.Icon; 85 | BuildLeafletMarker: (marker: Marker, position: L.LatLng) => L.Marker; 86 | PrepareLeafletMarker: (marker: L.Marker, data: {}, category: number) => void; 87 | } 88 | interface LeafletMarker extends L.Marker { 89 | _population?: number; 90 | _hashCode?: number; 91 | _zoomLevel?: number; 92 | _removeFromMap?: boolean; 93 | } 94 | interface ILeafletAdapterData { 95 | _leafletMarker?: LeafletMarker; 96 | _leafletCollision?: boolean; 97 | _leafletOldPopulation?: number; 98 | _leafletOldHashCode?: number; 99 | _leafletPosition?: L.LatLng; 100 | } 101 | } 102 | declare var PruneClusterForLeaflet: any; 103 | declare var PruneClusterLeafletSpiderfier: any; 104 | -------------------------------------------------------------------------------- /dist/PruneCluster.js: -------------------------------------------------------------------------------- 1 | var __extends = (this && this.__extends) || (function () { 2 | var extendStatics = Object.setPrototypeOf || 3 | ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || 4 | function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; 5 | return function (d, b) { 6 | extendStatics(d, b); 7 | function __() { this.constructor = d; } 8 | d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); 9 | }; 10 | })(); 11 | var PruneCluster; 12 | (function (PruneCluster_1) { 13 | var Point = (function () { 14 | function Point() { 15 | } 16 | return Point; 17 | }()); 18 | PruneCluster_1.Point = Point; 19 | var ClusterObject = (function () { 20 | function ClusterObject() { 21 | } 22 | return ClusterObject; 23 | }()); 24 | PruneCluster_1.ClusterObject = ClusterObject; 25 | var hashCodeCounter = 1; 26 | var maxHashCodeValue = Math.pow(2, 53) - 1; 27 | var Marker = (function (_super) { 28 | __extends(Marker, _super); 29 | function Marker(lat, lng, data, category, weight, filtered) { 30 | if (data === void 0) { data = {}; } 31 | if (weight === void 0) { weight = 1; } 32 | if (filtered === void 0) { filtered = false; } 33 | var _this = _super.call(this) || this; 34 | _this.data = data; 35 | _this.position = { lat: +lat, lng: +lng }; 36 | _this.weight = weight; 37 | _this.category = category; 38 | _this.filtered = filtered; 39 | _this.hashCode = hashCodeCounter++; 40 | return _this; 41 | } 42 | Marker.prototype.Move = function (lat, lng) { 43 | this.position.lat = +lat; 44 | this.position.lng = +lng; 45 | }; 46 | Marker.prototype.SetData = function (data) { 47 | for (var key in data) { 48 | this.data[key] = data[key]; 49 | } 50 | }; 51 | return Marker; 52 | }(ClusterObject)); 53 | PruneCluster_1.Marker = Marker; 54 | var Cluster = (function (_super) { 55 | __extends(Cluster, _super); 56 | function Cluster(marker) { 57 | var _this = _super.call(this) || this; 58 | _this.stats = [0, 0, 0, 0, 0, 0, 0, 0]; 59 | _this.data = {}; 60 | if (!marker) { 61 | _this.hashCode = 1; 62 | if (Cluster.ENABLE_MARKERS_LIST) { 63 | _this._clusterMarkers = []; 64 | } 65 | return _this; 66 | } 67 | if (Cluster.ENABLE_MARKERS_LIST) { 68 | _this._clusterMarkers = [marker]; 69 | } 70 | _this.lastMarker = marker; 71 | _this.hashCode = 31 + marker.hashCode; 72 | _this.population = 1; 73 | if (marker.category !== undefined) { 74 | _this.stats[marker.category] = 1; 75 | } 76 | _this.totalWeight = marker.weight; 77 | _this.position = { 78 | lat: marker.position.lat, 79 | lng: marker.position.lng 80 | }; 81 | _this.averagePosition = { 82 | lat: marker.position.lat, 83 | lng: marker.position.lng 84 | }; 85 | return _this; 86 | } 87 | Cluster.prototype.AddMarker = function (marker) { 88 | if (Cluster.ENABLE_MARKERS_LIST) { 89 | this._clusterMarkers.push(marker); 90 | } 91 | var h = this.hashCode; 92 | h = ((h << 5) - h) + marker.hashCode; 93 | if (h >= maxHashCodeValue) { 94 | this.hashCode = h % maxHashCodeValue; 95 | } 96 | else { 97 | this.hashCode = h; 98 | } 99 | this.lastMarker = marker; 100 | var weight = marker.weight, currentTotalWeight = this.totalWeight, newWeight = weight + currentTotalWeight; 101 | this.averagePosition.lat = 102 | (this.averagePosition.lat * currentTotalWeight + 103 | marker.position.lat * weight) / newWeight; 104 | this.averagePosition.lng = 105 | (this.averagePosition.lng * currentTotalWeight + 106 | marker.position.lng * weight) / newWeight; 107 | ++this.population; 108 | this.totalWeight = newWeight; 109 | if (marker.category !== undefined) { 110 | this.stats[marker.category] = (this.stats[marker.category] + 1) || 1; 111 | } 112 | }; 113 | Cluster.prototype.Reset = function () { 114 | this.hashCode = 1; 115 | this.lastMarker = undefined; 116 | this.population = 0; 117 | this.totalWeight = 0; 118 | this.stats = [0, 0, 0, 0, 0, 0, 0, 0]; 119 | if (Cluster.ENABLE_MARKERS_LIST) { 120 | this._clusterMarkers = []; 121 | } 122 | }; 123 | Cluster.prototype.ComputeBounds = function (cluster) { 124 | var proj = cluster.Project(this.position.lat, this.position.lng); 125 | var size = cluster.Size; 126 | var nbX = Math.floor(proj.x / size), nbY = Math.floor(proj.y / size), startX = nbX * size, startY = nbY * size; 127 | var a = cluster.UnProject(startX, startY), b = cluster.UnProject(startX + size, startY + size); 128 | this.bounds = { 129 | minLat: b.lat, 130 | maxLat: a.lat, 131 | minLng: a.lng, 132 | maxLng: b.lng 133 | }; 134 | }; 135 | Cluster.prototype.GetClusterMarkers = function () { 136 | return this._clusterMarkers; 137 | }; 138 | Cluster.prototype.ApplyCluster = function (newCluster) { 139 | this.hashCode = this.hashCode * 41 + newCluster.hashCode * 43; 140 | if (this.hashCode > maxHashCodeValue) { 141 | this.hashCode = this.hashCode = maxHashCodeValue; 142 | } 143 | var weight = newCluster.totalWeight, currentTotalWeight = this.totalWeight, newWeight = weight + currentTotalWeight; 144 | this.averagePosition.lat = 145 | (this.averagePosition.lat * currentTotalWeight + 146 | newCluster.averagePosition.lat * weight) / newWeight; 147 | this.averagePosition.lng = 148 | (this.averagePosition.lng * currentTotalWeight + 149 | newCluster.averagePosition.lng * weight) / newWeight; 150 | this.population += newCluster.population; 151 | this.totalWeight = newWeight; 152 | this.bounds.minLat = Math.min(this.bounds.minLat, newCluster.bounds.minLat); 153 | this.bounds.minLng = Math.min(this.bounds.minLng, newCluster.bounds.minLng); 154 | this.bounds.maxLat = Math.max(this.bounds.maxLat, newCluster.bounds.maxLat); 155 | this.bounds.maxLng = Math.max(this.bounds.maxLng, newCluster.bounds.maxLng); 156 | for (var category in newCluster.stats) { 157 | if (newCluster.stats.hasOwnProperty(category)) { 158 | if (this.stats.hasOwnProperty(category)) { 159 | this.stats[category] += newCluster.stats[category]; 160 | } 161 | else { 162 | this.stats[category] = newCluster.stats[category]; 163 | } 164 | } 165 | } 166 | if (Cluster.ENABLE_MARKERS_LIST) { 167 | this._clusterMarkers = this._clusterMarkers.concat(newCluster.GetClusterMarkers()); 168 | } 169 | }; 170 | Cluster.ENABLE_MARKERS_LIST = false; 171 | return Cluster; 172 | }(ClusterObject)); 173 | PruneCluster_1.Cluster = Cluster; 174 | function checkPositionInsideBounds(a, b) { 175 | return (a.lat >= b.minLat && a.lat <= b.maxLat) && 176 | a.lng >= b.minLng && a.lng <= b.maxLng; 177 | } 178 | function insertionSort(list) { 179 | for (var i = 1, j, tmp, tmpLng, length = list.length; i < length; ++i) { 180 | tmp = list[i]; 181 | tmpLng = tmp.position.lng; 182 | for (j = i - 1; j >= 0 && list[j].position.lng > tmpLng; --j) { 183 | list[j + 1] = list[j]; 184 | } 185 | list[j + 1] = tmp; 186 | } 187 | } 188 | function shouldUseInsertionSort(total, nbChanges) { 189 | if (nbChanges > 300) { 190 | return false; 191 | } 192 | else { 193 | return (nbChanges / total) < 0.2; 194 | } 195 | } 196 | var PruneCluster = (function () { 197 | function PruneCluster() { 198 | this._markers = []; 199 | this._nbChanges = 0; 200 | this._clusters = []; 201 | this.Size = 166; 202 | this.ViewPadding = 0.2; 203 | } 204 | PruneCluster.prototype.RegisterMarker = function (marker) { 205 | if (marker._removeFlag) { 206 | delete marker._removeFlag; 207 | } 208 | this._markers.push(marker); 209 | this._nbChanges += 1; 210 | }; 211 | PruneCluster.prototype.RegisterMarkers = function (markers) { 212 | var _this = this; 213 | markers.forEach(function (marker) { 214 | _this.RegisterMarker(marker); 215 | }); 216 | }; 217 | PruneCluster.prototype._sortMarkers = function () { 218 | var markers = this._markers, length = markers.length; 219 | if (this._nbChanges && !shouldUseInsertionSort(length, this._nbChanges)) { 220 | this._markers.sort(function (a, b) { return a.position.lng - b.position.lng; }); 221 | } 222 | else { 223 | insertionSort(markers); 224 | } 225 | this._nbChanges = 0; 226 | }; 227 | PruneCluster.prototype._sortClusters = function () { 228 | insertionSort(this._clusters); 229 | }; 230 | PruneCluster.prototype._indexLowerBoundLng = function (lng) { 231 | var markers = this._markers, it, step, first = 0, count = markers.length; 232 | while (count > 0) { 233 | step = Math.floor(count / 2); 234 | it = first + step; 235 | if (markers[it].position.lng < lng) { 236 | first = ++it; 237 | count -= step + 1; 238 | } 239 | else { 240 | count = step; 241 | } 242 | } 243 | return first; 244 | }; 245 | PruneCluster.prototype._resetClusterViews = function () { 246 | for (var i = 0, l = this._clusters.length; i < l; ++i) { 247 | var cluster = this._clusters[i]; 248 | cluster.Reset(); 249 | cluster.ComputeBounds(this); 250 | } 251 | }; 252 | PruneCluster.prototype.ProcessView = function (bounds) { 253 | var heightBuffer = Math.abs(bounds.maxLat - bounds.minLat) * this.ViewPadding, widthBuffer = Math.abs(bounds.maxLng - bounds.minLng) * this.ViewPadding; 254 | var extendedBounds = { 255 | minLat: bounds.minLat - heightBuffer - heightBuffer, 256 | maxLat: bounds.maxLat + heightBuffer + heightBuffer, 257 | minLng: bounds.minLng - widthBuffer - widthBuffer, 258 | maxLng: bounds.maxLng + widthBuffer + widthBuffer 259 | }; 260 | this._sortMarkers(); 261 | this._resetClusterViews(); 262 | var firstIndex = this._indexLowerBoundLng(extendedBounds.minLng); 263 | var markers = this._markers, clusters = this._clusters; 264 | var workingClusterList = clusters.slice(0); 265 | for (var i = firstIndex, l = markers.length; i < l; ++i) { 266 | var marker = markers[i], markerPosition = marker.position; 267 | if (markerPosition.lng > extendedBounds.maxLng) { 268 | break; 269 | } 270 | if (markerPosition.lat > extendedBounds.minLat && 271 | markerPosition.lat < extendedBounds.maxLat && 272 | !marker.filtered) { 273 | var clusterFound = false, cluster; 274 | for (var j = 0, ll = workingClusterList.length; j < ll; ++j) { 275 | cluster = workingClusterList[j]; 276 | if (cluster.bounds.maxLng < marker.position.lng) { 277 | workingClusterList.splice(j, 1); 278 | --j; 279 | --ll; 280 | continue; 281 | } 282 | if (checkPositionInsideBounds(markerPosition, cluster.bounds)) { 283 | cluster.AddMarker(marker); 284 | clusterFound = true; 285 | break; 286 | } 287 | } 288 | if (!clusterFound) { 289 | cluster = new Cluster(marker); 290 | cluster.ComputeBounds(this); 291 | clusters.push(cluster); 292 | workingClusterList.push(cluster); 293 | } 294 | } 295 | } 296 | var newClustersList = []; 297 | for (i = 0, l = clusters.length; i < l; ++i) { 298 | cluster = clusters[i]; 299 | if (cluster.population > 0) { 300 | newClustersList.push(cluster); 301 | } 302 | } 303 | this._clusters = newClustersList; 304 | this._sortClusters(); 305 | return this._clusters; 306 | }; 307 | PruneCluster.prototype.RemoveMarkers = function (markers) { 308 | if (!markers) { 309 | this._markers = []; 310 | return; 311 | } 312 | for (var i = 0, l = markers.length; i < l; ++i) { 313 | markers[i]._removeFlag = true; 314 | } 315 | var newMarkersList = []; 316 | for (i = 0, l = this._markers.length; i < l; ++i) { 317 | if (!this._markers[i]._removeFlag) { 318 | newMarkersList.push(this._markers[i]); 319 | } 320 | else { 321 | delete this._markers[i]._removeFlag; 322 | } 323 | } 324 | this._markers = newMarkersList; 325 | }; 326 | PruneCluster.prototype.FindMarkersInArea = function (area) { 327 | var aMinLat = area.minLat, aMaxLat = area.maxLat, aMinLng = area.minLng, aMaxLng = area.maxLng, markers = this._markers, result = []; 328 | var firstIndex = this._indexLowerBoundLng(aMinLng); 329 | for (var i = firstIndex, l = markers.length; i < l; ++i) { 330 | var pos = markers[i].position; 331 | if (pos.lng > aMaxLng) { 332 | break; 333 | } 334 | if (pos.lat >= aMinLat && pos.lat <= aMaxLat && 335 | pos.lng >= aMinLng) { 336 | result.push(markers[i]); 337 | } 338 | } 339 | return result; 340 | }; 341 | PruneCluster.prototype.ComputeBounds = function (markers, withFiltered) { 342 | if (withFiltered === void 0) { withFiltered = true; } 343 | if (!markers || !markers.length) { 344 | return null; 345 | } 346 | var rMinLat = Number.MAX_VALUE, rMaxLat = -Number.MAX_VALUE, rMinLng = Number.MAX_VALUE, rMaxLng = -Number.MAX_VALUE; 347 | for (var i = 0, l = markers.length; i < l; ++i) { 348 | if (!withFiltered && markers[i].filtered) { 349 | continue; 350 | } 351 | var pos = markers[i].position; 352 | if (pos.lat < rMinLat) 353 | rMinLat = pos.lat; 354 | if (pos.lat > rMaxLat) 355 | rMaxLat = pos.lat; 356 | if (pos.lng < rMinLng) 357 | rMinLng = pos.lng; 358 | if (pos.lng > rMaxLng) 359 | rMaxLng = pos.lng; 360 | } 361 | return { 362 | minLat: rMinLat, 363 | maxLat: rMaxLat, 364 | minLng: rMinLng, 365 | maxLng: rMaxLng 366 | }; 367 | }; 368 | PruneCluster.prototype.FindMarkersBoundsInArea = function (area) { 369 | return this.ComputeBounds(this.FindMarkersInArea(area)); 370 | }; 371 | PruneCluster.prototype.ComputeGlobalBounds = function (withFiltered) { 372 | if (withFiltered === void 0) { withFiltered = true; } 373 | return this.ComputeBounds(this._markers, withFiltered); 374 | }; 375 | PruneCluster.prototype.GetMarkers = function () { 376 | return this._markers; 377 | }; 378 | PruneCluster.prototype.GetPopulation = function () { 379 | return this._markers.length; 380 | }; 381 | PruneCluster.prototype.ResetClusters = function () { 382 | this._clusters = []; 383 | }; 384 | return PruneCluster; 385 | }()); 386 | PruneCluster_1.PruneCluster = PruneCluster; 387 | })(PruneCluster || (PruneCluster = {})); 388 | var PruneCluster; 389 | (function (PruneCluster) { 390 | })(PruneCluster || (PruneCluster = {})); 391 | var PruneClusterForLeaflet = (L.Layer ? L.Layer : L.Class).extend({ 392 | initialize: function (size, clusterMargin) { 393 | var _this = this; 394 | if (size === void 0) { size = 120; } 395 | if (clusterMargin === void 0) { clusterMargin = 20; } 396 | this.Cluster = new PruneCluster.PruneCluster(); 397 | this.Cluster.Size = size; 398 | this.clusterMargin = Math.min(clusterMargin, size / 4); 399 | this.Cluster.Project = function (lat, lng) { 400 | return _this._map.project(new L.LatLng(lat, lng), Math.floor(_this._map.getZoom())); 401 | }; 402 | this.Cluster.UnProject = function (x, y) { 403 | return _this._map.unproject(new L.Point(x, y), Math.floor(_this._map.getZoom())); 404 | }; 405 | this._objectsOnMap = []; 406 | this.spiderfier = new PruneClusterLeafletSpiderfier(this); 407 | this._hardMove = false; 408 | this._resetIcons = false; 409 | this._removeTimeoutId = 0; 410 | this._markersRemoveListTimeout = []; 411 | }, 412 | RegisterMarker: function (marker) { 413 | this.Cluster.RegisterMarker(marker); 414 | }, 415 | RegisterMarkers: function (markers) { 416 | this.Cluster.RegisterMarkers(markers); 417 | }, 418 | RemoveMarkers: function (markers) { 419 | this.Cluster.RemoveMarkers(markers); 420 | }, 421 | BuildLeafletCluster: function (cluster, position) { 422 | var _this = this; 423 | var m = new L.Marker(position, { 424 | icon: this.BuildLeafletClusterIcon(cluster) 425 | }); 426 | m._leafletClusterBounds = cluster.bounds; 427 | m.on('click', function () { 428 | var cbounds = m._leafletClusterBounds; 429 | var markersArea = _this.Cluster.FindMarkersInArea(cbounds); 430 | var b = _this.Cluster.ComputeBounds(markersArea); 431 | if (b) { 432 | var bounds = new L.LatLngBounds(new L.LatLng(b.minLat, b.maxLng), new L.LatLng(b.maxLat, b.minLng)); 433 | var zoomLevelBefore = _this._map.getZoom(), zoomLevelAfter = _this._map.getBoundsZoom(bounds, false, new L.Point(20, 20)); 434 | if (zoomLevelAfter === zoomLevelBefore) { 435 | var filteredBounds = []; 436 | for (var i = 0, l = _this._objectsOnMap.length; i < l; ++i) { 437 | var o = _this._objectsOnMap[i]; 438 | if (o.data._leafletMarker !== m) { 439 | if (o.bounds.minLat >= cbounds.minLat && 440 | o.bounds.maxLat <= cbounds.maxLat && 441 | o.bounds.minLng >= cbounds.minLng && 442 | o.bounds.maxLng <= cbounds.maxLng) { 443 | filteredBounds.push(o.bounds); 444 | } 445 | } 446 | } 447 | if (filteredBounds.length > 0) { 448 | var newMarkersArea = []; 449 | var ll = filteredBounds.length; 450 | for (i = 0, l = markersArea.length; i < l; ++i) { 451 | var markerPos = markersArea[i].position; 452 | var isFiltered = false; 453 | for (var j = 0; j < ll; ++j) { 454 | var currentFilteredBounds = filteredBounds[j]; 455 | if (markerPos.lat >= currentFilteredBounds.minLat && 456 | markerPos.lat <= currentFilteredBounds.maxLat && 457 | markerPos.lng >= currentFilteredBounds.minLng && 458 | markerPos.lng <= currentFilteredBounds.maxLng) { 459 | isFiltered = true; 460 | break; 461 | } 462 | } 463 | if (!isFiltered) { 464 | newMarkersArea.push(markersArea[i]); 465 | } 466 | } 467 | markersArea = newMarkersArea; 468 | } 469 | if (markersArea.length < 200 || zoomLevelAfter >= _this._map.getMaxZoom()) { 470 | _this._map.fire('overlappingmarkers', { 471 | cluster: _this, 472 | markers: markersArea, 473 | center: m.getLatLng(), 474 | marker: m 475 | }); 476 | } 477 | else { 478 | zoomLevelAfter++; 479 | } 480 | _this._map.setView(m.getLatLng(), zoomLevelAfter); 481 | } 482 | else { 483 | _this._map.fitBounds(bounds); 484 | } 485 | } 486 | }); 487 | return m; 488 | }, 489 | BuildLeafletClusterIcon: function (cluster) { 490 | var c = 'prunecluster prunecluster-'; 491 | var iconSize = 38; 492 | var maxPopulation = this.Cluster.GetPopulation(); 493 | if (cluster.population < Math.max(10, maxPopulation * 0.01)) { 494 | c += 'small'; 495 | } 496 | else if (cluster.population < Math.max(100, maxPopulation * 0.05)) { 497 | c += 'medium'; 498 | iconSize = 40; 499 | } 500 | else { 501 | c += 'large'; 502 | iconSize = 44; 503 | } 504 | return new L.DivIcon({ 505 | html: "
" + cluster.population + "
", 506 | className: c, 507 | iconSize: L.point(iconSize, iconSize) 508 | }); 509 | }, 510 | BuildLeafletMarker: function (marker, position) { 511 | var m = new L.Marker(position); 512 | this.PrepareLeafletMarker(m, marker.data, marker.category); 513 | return m; 514 | }, 515 | PrepareLeafletMarker: function (marker, data, category) { 516 | if (data.icon) { 517 | if (typeof data.icon === 'function') { 518 | marker.setIcon(data.icon(data, category)); 519 | } 520 | else { 521 | marker.setIcon(data.icon); 522 | } 523 | } 524 | if (data.popup) { 525 | var content = typeof data.popup === 'function' ? data.popup(data, category) : data.popup; 526 | if (marker.getPopup()) { 527 | marker.setPopupContent(content, data.popupOptions); 528 | } 529 | else { 530 | marker.bindPopup(content, data.popupOptions); 531 | } 532 | } 533 | }, 534 | onAdd: function (map) { 535 | this._map = map; 536 | map.on('movestart', this._moveStart, this); 537 | map.on('moveend', this._moveEnd, this); 538 | map.on('zoomend', this._zoomStart, this); 539 | map.on('zoomend', this._zoomEnd, this); 540 | this.ProcessView(); 541 | map.addLayer(this.spiderfier); 542 | }, 543 | onRemove: function (map) { 544 | map.off('movestart', this._moveStart, this); 545 | map.off('moveend', this._moveEnd, this); 546 | map.off('zoomend', this._zoomStart, this); 547 | map.off('zoomend', this._zoomEnd, this); 548 | for (var i = 0, l = this._objectsOnMap.length; i < l; ++i) { 549 | map.removeLayer(this._objectsOnMap[i].data._leafletMarker); 550 | } 551 | this._objectsOnMap = []; 552 | this.Cluster.ResetClusters(); 553 | map.removeLayer(this.spiderfier); 554 | this._map = null; 555 | }, 556 | _moveStart: function () { 557 | this._moveInProgress = true; 558 | }, 559 | _moveEnd: function (e) { 560 | this._moveInProgress = false; 561 | this._hardMove = e.hard; 562 | this.ProcessView(); 563 | }, 564 | _zoomStart: function () { 565 | this._zoomInProgress = true; 566 | }, 567 | _zoomEnd: function () { 568 | this._zoomInProgress = false; 569 | this.ProcessView(); 570 | }, 571 | ProcessView: function () { 572 | var _this = this; 573 | if (!this._map || this._zoomInProgress || this._moveInProgress) { 574 | return; 575 | } 576 | var map = this._map, bounds = map.getBounds(), zoom = Math.floor(map.getZoom()), marginRatio = this.clusterMargin / this.Cluster.Size, resetIcons = this._resetIcons; 577 | var southWest = bounds.getSouthWest(), northEast = bounds.getNorthEast(); 578 | var clusters = this.Cluster.ProcessView({ 579 | minLat: southWest.lat, 580 | minLng: southWest.lng, 581 | maxLat: northEast.lat, 582 | maxLng: northEast.lng 583 | }); 584 | var objectsOnMap = this._objectsOnMap, newObjectsOnMap = [], markersOnMap = new Array(objectsOnMap.length); 585 | for (var i = 0, l = objectsOnMap.length; i < l; ++i) { 586 | var marker = objectsOnMap[i].data._leafletMarker; 587 | markersOnMap[i] = marker; 588 | marker._removeFromMap = true; 589 | } 590 | var clusterCreationList = []; 591 | var clusterCreationListPopOne = []; 592 | var opacityUpdateList = []; 593 | var workingList = []; 594 | for (i = 0, l = clusters.length; i < l; ++i) { 595 | var icluster = clusters[i], iclusterData = icluster.data; 596 | var latMargin = (icluster.bounds.maxLat - icluster.bounds.minLat) * marginRatio, lngMargin = (icluster.bounds.maxLng - icluster.bounds.minLng) * marginRatio; 597 | for (var j = 0, ll = workingList.length; j < ll; ++j) { 598 | var c = workingList[j]; 599 | if (c.bounds.maxLng < icluster.bounds.minLng) { 600 | workingList.splice(j, 1); 601 | --j; 602 | --ll; 603 | continue; 604 | } 605 | var oldMaxLng = c.averagePosition.lng + lngMargin, oldMinLat = c.averagePosition.lat - latMargin, oldMaxLat = c.averagePosition.lat + latMargin, newMinLng = icluster.averagePosition.lng - lngMargin, newMinLat = icluster.averagePosition.lat - latMargin, newMaxLat = icluster.averagePosition.lat + latMargin; 606 | if (oldMaxLng > newMinLng && oldMaxLat > newMinLat && oldMinLat < newMaxLat) { 607 | iclusterData._leafletCollision = true; 608 | c.ApplyCluster(icluster); 609 | break; 610 | } 611 | } 612 | if (!iclusterData._leafletCollision) { 613 | workingList.push(icluster); 614 | } 615 | } 616 | clusters.forEach(function (cluster) { 617 | var m = undefined; 618 | var data = cluster.data; 619 | if (data._leafletCollision) { 620 | data._leafletCollision = false; 621 | data._leafletOldPopulation = 0; 622 | data._leafletOldHashCode = 0; 623 | return; 624 | } 625 | var position = new L.LatLng(cluster.averagePosition.lat, cluster.averagePosition.lng); 626 | var oldMarker = data._leafletMarker; 627 | if (oldMarker) { 628 | if (cluster.population === 1 && data._leafletOldPopulation === 1 && cluster.hashCode === oldMarker._hashCode) { 629 | if (resetIcons || oldMarker._zoomLevel !== zoom || cluster.lastMarker.data.forceIconRedraw) { 630 | _this.PrepareLeafletMarker(oldMarker, cluster.lastMarker.data, cluster.lastMarker.category); 631 | if (cluster.lastMarker.data.forceIconRedraw) { 632 | cluster.lastMarker.data.forceIconRedraw = false; 633 | } 634 | } 635 | oldMarker.setLatLng(position); 636 | m = oldMarker; 637 | } 638 | else if (cluster.population > 1 && data._leafletOldPopulation > 1 && (oldMarker._zoomLevel === zoom || 639 | data._leafletPosition.equals(position))) { 640 | oldMarker.setLatLng(position); 641 | if (resetIcons || cluster.population != data._leafletOldPopulation || 642 | cluster.hashCode !== data._leafletOldHashCode) { 643 | var boundsCopy = {}; 644 | L.Util.extend(boundsCopy, cluster.bounds); 645 | oldMarker._leafletClusterBounds = boundsCopy; 646 | oldMarker.setIcon(_this.BuildLeafletClusterIcon(cluster)); 647 | } 648 | data._leafletOldPopulation = cluster.population; 649 | data._leafletOldHashCode = cluster.hashCode; 650 | m = oldMarker; 651 | } 652 | } 653 | if (!m) { 654 | if (cluster.population === 1) { 655 | clusterCreationListPopOne.push(cluster); 656 | } 657 | else { 658 | clusterCreationList.push(cluster); 659 | } 660 | data._leafletPosition = position; 661 | data._leafletOldPopulation = cluster.population; 662 | data._leafletOldHashCode = cluster.hashCode; 663 | } 664 | else { 665 | m._removeFromMap = false; 666 | newObjectsOnMap.push(cluster); 667 | m._zoomLevel = zoom; 668 | m._hashCode = cluster.hashCode; 669 | m._population = cluster.population; 670 | data._leafletMarker = m; 671 | data._leafletPosition = position; 672 | } 673 | }); 674 | clusterCreationList = clusterCreationListPopOne.concat(clusterCreationList); 675 | for (i = 0, l = objectsOnMap.length; i < l; ++i) { 676 | icluster = objectsOnMap[i]; 677 | var idata = icluster.data; 678 | marker = idata._leafletMarker; 679 | if (idata._leafletMarker._removeFromMap) { 680 | var remove = true; 681 | if (marker._zoomLevel === zoom) { 682 | var pa = icluster.averagePosition; 683 | latMargin = (icluster.bounds.maxLat - icluster.bounds.minLat) * marginRatio, 684 | lngMargin = (icluster.bounds.maxLng - icluster.bounds.minLng) * marginRatio; 685 | for (j = 0, ll = clusterCreationList.length; j < ll; ++j) { 686 | var jcluster = clusterCreationList[j], jdata = jcluster.data; 687 | if (marker._population === 1 && jcluster.population === 1 && 688 | marker._hashCode === jcluster.hashCode) { 689 | if (resetIcons || jcluster.lastMarker.data.forceIconRedraw) { 690 | this.PrepareLeafletMarker(marker, jcluster.lastMarker.data, jcluster.lastMarker.category); 691 | if (jcluster.lastMarker.data.forceIconRedraw) { 692 | jcluster.lastMarker.data.forceIconRedraw = false; 693 | } 694 | } 695 | marker.setLatLng(jdata._leafletPosition); 696 | remove = false; 697 | } 698 | else { 699 | var pb = jcluster.averagePosition; 700 | var oldMinLng = pa.lng - lngMargin, newMaxLng = pb.lng + lngMargin; 701 | oldMaxLng = pa.lng + lngMargin; 702 | oldMinLat = pa.lat - latMargin; 703 | oldMaxLat = pa.lat + latMargin; 704 | newMinLng = pb.lng - lngMargin; 705 | newMinLat = pb.lat - latMargin; 706 | newMaxLat = pb.lat + latMargin; 707 | if ((marker._population > 1 && jcluster.population > 1) && 708 | (oldMaxLng > newMinLng && oldMinLng < newMaxLng && oldMaxLat > newMinLat && oldMinLat < newMaxLat)) { 709 | marker.setLatLng(jdata._leafletPosition); 710 | marker.setIcon(this.BuildLeafletClusterIcon(jcluster)); 711 | var poisson = {}; 712 | L.Util.extend(poisson, jcluster.bounds); 713 | marker._leafletClusterBounds = poisson; 714 | jdata._leafletOldPopulation = jcluster.population; 715 | jdata._leafletOldHashCode = jcluster.hashCode; 716 | marker._population = jcluster.population; 717 | remove = false; 718 | } 719 | } 720 | if (!remove) { 721 | jdata._leafletMarker = marker; 722 | marker._removeFromMap = false; 723 | newObjectsOnMap.push(jcluster); 724 | clusterCreationList.splice(j, 1); 725 | --j; 726 | --ll; 727 | break; 728 | } 729 | } 730 | } 731 | if (remove) { 732 | if (!marker._removeFromMap) 733 | console.error("wtf"); 734 | } 735 | } 736 | } 737 | for (i = 0, l = clusterCreationList.length; i < l; ++i) { 738 | icluster = clusterCreationList[i], 739 | idata = icluster.data; 740 | var iposition = idata._leafletPosition; 741 | var creationMarker; 742 | if (icluster.population === 1) { 743 | creationMarker = this.BuildLeafletMarker(icluster.lastMarker, iposition); 744 | } 745 | else { 746 | creationMarker = this.BuildLeafletCluster(icluster, iposition); 747 | } 748 | creationMarker.addTo(map); 749 | creationMarker.setOpacity(0); 750 | opacityUpdateList.push(creationMarker); 751 | idata._leafletMarker = creationMarker; 752 | creationMarker._zoomLevel = zoom; 753 | creationMarker._hashCode = icluster.hashCode; 754 | creationMarker._population = icluster.population; 755 | newObjectsOnMap.push(icluster); 756 | } 757 | window.setTimeout(function () { 758 | for (i = 0, l = opacityUpdateList.length; i < l; ++i) { 759 | var m = opacityUpdateList[i]; 760 | if (m._icon) 761 | L.DomUtil.addClass(m._icon, "prunecluster-anim"); 762 | if (m._shadow) 763 | L.DomUtil.addClass(m._shadow, "prunecluster-anim"); 764 | m.setOpacity(1); 765 | } 766 | }, 1); 767 | if (this._hardMove) { 768 | for (i = 0, l = markersOnMap.length; i < l; ++i) { 769 | marker = markersOnMap[i]; 770 | if (marker._removeFromMap) { 771 | map.removeLayer(marker); 772 | } 773 | } 774 | } 775 | else { 776 | if (this._removeTimeoutId !== 0) { 777 | window.clearTimeout(this._removeTimeoutId); 778 | for (i = 0, l = this._markersRemoveListTimeout.length; i < l; ++i) { 779 | map.removeLayer(this._markersRemoveListTimeout[i]); 780 | } 781 | } 782 | var toRemove = []; 783 | for (i = 0, l = markersOnMap.length; i < l; ++i) { 784 | marker = markersOnMap[i]; 785 | if (marker._removeFromMap) { 786 | marker.setOpacity(0); 787 | toRemove.push(marker); 788 | } 789 | } 790 | if (toRemove.length > 0) { 791 | this._removeTimeoutId = window.setTimeout(function () { 792 | for (i = 0, l = toRemove.length; i < l; ++i) { 793 | map.removeLayer(toRemove[i]); 794 | } 795 | _this._removeTimeoutId = 0; 796 | }, 300); 797 | } 798 | this._markersRemoveListTimeout = toRemove; 799 | } 800 | this._objectsOnMap = newObjectsOnMap; 801 | this._hardMove = false; 802 | this._resetIcons = false; 803 | }, 804 | FitBounds: function (withFiltered) { 805 | if (withFiltered === void 0) { withFiltered = true; } 806 | var bounds = this.Cluster.ComputeGlobalBounds(withFiltered); 807 | if (bounds) { 808 | this._map.fitBounds(new L.LatLngBounds(new L.LatLng(bounds.minLat, bounds.maxLng), new L.LatLng(bounds.maxLat, bounds.minLng))); 809 | } 810 | }, 811 | GetMarkers: function () { 812 | return this.Cluster.GetMarkers(); 813 | }, 814 | RedrawIcons: function (processView) { 815 | if (processView === void 0) { processView = true; } 816 | this._resetIcons = true; 817 | if (processView) { 818 | this.ProcessView(); 819 | } 820 | } 821 | }); 822 | var PruneClusterLeafletSpiderfier = (L.Layer ? L.Layer : L.Class).extend({ 823 | _2PI: Math.PI * 2, 824 | _circleFootSeparation: 25, 825 | _circleStartAngle: Math.PI / 6, 826 | _spiralFootSeparation: 28, 827 | _spiralLengthStart: 11, 828 | _spiralLengthFactor: 5, 829 | _spiralCountTrigger: 8, 830 | spiderfyDistanceMultiplier: 1, 831 | initialize: function (cluster) { 832 | this._cluster = cluster; 833 | this._currentMarkers = []; 834 | this._multiLines = !!L.multiPolyline; 835 | this._lines = this._multiLines ? 836 | L.multiPolyline([], { weight: 1.5, color: '#222' }) : 837 | L.polyline([], { weight: 1.5, color: '#222' }); 838 | }, 839 | onAdd: function (map) { 840 | this._map = map; 841 | this._map.on('overlappingmarkers', this.Spiderfy, this); 842 | this._map.on('click', this.Unspiderfy, this); 843 | this._map.on('zoomend', this.Unspiderfy, this); 844 | }, 845 | Spiderfy: function (data) { 846 | var _this = this; 847 | if (data.cluster !== this._cluster) { 848 | return; 849 | } 850 | this.Unspiderfy(); 851 | var markers = data.markers.filter(function (marker) { 852 | return !marker.filtered; 853 | }); 854 | this._currentCenter = data.center; 855 | var centerPoint = this._map.latLngToLayerPoint(data.center); 856 | var points; 857 | if (markers.length >= this._spiralCountTrigger) { 858 | points = this._generatePointsSpiral(markers.length, centerPoint); 859 | } 860 | else { 861 | if (this._multiLines) { 862 | centerPoint.y += 10; 863 | } 864 | points = this._generatePointsCircle(markers.length, centerPoint); 865 | } 866 | var polylines = []; 867 | var leafletMarkers = []; 868 | var projectedPoints = []; 869 | for (var i = 0, l = points.length; i < l; ++i) { 870 | var pos = this._map.layerPointToLatLng(points[i]); 871 | var m = this._cluster.BuildLeafletMarker(markers[i], data.center); 872 | m.setZIndexOffset(5000); 873 | m.setOpacity(0); 874 | this._currentMarkers.push(m); 875 | this._map.addLayer(m); 876 | leafletMarkers.push(m); 877 | projectedPoints.push(pos); 878 | } 879 | window.setTimeout(function () { 880 | for (i = 0, l = points.length; i < l; ++i) { 881 | leafletMarkers[i].setLatLng(projectedPoints[i]) 882 | .setOpacity(1); 883 | } 884 | var startTime = +new Date(); 885 | var interval = 42, duration = 290; 886 | var anim = window.setInterval(function () { 887 | polylines = []; 888 | var now = +new Date(); 889 | var d = now - startTime; 890 | if (d >= duration) { 891 | window.clearInterval(anim); 892 | stepRatio = 1.0; 893 | } 894 | else { 895 | var stepRatio = d / duration; 896 | } 897 | var center = data.center; 898 | for (i = 0, l = points.length; i < l; ++i) { 899 | var p = projectedPoints[i], diffLat = p.lat - center.lat, diffLng = p.lng - center.lng; 900 | polylines.push([center, new L.LatLng(center.lat + diffLat * stepRatio, center.lng + diffLng * stepRatio)]); 901 | } 902 | _this._lines.setLatLngs(polylines); 903 | }, interval); 904 | }, 1); 905 | this._lines.setLatLngs(polylines); 906 | this._map.addLayer(this._lines); 907 | if (data.marker) { 908 | this._clusterMarker = data.marker.setOpacity(0.3); 909 | } 910 | }, 911 | _generatePointsCircle: function (count, centerPt) { 912 | var circumference = this.spiderfyDistanceMultiplier * this._circleFootSeparation * (2 + count), legLength = circumference / this._2PI, angleStep = this._2PI / count, res = [], i, angle; 913 | res.length = count; 914 | for (i = count - 1; i >= 0; i--) { 915 | angle = this._circleStartAngle + i * angleStep; 916 | res[i] = new L.Point(Math.round(centerPt.x + legLength * Math.cos(angle)), Math.round(centerPt.y + legLength * Math.sin(angle))); 917 | } 918 | return res; 919 | }, 920 | _generatePointsSpiral: function (count, centerPt) { 921 | var legLength = this.spiderfyDistanceMultiplier * this._spiralLengthStart, separation = this.spiderfyDistanceMultiplier * this._spiralFootSeparation, lengthFactor = this.spiderfyDistanceMultiplier * this._spiralLengthFactor, angle = 0, res = [], i; 922 | res.length = count; 923 | for (i = count - 1; i >= 0; i--) { 924 | angle += separation / legLength + i * 0.0005; 925 | res[i] = new L.Point(Math.round(centerPt.x + legLength * Math.cos(angle)), Math.round(centerPt.y + legLength * Math.sin(angle))); 926 | legLength += this._2PI * lengthFactor / angle; 927 | } 928 | return res; 929 | }, 930 | Unspiderfy: function () { 931 | var _this = this; 932 | for (var i = 0, l = this._currentMarkers.length; i < l; ++i) { 933 | this._currentMarkers[i].setLatLng(this._currentCenter).setOpacity(0); 934 | } 935 | var markers = this._currentMarkers; 936 | window.setTimeout(function () { 937 | for (i = 0, l = markers.length; i < l; ++i) { 938 | _this._map.removeLayer(markers[i]); 939 | } 940 | }, 300); 941 | this._currentMarkers = []; 942 | this._map.removeLayer(this._lines); 943 | if (this._clusterMarker) { 944 | this._clusterMarker.setOpacity(1); 945 | } 946 | }, 947 | onRemove: function (map) { 948 | this.Unspiderfy(); 949 | map.off('overlappingmarkers', this.Spiderfy, this); 950 | map.off('click', this.Unspiderfy, this); 951 | map.off('zoomend', this.Unspiderfy, this); 952 | } 953 | }); 954 | -------------------------------------------------------------------------------- /examples/airplane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SINTEF-9012/PruneCluster/f96bf4ee2c5025d099ef9e0ebdbe0425a0d4c748/examples/airplane.png -------------------------------------------------------------------------------- /examples/bug-107.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Bug 107 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /examples/bug-11.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | Switch 18 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /examples/bug-18.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | Switch 18 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /examples/bug-23.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /examples/bug-87.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /examples/examples.css: -------------------------------------------------------------------------------- 1 | @import url('../dist/LeafletStyleSheet.css'); 2 | 3 | html, body, #map { 4 | width:100%; 5 | height:100%; 6 | margin: 0; 7 | padding: 0; 8 | font-family: sans-serif; 9 | } 10 | 11 | div#size, a#delete { 12 | position: absolute; 13 | right: 1em; 14 | top: 1em; 15 | background: white; 16 | color: black; 17 | padding: 0.4em; 18 | border-radius: 4px; 19 | z-index: 500; 20 | } 21 | -------------------------------------------------------------------------------- /examples/helicopter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SINTEF-9012/PruneCluster/f96bf4ee2c5025d099ef9e0ebdbe0425a0d4c748/examples/helicopter.png -------------------------------------------------------------------------------- /examples/json example/diwalipretty.json.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SINTEF-9012/PruneCluster/f96bf4ee2c5025d099ef9e0ebdbe0425a0d4c748/examples/json example/diwalipretty.json.zip -------------------------------------------------------------------------------- /examples/json example/heatmap.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Heatmap with Leaflet 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
Cluster size: 160
21 | 90 | 91 | -------------------------------------------------------------------------------- /examples/json example/instructions.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf1504\cocoasubrtf600 2 | {\fonttbl\f0\fswiss\fcharset0 Helvetica;} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;\csgray\c100000;} 5 | \margl1440\margr1440\vieww10800\viewh8400\viewkind0 6 | \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 7 | 8 | \f0\fs24 \cf0 Instructions for using this demo:\ 9 | \ 10 | 1. You need to unzip the json file for the demo html to work\ 11 | 2. This json file is massive and has over 3,000,000 features in it. It takes my MacBook about 1 minute to load the web page - but once loaded, it works seamlessly. \ 12 | 3. If you\'92d like to switch it out for your own file, edit the html accordingly. The code is quite simple :) and smaller json files will work far more quickly than my massive one.\ 13 | 4. In case you\'92re curious, the json file included is the complete history of all \'93nodes\'94 mapped in Open Street Maps until 2015 in Nepal. Enjoy!} -------------------------------------------------------------------------------- /examples/moving.1.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /examples/moving.10.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /examples/random.100-icons.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /examples/random.100-popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /examples/random.100.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /examples/random.1000.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/random.10000-categories-2.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /examples/random.10000-categories.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /examples/random.10000-control.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /examples/random.10000-delete.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | Delete 20 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /examples/random.10000-filtering.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
Filtering:
20 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /examples/random.10000-fractionalzoom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
Zoom:
20 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /examples/random.10000-getclustermarkers.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /examples/random.10000-reseticons.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | Reset icons 20 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /examples/random.10000-size.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
Cluster size: 160
20 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /examples/random.10000-spiderfier.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/random.10000.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/random.1000000.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/random.150000.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/random.60000.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/realworld.50000-categories.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /examples/realworld.50000.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | PruneCluster - Realworld 50k 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /meteor/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Packaging [PruneCluster](https://github.com/SINTEF-9012/PruneCluster) for [Meteor.js](http://meteor.com). 4 | 5 | 6 | # Meteor 7 | 8 | If you're new to Meteor, here's what the excitement is all about - 9 | [watch the first two minutes](https://www.youtube.com/watch?v=fsi0aJ9yr2o); you'll be hooked by 1:28. 10 | 11 | That screencast is from 2012. In the meantime, Meteor has become a mature JavaScript-everywhere web 12 | development framework. Read more at [Why Meteor](http://www.meteorpedia.com/read/Why_Meteor). 13 | 14 | 15 | # Issues 16 | 17 | If you encounter an issue while using this package, please CC @mordka when you file it in this repo. 18 | 19 | 20 | # DONE 21 | 22 | 23 | * Instantiation test 24 | 25 | 26 | # TODO 27 | 28 | * Merge with upstream -------------------------------------------------------------------------------- /meteor/package.js: -------------------------------------------------------------------------------- 1 | // package metadata file for Meteor.js 2 | 'use strict'; 3 | 4 | var packageName = 'prunecluster:prunecluster'; 5 | var where = 'client'; 6 | var packageJson = JSON.parse(Npm.require("fs").readFileSync('package.json')); 7 | 8 | Package.describe({ 9 | name: packageName, 10 | version: packageJson.version, 11 | summary: 'PruneCluster: add fast, realtime marker clustering to Leaflet, with low memory footprint', 12 | git: 'https://github.com/SINTEF-9012/PruneCluster.git' 13 | }); 14 | 15 | Package.onUse(function(api) { 16 | api.versionsFrom(['METEOR@0.9.0', 'METEOR@1.0']); 17 | api.use(["bevanhunt:leaflet@1.0.3"]); 18 | api.export("PruneCluster",where); 19 | api.export("PruneClusterForLeaflet",where); 20 | api.export("PruneClusterLeafletSpiderfier",where); 21 | api.addFiles(['dist/LeafletStyleSheet.css', 22 | 'dist/PruneCluster.js', 23 | 'dist/PruneCluster.js.map', 24 | 'dist/PruneCluster.min.js' 25 | ],where,{bare: true}); 26 | }); 27 | 28 | Package.onTest(function (api) { 29 | api.use(packageName, where); 30 | api.use('tinytest', where); 31 | 32 | api.addFiles('meteor/test.js', where); 33 | }); 34 | -------------------------------------------------------------------------------- /meteor/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Tinytest.add('PruneCluster.is', function (test) { 4 | var pruneCluster = new PruneClusterForLeaflet(); 5 | var pruneMarker = new PruneCluster.Marker(0,0); 6 | test.isNotUndefined(pruneCluster, 'Cluster Instantiation OK'); 7 | test.equal(pruneMarker.position,{ lat: 0, lng: 0 }, 'Marker Instantiation OK'); 8 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sintef/prune-cluster", 3 | "version": "2.1.0", 4 | "homepage": "https://github.com/SINTEF-9012/PruneCluster", 5 | "description": "Fast and realtime marker clustering for Leaflet", 6 | "keywords": [ 7 | "map", 8 | "clustering", 9 | "leaflet", 10 | "marker", 11 | "realtime" 12 | ], 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/SINTEF-9012/PruneCluster.git" 17 | }, 18 | "main": "./dist/PruneCluster.js", 19 | "devDependencies": { 20 | "grunt": "^1.0.1", 21 | "grunt-contrib-clean": "~1.1.0", 22 | "grunt-contrib-copy": "~1.0.0", 23 | "grunt-contrib-uglify": "~3.0.1", 24 | "grunt-exec": "^2.0.0", 25 | "grunt-ts": "~6.0.0-beta.16", 26 | "load-grunt-tasks": "~3.5.2", 27 | "spacejam": "^1.1.1", 28 | "time-grunt": "~1.4.0", 29 | "typescript": "^2.4.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsd.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v4", 3 | "repo": "borisyankov/DefinitelyTyped", 4 | "ref": "master", 5 | "path": "typings", 6 | "bundle": "typings/tsd.d.ts", 7 | "installed": { 8 | "leaflet/leaflet.d.ts": { 9 | "commit": "b57c33fec0be3cba8f5e9cec642db873565923d0" 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------