diff --git a/examples/cluster.css b/examples/cluster.css new file mode 100644 index 0000000000..b1f7938dd4 --- /dev/null +++ b/examples/cluster.css @@ -0,0 +1,15 @@ +.info { + min-width: 3em; + text-align: right; +} +form { + display: table; +} +form > div { + display: table-row; +} +form > div > * { + display: table-cell; + white-space: nowrap; + padding-right: 5px; +} diff --git a/examples/cluster.html b/examples/cluster.html index 70c52312a8..9313ece8d3 100644 --- a/examples/cluster.html +++ b/examples/cluster.html @@ -8,6 +8,22 @@ tags: "cluster, vector" ---
- - +
+ + + px + +
+
+ + + px + +
+
+ Clusters: + + + +
diff --git a/examples/cluster.js b/examples/cluster.js index 48e1d0493c..ddc96de897 100644 --- a/examples/cluster.js +++ b/examples/cluster.js @@ -13,7 +13,11 @@ 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 distanceNode = document.getElementById('distance-info'); +const minDistanceInput = document.getElementById('min-distance'); +const minDistanceNode = document.getElementById('min-distance-info'); +const numClustersNode = document.getElementById('num-clusters'); const count = 20000; const features = new Array(count); @@ -28,9 +32,15 @@ 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, }); +clusterSource.on('change', function (evt) { + numClustersNode.innerText = evt.target.features.length; +}); +distanceNode.innerText = clusterSource.getDistance(); +minDistanceNode.innerText = clusterSource.getMinDistance(); const styleCache = {}; const clusters = new VectorLayer({ @@ -75,8 +85,14 @@ const map = new Map({ }), }); -distance.addEventListener('input', function () { - clusterSource.setDistance(parseInt(distance.value, 10)); +distanceInput.addEventListener('input', function () { + distanceNode.innerText = distanceInput.value; + clusterSource.setDistance(parseInt(distanceInput.value, 10)); +}); + +minDistanceInput.addEventListener('input', function () { + minDistanceNode.innerText = minDistanceInput.value; + 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..a8192b9974 100644 --- a/src/ol/source/Cluster.js +++ b/src/ol/source/Cluster.js @@ -9,13 +9,19 @@ 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 within which features will be clustered + * together. * @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 @@ -29,6 +35,11 @@ import {getUid} from '../util.js'; * ``` * See {@link module:ol/geom/Polygon~Polygon#getInteriorPoint} for a way to get a cluster * calculation point for polygons. + * @property {number} [minDistance=0] Minimum distance 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 {VectorSource} [source] Source. * @property {boolean} [wrapX=true] Whether to wrap the world horizontally. */ @@ -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); } @@ -134,13 +164,31 @@ class Cluster extends VectorSource { } /** - * 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. + * @api + */ + getMinDistance() { + return this.minDistance; } /** @@ -168,6 +216,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 +245,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 +257,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 +275,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 +290,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; } }