diff --git a/examples/cluster.html b/examples/cluster.html index 70c52312a8..ffcba31d83 100644 --- a/examples/cluster.html +++ b/examples/cluster.html @@ -8,6 +8,18 @@ tags: "cluster, vector" ---
- - +
+ + + + The distance within which features will be clustered together. + +
+
+ + + + The minimum distance between clusters. Can't be larger than the configured distance. + +
diff --git a/examples/cluster.js b/examples/cluster.js index 48e1d0493c..125325b20f 100644 --- a/examples/cluster.js +++ b/examples/cluster.js @@ -13,7 +13,8 @@ import {Cluster, OSM, Vector as VectorSource} from '../src/ol/source.js'; import {Tile as TileLayer, Vector as VectorLayer} from '../src/ol/layer.js'; import {boundingExtent} from '../src/ol/extent.js'; -const distance = document.getElementById('distance'); +const distanceInput = document.getElementById('distance'); +const minDistanceInput = document.getElementById('min-distance'); const count = 20000; const features = new Array(count); @@ -28,7 +29,8 @@ const source = new VectorSource({ }); const clusterSource = new Cluster({ - distance: parseInt(distance.value, 10), + distance: parseInt(distanceInput.value, 10), + minDistance: parseInt(minDistanceInput.value, 10), source: source, }); @@ -75,8 +77,12 @@ const map = new Map({ }), }); -distance.addEventListener('input', function () { - clusterSource.setDistance(parseInt(distance.value, 10)); +distanceInput.addEventListener('input', function () { + clusterSource.setDistance(parseInt(distanceInput.value, 10)); +}); + +minDistanceInput.addEventListener('input', function () { + clusterSource.setMinDistance(parseInt(minDistanceInput.value, 10)); }); map.on('click', (e) => { diff --git a/src/ol/source/Cluster.js b/src/ol/source/Cluster.js index 22050979bd..a2fc5dd3e6 100644 --- a/src/ol/source/Cluster.js +++ b/src/ol/source/Cluster.js @@ -9,13 +9,24 @@ import Point from '../geom/Point.js'; import VectorSource from './Vector.js'; import {add as addCoordinate, scale as scaleCoordinate} from '../coordinate.js'; import {assert} from '../asserts.js'; -import {buffer, createEmpty, createOrUpdateFromCoordinate} from '../extent.js'; +import { + buffer, + createEmpty, + createOrUpdateFromCoordinate, + getCenter, +} from '../extent.js'; import {getUid} from '../util.js'; /** * @typedef {Object} Options * @property {import("./Source.js").AttributionLike} [attributions] Attributions. - * @property {number} [distance=20] Minimum distance in pixels between clusters. + * @property {number} [distance=20] Distance in pixels within which features will + * be clustered together. + * @property {number} [minDistance=0] Minimum distance in pixels between clusters. + * Will be capped at the configured distance. + * By default no minimum distance is guaranteed. This config can be used to avoid + * overlapping icons. As a tradoff, the cluster feature's position will no longer be + * the center of all its features. * @property {function(Feature):Point} [geometryFunction] * Function that takes an {@link module:ol/Feature} as argument and returns an * {@link module:ol/geom/Point} as cluster calculation point for the feature. When a @@ -66,6 +77,18 @@ class Cluster extends VectorSource { */ this.distance = options.distance !== undefined ? options.distance : 20; + /** + * @type {number} + * @protected + */ + this.minDistance = options.minDistance || 0; + + /** + * @type {number} + * @protected + */ + this.interpolationRatio = 0; + /** * @type {Array} * @protected @@ -85,8 +108,15 @@ class Cluster extends VectorSource { return geometry; }; + /** + * @type {VectorSource} + * @protected + */ + this.source = null; + this.boundRefresh_ = this.refresh.bind(this); + this.updateDistance(this.distance, this.minDistance); this.setSource(options.source || null); } @@ -126,21 +156,37 @@ class Cluster extends VectorSource { loadFeatures(extent, resolution, projection) { this.source.loadFeatures(extent, resolution, projection); if (resolution !== this.resolution) { - this.clear(); this.resolution = resolution; - this.cluster(); - this.addFeatures(this.features); + this.refresh(); } } /** - * Set the distance in pixels between clusters. + * Set the distance within which features will be clusterd together. * @param {number} distance The distance in pixels. * @api */ setDistance(distance) { - this.distance = distance; - this.refresh(); + this.updateDistance(distance, this.minDistance); + } + + /** + * Set the minimum distance between clusters. Will be capped at the + * configured distance. + * @param {number} minDistance The minimum distance in pixels. + * @api + */ + setMinDistance(minDistance) { + this.updateDistance(this.distance, minDistance); + } + + /** + * The configured minimum distance between clusters. + * @return {number} The minimum distance in pixels. + * @api + */ + getMinDistance() { + return this.minDistance; } /** @@ -168,6 +214,24 @@ class Cluster extends VectorSource { this.addFeatures(this.features); } + /** + * Update the distances and refresh the source if necessary. + * @param {number} distance The new distnce. + * @param {number} minDistance The new minimum distance. + */ + updateDistance(distance, minDistance) { + const ratio = + distance === 0 ? 0 : Math.min(minDistance, distance) / distance; + const changed = + distance !== this.distance || this.interpolationRatio !== ratio; + this.distance = distance; + this.minDistance = minDistance; + this.interpolationRatio = ratio; + if (changed) { + this.refresh(); + } + } + /** * @protected */ @@ -179,9 +243,7 @@ class Cluster extends VectorSource { const mapDistance = this.distance * this.resolution; const features = this.source.getFeatures(); - /** - * @type {!Object} - */ + /** @type {Object} */ const clustered = {}; for (let i = 0, ii = features.length; i < ii; i++) { @@ -193,17 +255,17 @@ class Cluster extends VectorSource { createOrUpdateFromCoordinate(coordinates, extent); buffer(extent, mapDistance, extent); - let neighbors = this.source.getFeaturesInExtent(extent); - neighbors = neighbors.filter(function (neighbor) { - const uid = getUid(neighbor); - if (!(uid in clustered)) { + const neighbors = this.source + .getFeaturesInExtent(extent) + .filter(function (neighbor) { + const uid = getUid(neighbor); + if (uid in clustered) { + return false; + } clustered[uid] = true; return true; - } else { - return false; - } - }); - this.features.push(this.createCluster(neighbors)); + }); + this.features.push(this.createCluster(neighbors, extent)); } } } @@ -211,10 +273,11 @@ class Cluster extends VectorSource { /** * @param {Array} features Features + * @param {import("../extent.js").Extent} extent The searched extent for these features. * @return {Feature} The cluster feature. * @protected */ - createCluster(features) { + createCluster(features, extent) { const centroid = [0, 0]; for (let i = features.length - 1; i >= 0; --i) { const geometry = this.geometryFunction(features[i]); @@ -225,9 +288,14 @@ class Cluster extends VectorSource { } } scaleCoordinate(centroid, 1 / features.length); - - const cluster = new Feature(new Point(centroid)); - cluster.set('features', features); + const searchCenter = getCenter(extent); + const ratio = this.interpolationRatio; + const geometry = new Point([ + centroid[0] * (1 - ratio) + searchCenter[0] * ratio, + centroid[1] * (1 - ratio) + searchCenter[1] * ratio, + ]); + const cluster = new Feature(geometry); + cluster.set('features', features, true); return cluster; } }