Files
openlayers/src/ol/interaction/Snap.js
mike-000 9d8609dd08 Modify and snap to circle in user coordinates
Correct modify interaction at center and at drawn circle circumference
Correct snap interaction at drawn circle circumference

Test circle geometry in a user projection
2019-12-13 16:40:39 +00:00

640 lines
20 KiB
JavaScript

/**
* @module ol/interaction/Snap
*/
import {getUid} from '../util.js';
import CollectionEventType from '../CollectionEventType.js';
import {distance as coordinateDistance, squaredDistance as squaredCoordinateDistance, closestOnCircle, closestOnSegment, squaredDistanceToSegment} from '../coordinate.js';
import {listen, unlistenByKey} from '../events.js';
import EventType from '../events/EventType.js';
import {boundingExtent, createEmpty} from '../extent.js';
import {TRUE, FALSE} from '../functions.js';
import GeometryType from '../geom/GeometryType.js';
import {fromCircle} from '../geom/Polygon.js';
import PointerInteraction from './Pointer.js';
import {getValues} from '../obj.js';
import VectorEventType from '../source/VectorEventType.js';
import RBush from '../structs/RBush.js';
import {getUserProjection, fromUserCoordinate, toUserCoordinate} from '../proj.js';
/**
* @typedef {Object} Result
* @property {boolean} snapped
* @property {import("../coordinate.js").Coordinate|null} vertex
* @property {import("../pixel.js").Pixel|null} vertexPixel
*/
/**
* @typedef {Object} SegmentData
* @property {import("../Feature.js").default} feature
* @property {Array<import("../coordinate.js").Coordinate>} segment
*/
/**
* @typedef {Object} Options
* @property {import("../Collection.js").default<import("../Feature.js").default>} [features] Snap to these features. Either this option or source should be provided.
* @property {boolean} [edge=true] Snap to edges.
* @property {boolean} [vertex=true] Snap to vertices.
* @property {number} [pixelTolerance=10] Pixel tolerance for considering the pointer close enough to a segment or
* vertex for snapping.
* @property {import("../source/Vector.js").default} [source] Snap to features from this source. Either this option or features should be provided
*/
/**
* @param {import("../source/Vector.js").VectorSourceEvent|import("../Collection.js").CollectionEvent} evt Event.
* @return {import("../Feature.js").default} Feature.
*/
function getFeatureFromEvent(evt) {
if (/** @type {import("../source/Vector.js").VectorSourceEvent} */ (evt).feature) {
return /** @type {import("../source/Vector.js").VectorSourceEvent} */ (evt).feature;
} else if (/** @type {import("../Collection.js").CollectionEvent} */ (evt).element) {
return /** @type {import("../Feature.js").default} */ (/** @type {import("../Collection.js").CollectionEvent} */ (evt).element);
}
}
const tempSegment = [];
/**
* @classdesc
* Handles snapping of vector features while modifying or drawing them. The
* features can come from a {@link module:ol/source/Vector} or {@link module:ol/Collection~Collection}
* Any interaction object that allows the user to interact
* with the features using the mouse can benefit from the snapping, as long
* as it is added before.
*
* The snap interaction modifies map browser event `coordinate` and `pixel`
* properties to force the snap to occur to any interaction that them.
*
* Example:
*
* import Snap from 'ol/interaction/Snap';
*
* const snap = new Snap({
* source: source
* });
*
* map.addInteraction(snap);
*
* @api
*/
class Snap extends PointerInteraction {
/**
* @param {Options=} opt_options Options.
*/
constructor(opt_options) {
const options = opt_options ? opt_options : {};
const pointerOptions = /** @type {import("./Pointer.js").Options} */ (options);
if (!pointerOptions.handleDownEvent) {
pointerOptions.handleDownEvent = TRUE;
}
if (!pointerOptions.stopDown) {
pointerOptions.stopDown = FALSE;
}
super(pointerOptions);
/**
* @type {import("../source/Vector.js").default}
* @private
*/
this.source_ = options.source ? options.source : null;
/**
* @private
* @type {boolean}
*/
this.vertex_ = options.vertex !== undefined ? options.vertex : true;
/**
* @private
* @type {boolean}
*/
this.edge_ = options.edge !== undefined ? options.edge : true;
/**
* @type {import("../Collection.js").default<import("../Feature.js").default>}
* @private
*/
this.features_ = options.features ? options.features : null;
/**
* @type {Array<import("../events.js").EventsKey>}
* @private
*/
this.featuresListenerKeys_ = [];
/**
* @type {Object<string, import("../events.js").EventsKey>}
* @private
*/
this.featureChangeListenerKeys_ = {};
/**
* Extents are preserved so indexed segment can be quickly removed
* when its feature geometry changes
* @type {Object<string, import("../extent.js").Extent>}
* @private
*/
this.indexedFeaturesExtents_ = {};
/**
* If a feature geometry changes while a pointer drag|move event occurs, the
* feature doesn't get updated right away. It will be at the next 'pointerup'
* event fired.
* @type {!Object<string, import("../Feature.js").default>}
* @private
*/
this.pendingFeatures_ = {};
/**
* @type {number}
* @private
*/
this.pixelTolerance_ = options.pixelTolerance !== undefined ?
options.pixelTolerance : 10;
/**
* Segment RTree for each layer
* @type {import("../structs/RBush.js").default<SegmentData>}
* @private
*/
this.rBush_ = new RBush();
/**
* @const
* @private
* @type {Object<string, function(import("../Feature.js").default, import("../geom/Geometry.js").default): void>}
*/
this.SEGMENT_WRITERS_ = {
'Point': this.writePointGeometry_.bind(this),
'LineString': this.writeLineStringGeometry_.bind(this),
'LinearRing': this.writeLineStringGeometry_.bind(this),
'Polygon': this.writePolygonGeometry_.bind(this),
'MultiPoint': this.writeMultiPointGeometry_.bind(this),
'MultiLineString': this.writeMultiLineStringGeometry_.bind(this),
'MultiPolygon': this.writeMultiPolygonGeometry_.bind(this),
'GeometryCollection': this.writeGeometryCollectionGeometry_.bind(this),
'Circle': this.writeCircleGeometry_.bind(this)
};
}
/**
* Add a feature to the collection of features that we may snap to.
* @param {import("../Feature.js").default} feature Feature.
* @param {boolean=} opt_listen Whether to listen to the feature change or not
* Defaults to `true`.
* @api
*/
addFeature(feature, opt_listen) {
const register = opt_listen !== undefined ? opt_listen : true;
const feature_uid = getUid(feature);
const geometry = feature.getGeometry();
if (geometry) {
const segmentWriter = this.SEGMENT_WRITERS_[geometry.getType()];
if (segmentWriter) {
this.indexedFeaturesExtents_[feature_uid] = geometry.getExtent(createEmpty());
segmentWriter(feature, geometry);
}
}
if (register) {
this.featureChangeListenerKeys_[feature_uid] = listen(
feature,
EventType.CHANGE,
this.handleFeatureChange_, this);
}
}
/**
* @param {import("../Feature.js").default} feature Feature.
* @private
*/
forEachFeatureAdd_(feature) {
this.addFeature(feature);
}
/**
* @param {import("../Feature.js").default} feature Feature.
* @private
*/
forEachFeatureRemove_(feature) {
this.removeFeature(feature);
}
/**
* @return {import("../Collection.js").default<import("../Feature.js").default>|Array<import("../Feature.js").default>} Features.
* @private
*/
getFeatures_() {
let features;
if (this.features_) {
features = this.features_;
} else if (this.source_) {
features = this.source_.getFeatures();
}
return features;
}
/**
* @inheritDoc
*/
handleEvent(evt) {
const result = this.snapTo(evt.pixel, evt.coordinate, evt.map);
if (result.snapped) {
evt.coordinate = result.vertex.slice(0, 2);
evt.pixel = result.vertexPixel;
}
return super.handleEvent(evt);
}
/**
* @param {import("../source/Vector.js").VectorSourceEvent|import("../Collection.js").CollectionEvent} evt Event.
* @private
*/
handleFeatureAdd_(evt) {
const feature = getFeatureFromEvent(evt);
this.addFeature(feature);
}
/**
* @param {import("../source/Vector.js").VectorSourceEvent|import("../Collection.js").CollectionEvent} evt Event.
* @private
*/
handleFeatureRemove_(evt) {
const feature = getFeatureFromEvent(evt);
this.removeFeature(feature);
}
/**
* @param {import("../events/Event.js").default} evt Event.
* @private
*/
handleFeatureChange_(evt) {
const feature = /** @type {import("../Feature.js").default} */ (evt.target);
if (this.handlingDownUpSequence) {
const uid = getUid(feature);
if (!(uid in this.pendingFeatures_)) {
this.pendingFeatures_[uid] = feature;
}
} else {
this.updateFeature_(feature);
}
}
/**
* @inheritDoc
*/
handleUpEvent(evt) {
const featuresToUpdate = getValues(this.pendingFeatures_);
if (featuresToUpdate.length) {
featuresToUpdate.forEach(this.updateFeature_.bind(this));
this.pendingFeatures_ = {};
}
return false;
}
/**
* Remove a feature from the collection of features that we may snap to.
* @param {import("../Feature.js").default} feature Feature
* @param {boolean=} opt_unlisten Whether to unlisten to the feature change
* or not. Defaults to `true`.
* @api
*/
removeFeature(feature, opt_unlisten) {
const unregister = opt_unlisten !== undefined ? opt_unlisten : true;
const feature_uid = getUid(feature);
const extent = this.indexedFeaturesExtents_[feature_uid];
if (extent) {
const rBush = this.rBush_;
const nodesToRemove = [];
rBush.forEachInExtent(extent, function(node) {
if (feature === node.feature) {
nodesToRemove.push(node);
}
});
for (let i = nodesToRemove.length - 1; i >= 0; --i) {
rBush.remove(nodesToRemove[i]);
}
}
if (unregister) {
unlistenByKey(this.featureChangeListenerKeys_[feature_uid]);
delete this.featureChangeListenerKeys_[feature_uid];
}
}
/**
* @inheritDoc
*/
setMap(map) {
const currentMap = this.getMap();
const keys = this.featuresListenerKeys_;
const features = /** @type {Array<import("../Feature.js").default>} */ (this.getFeatures_());
if (currentMap) {
keys.forEach(unlistenByKey);
keys.length = 0;
features.forEach(this.forEachFeatureRemove_.bind(this));
}
super.setMap(map);
if (map) {
if (this.features_) {
keys.push(
listen(this.features_, CollectionEventType.ADD,
this.handleFeatureAdd_, this),
listen(this.features_, CollectionEventType.REMOVE,
this.handleFeatureRemove_, this)
);
} else if (this.source_) {
keys.push(
listen(this.source_, VectorEventType.ADDFEATURE,
this.handleFeatureAdd_, this),
listen(this.source_, VectorEventType.REMOVEFEATURE,
this.handleFeatureRemove_, this)
);
}
features.forEach(this.forEachFeatureAdd_.bind(this));
}
}
/**
* @param {import("../pixel.js").Pixel} pixel Pixel
* @param {import("../coordinate.js").Coordinate} pixelCoordinate Coordinate
* @param {import("../PluggableMap.js").default} map Map.
* @return {Result} Snap result
*/
snapTo(pixel, pixelCoordinate, map) {
const lowerLeft = map.getCoordinateFromPixel(
[pixel[0] - this.pixelTolerance_, pixel[1] + this.pixelTolerance_]);
const upperRight = map.getCoordinateFromPixel(
[pixel[0] + this.pixelTolerance_, pixel[1] - this.pixelTolerance_]);
const box = boundingExtent([lowerLeft, upperRight]);
let segments = this.rBush_.getInExtent(box);
// If snapping on vertices only, don't consider circles
if (this.vertex_ && !this.edge_) {
segments = segments.filter(function(segment) {
return segment.feature.getGeometry().getType() !==
GeometryType.CIRCLE;
});
}
let snapped = false;
let vertex = null;
let vertexPixel = null;
if (segments.length === 0) {
return {
snapped: snapped,
vertex: vertex,
vertexPixel: vertexPixel
};
}
const projection = map.getView().getProjection();
const projectedCoordinate = fromUserCoordinate(pixelCoordinate, projection);
let closestSegmentData;
let minSquaredDistance = Infinity;
for (let i = 0; i < segments.length; ++i) {
const segmentData = segments[i];
tempSegment[0] = fromUserCoordinate(segmentData.segment[0], projection);
tempSegment[1] = fromUserCoordinate(segmentData.segment[1], projection);
const delta = squaredDistanceToSegment(projectedCoordinate, tempSegment);
if (delta < minSquaredDistance) {
closestSegmentData = segmentData;
minSquaredDistance = delta;
}
}
const closestSegment = closestSegmentData.segment;
if (this.vertex_ && !this.edge_) {
const pixel1 = map.getPixelFromCoordinate(closestSegment[0]);
const pixel2 = map.getPixelFromCoordinate(closestSegment[1]);
const squaredDist1 = squaredCoordinateDistance(pixel, pixel1);
const squaredDist2 = squaredCoordinateDistance(pixel, pixel2);
const dist = Math.sqrt(Math.min(squaredDist1, squaredDist2));
if (dist <= this.pixelTolerance_) {
snapped = true;
vertex = squaredDist1 > squaredDist2 ? closestSegment[1] : closestSegment[0];
vertexPixel = map.getPixelFromCoordinate(vertex);
}
} else if (this.edge_) {
const isCircle = closestSegmentData.feature.getGeometry().getType() === GeometryType.CIRCLE;
if (isCircle) {
let circleGeometry = closestSegmentData.feature.getGeometry();
const userProjection = getUserProjection();
if (userProjection) {
circleGeometry = circleGeometry.clone().transform(userProjection, projection);
}
vertex = toUserCoordinate(closestOnCircle(projectedCoordinate,
/** @type {import("../geom/Circle.js").default} */ (circleGeometry)), projection);
} else {
tempSegment[0] = fromUserCoordinate(closestSegment[0], projection);
tempSegment[1] = fromUserCoordinate(closestSegment[1], projection);
vertex = toUserCoordinate(closestOnSegment(projectedCoordinate, tempSegment), projection);
}
vertexPixel = map.getPixelFromCoordinate(vertex);
if (coordinateDistance(pixel, vertexPixel) <= this.pixelTolerance_) {
snapped = true;
if (this.vertex_ && !isCircle) {
const pixel1 = map.getPixelFromCoordinate(closestSegment[0]);
const pixel2 = map.getPixelFromCoordinate(closestSegment[1]);
const squaredDist1 = squaredCoordinateDistance(vertexPixel, pixel1);
const squaredDist2 = squaredCoordinateDistance(vertexPixel, pixel2);
const dist = Math.sqrt(Math.min(squaredDist1, squaredDist2));
if (dist <= this.pixelTolerance_) {
vertex = squaredDist1 > squaredDist2 ? closestSegment[1] : closestSegment[0];
vertexPixel = map.getPixelFromCoordinate(vertex);
}
}
}
}
if (snapped) {
vertexPixel = [Math.round(vertexPixel[0]), Math.round(vertexPixel[1])];
}
return {
snapped: snapped,
vertex: vertex,
vertexPixel: vertexPixel
};
}
/**
* @param {import("../Feature.js").default} feature Feature
* @private
*/
updateFeature_(feature) {
this.removeFeature(feature, false);
this.addFeature(feature, false);
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/Circle.js").default} geometry Geometry.
* @private
*/
writeCircleGeometry_(feature, geometry) {
const projection = this.getMap().getView().getProjection();
let circleGeometry = geometry;
const userProjection = getUserProjection();
if (userProjection) {
circleGeometry = /** @type {import("../geom/Circle.js").default} */ (circleGeometry.clone().transform(userProjection, projection));
}
const polygon = fromCircle(circleGeometry);
if (userProjection) {
polygon.transform(projection, userProjection);
}
const coordinates = polygon.getCoordinates()[0];
for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
const segment = coordinates.slice(i, i + 2);
const segmentData = {
feature: feature,
segment: segment
};
this.rBush_.insert(boundingExtent(segment), segmentData);
}
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/GeometryCollection.js").default} geometry Geometry.
* @private
*/
writeGeometryCollectionGeometry_(feature, geometry) {
const geometries = geometry.getGeometriesArray();
for (let i = 0; i < geometries.length; ++i) {
const segmentWriter = this.SEGMENT_WRITERS_[geometries[i].getType()];
if (segmentWriter) {
segmentWriter(feature, geometries[i]);
}
}
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/LineString.js").default} geometry Geometry.
* @private
*/
writeLineStringGeometry_(feature, geometry) {
const coordinates = geometry.getCoordinates();
for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
const segment = coordinates.slice(i, i + 2);
const segmentData = {
feature: feature,
segment: segment
};
this.rBush_.insert(boundingExtent(segment), segmentData);
}
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/MultiLineString.js").default} geometry Geometry.
* @private
*/
writeMultiLineStringGeometry_(feature, geometry) {
const lines = geometry.getCoordinates();
for (let j = 0, jj = lines.length; j < jj; ++j) {
const coordinates = lines[j];
for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
const segment = coordinates.slice(i, i + 2);
const segmentData = {
feature: feature,
segment: segment
};
this.rBush_.insert(boundingExtent(segment), segmentData);
}
}
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/MultiPoint.js").default} geometry Geometry.
* @private
*/
writeMultiPointGeometry_(feature, geometry) {
const points = geometry.getCoordinates();
for (let i = 0, ii = points.length; i < ii; ++i) {
const coordinates = points[i];
const segmentData = {
feature: feature,
segment: [coordinates, coordinates]
};
this.rBush_.insert(geometry.getExtent(), segmentData);
}
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/MultiPolygon.js").default} geometry Geometry.
* @private
*/
writeMultiPolygonGeometry_(feature, geometry) {
const polygons = geometry.getCoordinates();
for (let k = 0, kk = polygons.length; k < kk; ++k) {
const rings = polygons[k];
for (let j = 0, jj = rings.length; j < jj; ++j) {
const coordinates = rings[j];
for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
const segment = coordinates.slice(i, i + 2);
const segmentData = {
feature: feature,
segment: segment
};
this.rBush_.insert(boundingExtent(segment), segmentData);
}
}
}
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/Point.js").default} geometry Geometry.
* @private
*/
writePointGeometry_(feature, geometry) {
const coordinates = geometry.getCoordinates();
const segmentData = {
feature: feature,
segment: [coordinates, coordinates]
};
this.rBush_.insert(geometry.getExtent(), segmentData);
}
/**
* @param {import("../Feature.js").default} feature Feature
* @param {import("../geom/Polygon.js").default} geometry Geometry.
* @private
*/
writePolygonGeometry_(feature, geometry) {
const rings = geometry.getCoordinates();
for (let j = 0, jj = rings.length; j < jj; ++j) {
const coordinates = rings[j];
for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
const segment = coordinates.slice(i, i + 2);
const segmentData = {
feature: feature,
segment: segment
};
this.rBush_.insert(boundingExtent(segment), segmentData);
}
}
}
}
export default Snap;