/* Copyright (c) 2015 Jean-Marc VIGLINO, released under the CeCILL-B license (http://www.cecill.info/). ol/interaction/SelectCluster is an interaction for selecting vector features in a cluster. */ import ol_Map from 'ol/Map.js' import ol_Collection from 'ol/Collection.js' import ol_layer_Vector from 'ol/layer/Vector.js' import ol_source_Vector from 'ol/source/Vector.js' import ol_interaction_Select from 'ol/interaction/Select.js' import ol_Feature from 'ol/Feature.js' import ol_geom_LineString from 'ol/geom/LineString.js' import {unByKey as ol_Observable_unByKey} from 'ol/Observable.js' import {easeOut as ol_easing_easeOut} from 'ol/easing.js' import ol_geom_Point from 'ol/geom/Point.js' import ol_style_Style from 'ol/style/Style.js' import ol_style_Circle from 'ol/style/Circle.js' import ol_render_getVectorContext from '../util/getVectorContext.js'; import { createEmpty as ol_extent_createEmpty } from 'ol/extent.js' import { extend as ol_extent_extend } from 'ol/extent.js' import { singleClick as ol_events_condition_singleClick } from 'ol/events/condition.js'; import {getUserProjection as ol_proj_getUserProjection} from 'ol/proj.js' /** * @classdesc * Interaction for selecting vector features in a cluster. * It can be used as an ol.interaction.Select. * When clicking on a cluster, it springs apart to reveal the features in the cluster. * Revealed features are selectable and you can pick the one you meant. * Revealed features are themselves a cluster with an attribute features that contain the original feature. * * @constructor * @extends {ol.interaction.Select} * @param {olx.interaction.SelectOptions=} options SelectOptions. * @param {ol.style} options.featureStyle used to style the revealed features as options.style is used by the Select interaction. * @param {boolean} options.selectCluster false if you don't want to get cluster selected * @param {Number} options.pointRadius to calculate distance between the features * @param {bool} options.spiral means you want the feature to be placed on a spiral (or a circle) * @param {Number} options.circleMaxObjects number of object that can be place on a circle * @param {Number} options.maxObjects number of object that can be drawn, other are hidden * @param {bool} options.animate if the cluster will animate when features spread out, default is false * @param {Number} options.animationDuration animation duration in ms, default is 500ms * @param {boolean} options.autoClose if selecting a cluster should close previously selected clusters. False to get toggle feature. Default is true * @fires ol.interaction.SelectEvent * @api stable */ var ol_interaction_SelectCluster = class olinteractionSelectCluster extends ol_interaction_Select { constructor(options) { options = options || {} // Create a new overlay layer for var overlay = new ol_layer_Vector({ source: new ol_source_Vector({ features: new ol_Collection(), wrapX: options.wrapX, useSpatialIndex: true }), name: 'Cluster overlay', updateWhileAnimating: true, updateWhileInteracting: true, displayInLayerSwitcher: false, style: options.featureStyle }) // Add the overlay to selection if (options.layers) { if (typeof (options.layers) == "function") { var fnLayers = options.layers options.layers = function (layer) { return (layer === overlay || fnLayers(layer)) } } else if (options.layers.push) { options.layers.push(overlay) } } // Don't select links if (options.filter) { var fnFilter = options.filter options.filter = function (f, l) { //if (l===overlay && f.get("selectclusterlink")) return false; if (!l && f.get("selectclusterlink")) return false else return fnFilter(f, l) } } else options.filter = function (f, l) { //if (l===overlay && f.get("selectclusterlink")) return false; if (!l && f.get("selectclusterlink")) return false else return true } if ((options.autoClose === false) && !options.toggleCondition) { options.toggleCondition = ol_events_condition_singleClick } super(options) this.overlayLayer_ = overlay; this.filter_ = options.filter this.pointRadius = options.pointRadius || 12 this.circleMaxObjects = options.circleMaxObjects || 10 this.maxObjects = options.maxObjects || 60 this.spiral = (options.spiral !== false) this.animate = options.animate this.animationDuration = options.animationDuration || 500 this.selectCluster_ = (options.selectCluster !== false) this._autoClose = (options.autoClose !== false) this.on("select", this.selectCluster.bind(this)) } /** * Remove the interaction from its current map, if any, and attach it to a new * map, if any. Pass `null` to just remove the interaction from the current map. * @param {ol.Map} map Map. * @api stable */ setMap(map) { if (this.getMap()) { this.getMap().removeLayer(this.overlayLayer_) } if (this._listener) ol_Observable_unByKey(this._listener) this._listener = null super.setMap(map) this.overlayLayer_.setMap(map) // map.addLayer(this.overlayLayer_); if (map && map.getView()) { this._listener = map.getView().on('change:resolution', this.clear.bind(this)) } } /** * Clear the selection, close the cluster and remove revealed features * @api stable */ clear() { this.getFeatures().clear() this.overlayLayer_.getSource().clear() } /** * Get the layer for the revealed features * @api stable */ getLayer() { return this.overlayLayer_ } /** * Select a cluster * @param {ol.SelectEvent | ol.Feature} a cluster feature ie. a feature with a 'features' attribute. * @api stable */ selectCluster(e) { // It's a feature => convert to SelectEvent if (e instanceof ol_Feature) { e = { selected: [e] } } // Nothing selected if (!e.selected.length) { if (this._autoClose) { this.clear() } else { var deselectedFeatures = e.deselected deselectedFeatures.forEach(deselectedFeature => { var selectClusterFeatures = deselectedFeature.get('selectcluserfeatures') if (selectClusterFeatures) { selectClusterFeatures.forEach(selectClusterFeature => { this.overlayLayer_.getSource().removeFeature(selectClusterFeature) }) } }) } return } // Get selection var feature = e.selected[0] // It's one of ours if (feature.get('selectclusterfeature')) return // Clic out of the cluster => close it var source = this.overlayLayer_.getSource() if (this._autoClose) { source.clear() } var cluster = feature.get('features') // Not a cluster (or just one feature) if (!cluster || cluster.length == 1) return // Remove cluster from selection if (!this.selectCluster_) this.getFeatures().clear() var center = feature.getGeometry().getCoordinates() // Pixel size in map unit var view = this.getMap().getView() var userproj = ol_proj_getUserProjection() var pix = view.getResolution() * (userproj ? view.getProjection().getMetersPerUnit() / userproj.getMetersPerUnit() : 1) var r, a, i, max var p, cf, lk // The features var features = [] // Draw on a circle if (!this.spiral || cluster.length <= this.circleMaxObjects) { max = Math.min(cluster.length, this.circleMaxObjects) r = pix * this.pointRadius * (0.5 + max / 4) for (i = 0; i < max; i++) { a = 2 * Math.PI * i / max if (max == 2 || max == 4) a += Math.PI / 4 p = [center[0] + r * Math.sin(a), center[1] + r * Math.cos(a)] cf = new ol_Feature({ 'selectclusterfeature': true, 'features': [cluster[i]], geometry: new ol_geom_Point(p) }) cf.setStyle(cluster[i].getStyle()) features.push(cf) lk = new ol_Feature({ 'selectclusterlink': true, geometry: new ol_geom_LineString([center, p]) }) features.push(lk) } } // Draw on a spiral else { // Start angle a = 0 var d = 2 * this.pointRadius max = Math.min(this.maxObjects, cluster.length) // Feature on a spiral for (i = 0; i < max; i++) { // New radius => increase d in one turn r = d / 2 + d * a / (2 * Math.PI) // Angle a = a + (d + 0.1) / r var dx = pix * r * Math.sin(a) var dy = pix * r * Math.cos(a) p = [center[0] + dx, center[1] + dy] cf = new ol_Feature({ 'selectclusterfeature': true, 'features': [cluster[i]], geometry: new ol_geom_Point(p) }) cf.setStyle(cluster[i].getStyle()) features.push(cf) lk = new ol_Feature({ 'selectclusterlink': true, geometry: new ol_geom_LineString([center, p]) }) features.push(lk) } } feature.set('selectcluserfeatures', features) if (this.animate) { this.animateCluster_(center, features) } else { source.addFeatures(features) } } /** * Animate the cluster and spread out the features * @param {ol.Coordinates} the center of the cluster */ animateCluster_(center, features) { // Stop animation (if one is running) if (this.listenerKey_) { ol_Observable_unByKey(this.listenerKey_) } // Features to animate // var features = this.overlayLayer_.getSource().getFeatures(); if (!features.length) return var style = this.overlayLayer_.getStyle() var stylefn = (typeof (style) == 'function') ? style : style.length ? function () { return style } : function () { return [style] } var duration = this.animationDuration || 500 var start = new Date().getTime() // Animate function function animate(event) { var vectorContext = event.vectorContext || ol_render_getVectorContext(event) // Retina device var ratio = event.frameState.pixelRatio var view = this.getMap().getView() var userproj = ol_proj_getUserProjection() var res = view.getResolution() + (userproj ? view.getProjection().getMetersPerUnit() / userproj.getMetersPerUnit() : 1) var e = ol_easing_easeOut((event.frameState.time - start) / duration) for (var i = 0, feature; feature = features[i]; i++) if (feature.get('features')) { var pt = feature.getGeometry().getCoordinates() pt[0] = center[0] + e * (pt[0] - center[0]) pt[1] = center[1] + e * (pt[1] - center[1]) var geo = new ol_geom_Point(pt) // Image style var st = stylefn(feature, res) for (var s = 0; s < st.length; s++) { var sc // OL < v4.3 : setImageStyle doesn't check retina var imgs = ol_Map.prototype.getFeaturesAtPixel ? false : st[s].getImage() if (imgs) { sc = imgs.getScale() imgs.setScale(ratio) } // OL3 > v3.14 if (vectorContext.setStyle) { vectorContext.setStyle(st[s]) vectorContext.drawGeometry(geo) } // older version else { vectorContext.setImageStyle(imgs) vectorContext.drawPointGeometry(geo) } if (imgs) imgs.setScale(sc) } } // Stop animation and restore cluster visibility if (e > 1.0) { ol_Observable_unByKey(this.listenerKey_) this.overlayLayer_.getSource().addFeatures(features) this.overlayLayer_.changed() return } // tell OL3 to continue postcompose animation event.frameState.animate = true } // Start a new postcompose animation this.listenerKey_ = this.overlayLayer_.on(['postcompose', 'postrender'], animate.bind(this)) // Start animation with a ghost feature var feature = new ol_Feature(new ol_geom_Point(this.getMap().getView().getCenter())) feature.setStyle(new ol_style_Style({ image: new ol_style_Circle({}) })) this.overlayLayer_.getSource().addFeature(feature) } /** Helper function to get the extent of a cluster * @param {ol.feature} feature * @return {ol.extent|null} the extent or null if extent is empty (no cluster or superimposed points) */ getClusterExtent(feature) { if (!feature.get('features')) return null var extent = ol_extent_createEmpty() feature.get('features').forEach(function (f) { extent = ol_extent_extend(extent, f.getGeometry().getExtent()) }) if (extent[0] === extent[2] && extent[1] === extent[3]) return null return extent } } export default ol_interaction_SelectCluster