/** * @module ol/interaction/Snap */ import CollectionEventType from '../CollectionEventType.js'; import { closestOnCircle, closestOnSegment, squaredDistance, } from '../coordinate.js'; import EventType from '../events/EventType.js'; import {SnapEvent, SnapEventType} from '../events/SnapEvent.js'; import {listen, unlistenByKey} from '../events.js'; import { boundingExtent, buffer, createEmpty, intersects as intersectsExtent, } from '../extent.js'; import {FALSE, TRUE} from '../functions.js'; import {fromCircle} from '../geom/Polygon.js'; import {getIntersectionPoint} from '../geom/flat/segments.js'; import {clear} from '../obj.js'; import { fromUserCoordinate, getUserProjection, toUserCoordinate, toUserExtent, } from '../proj.js'; import VectorEventType from '../source/VectorEventType.js'; import RBush from '../structs/RBush.js'; import {getUid} from '../util.js'; import PointerInteraction from './Pointer.js'; /** * @typedef {Array} Segment * An array of two coordinates representing a line segment, or an array of one * coordinate representing a point. */ /** * @typedef {Object} SegmentData * @property {import("../Feature.js").default} feature Feature. * @property {import("../Feature.js").default} [intersectionFeature] Feature which intersects. * @property {Segment} segment Segment. */ /** * @template {import("../geom/Geometry.js").default} [GeometryType=import("../geom/Geometry.js").default] * @typedef {(geometry: GeometryType, projection?: import("../proj/Projection.js").default) => Array} Segmenter * A function taking a {@link module:ol/geom/Geometry~Geometry} as argument and returning an array of {@link Segment}s. */ /** * Each segmenter specified here will override the default segmenter for the * corresponding geometry type. To exclude all geometries of a specific geometry type from being snapped to, * set the segmenter to `null`. * @typedef {Object} Segmenters * @property {Segmenter|null} [Point] Point segmenter. * @property {Segmenter|null} [LineString] LineString segmenter. * @property {Segmenter|null} [Polygon] Polygon segmenter. * @property {Segmenter|null} [Circle] Circle segmenter. * @property {Segmenter|null} [GeometryCollection] GeometryCollection segmenter. * @property {Segmenter|null} [MultiPoint] MultiPoint segmenter. * @property {Segmenter|null} [MultiLineString] MultiLineString segmenter. * @property {Segmenter|null} [MultiPolygon] MultiPolygon segmenter. */ /** * @typedef {Object} Options * @property {import("../Collection.js").default} [features] Snap to these features. Either this option or source should be provided. * @property {import("../source/Vector.js").default} [source] Snap to features from this source. Either this option or features should be provided * @property {boolean} [edge=true] Snap to edges. * @property {boolean} [vertex=true] Snap to vertices. * @property {boolean} [intersection=false] Snap to intersections between segments. * @property {number} [pixelTolerance=10] Pixel tolerance for considering the pointer close enough to a segment or * vertex for snapping. * @property {Segmenters} [segmenters] Custom segmenters by {@link module:ol/geom/Geometry~Type}. By default, the * following segmenters are used: * - `Point`: A one-dimensional segment (e.g. `[[10, 20]]`) representing the point. * - `LineString`: One two-dimensional segment (e.g. `[[10, 20], [30, 40]]`) for each segment of the linestring. * - `Polygon`: One two-dimensional segment for each segment of the exterior ring and the interior rings. * - `Circle`: One two-dimensional segment for each segment of a regular polygon with 32 points representing the circle circumference. * - `GeometryCollection`: All segments of the contained geometries. * - `MultiPoint`: One one-dimensional segment for each point. * - `MultiLineString`: One two-dimensional segment for each segment of the linestrings. * - `MultiPolygon`: One two-dimensional segment for each segment of the polygons. */ /** * Information about the last snapped state. * @typedef {Object} SnappedInfo * @property {import("../coordinate.js").Coordinate|null} vertex - The snapped vertex. * @property {import("../pixel.js").Pixel|null} vertexPixel - The pixel of the snapped vertex. * @property {import("../Feature.js").default|null} feature - The feature being snapped. * @property {Segment|null} segment - Segment, or `null` if snapped to a vertex. */ /*** * @type {Object} */ const GEOMETRY_SEGMENTERS = { /** * @param {import("../geom/Circle.js").default} geometry Geometry. * @param {import("../proj/Projection.js").default} projection Projection. * @return {Array} Segments */ Circle(geometry, projection) { let circleGeometry = geometry; const userProjection = getUserProjection(); if (userProjection) { circleGeometry = circleGeometry .clone() .transform(userProjection, projection); } const polygon = fromCircle(circleGeometry); if (userProjection) { polygon.transform(projection, userProjection); } return GEOMETRY_SEGMENTERS.Polygon(polygon); }, /** * @param {import("../geom/GeometryCollection.js").default} geometry Geometry. * @param {import("../proj/Projection.js").default} projection Projection. * @return {Array} Segments */ GeometryCollection(geometry, projection) { /** @type {Array>} */ const segments = []; const geometries = geometry.getGeometriesArray(); for (let i = 0; i < geometries.length; ++i) { const segmenter = this[geometries[i].getType()]; if (segmenter) { segments.push(segmenter(geometries[i], projection)); } } return segments.flat(); }, /** * @param {import("../geom/LineString.js").default} geometry Geometry. * @return {Array} Segments */ LineString(geometry) { /** @type {Array} */ const segments = []; const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); for (let i = 0, ii = coordinates.length - stride; i < ii; i += stride) { segments.push([ coordinates.slice(i, i + 2), coordinates.slice(i + stride, i + stride + 2), ]); } return segments; }, /** * @param {import("../geom/MultiLineString.js").default} geometry Geometry. * @return {Array} Segments */ MultiLineString(geometry) { /** @type {Array} */ const segments = []; const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); const ends = geometry.getEnds(); let offset = 0; for (let i = 0, ii = ends.length; i < ii; ++i) { const end = ends[i]; for (let j = offset, jj = end - stride; j < jj; j += stride) { segments.push([ coordinates.slice(j, j + 2), coordinates.slice(j + stride, j + stride + 2), ]); } offset = end; } return segments; }, /** * @param {import("../geom/MultiPoint.js").default} geometry Geometry. * @return {Array} Segments */ MultiPoint(geometry) { /** @type {Array} */ const segments = []; const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); for (let i = 0, ii = coordinates.length; i < ii; i += stride) { segments.push([coordinates.slice(i, i + 2)]); } return segments; }, /** * @param {import("../geom/MultiPolygon.js").default} geometry Geometry. * @return {Array} Segments */ MultiPolygon(geometry) { /** @type {Array} */ const segments = []; const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); const endss = geometry.getEndss(); let offset = 0; for (let i = 0, ii = endss.length; i < ii; ++i) { const ends = endss[i]; for (let j = 0, jj = ends.length; j < jj; ++j) { const end = ends[j]; for (let k = offset, kk = end - stride; k < kk; k += stride) { segments.push([ coordinates.slice(k, k + 2), coordinates.slice(k + stride, k + stride + 2), ]); } offset = end; } } return segments; }, /** * @param {import("../geom/Point.js").default} geometry Geometry. * @return {Array} Segments */ Point(geometry) { return [[geometry.getFlatCoordinates().slice(0, 2)]]; }, /** * @param {import("../geom/Polygon.js").default} geometry Geometry. * @return {Array} Segments */ Polygon(geometry) { /** @type {Array} */ const segments = []; const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); const ends = geometry.getEnds(); let offset = 0; for (let i = 0, ii = ends.length; i < ii; ++i) { const end = ends[i]; for (let j = offset, jj = end - stride; j < jj; j += stride) { segments.push([ coordinates.slice(j, j + 2), coordinates.slice(j + stride, j + stride + 2), ]); } offset = end; } return segments; }, }; /** * @param {import("../source/Vector.js").VectorSourceEvent|import("../Collection.js").CollectionEvent} evt Event. * @return {import("../Feature.js").default|null} Feature. */ function getFeatureFromEvent(evt) { if ( /** @type {import("../source/Vector.js").VectorSourceEvent} */ (evt).feature ) { return /** @type {import("../source/Vector.js").VectorSourceEvent} */ (evt) .feature; } if ( /** @type {import("../Collection.js").CollectionEvent} */ ( evt ).element ) { return /** @type {import("../Collection.js").CollectionEvent} */ ( evt ).element; } return null; } const tempSegment = []; /** @type {Array} */ const tempExtents = []; /** @type {Array} */ const tempSegmentData = []; /*** * @template Return * @typedef {import("../Observable").OnSignature & * import("../Observable").OnSignature & * import("../Observable").OnSignature<'snap'|'unsnap', SnapEvent, Return> & * import("../Observable").CombinedOnSignature} SnapOnSignature */ /** * @classdesc * Handles snapping of vector features while modifying or drawing them. The * features can come from a {@link module:ol/source/Vector~VectorSource} 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 uses them. * * Example: * * import Snap from 'ol/interaction/Snap.js'; * * const snap = new Snap({ * source: source * }); * * map.addInteraction(snap); * * @fires SnapEvent * @api */ class Snap extends PointerInteraction { /** * @param {Options} [options] Options. */ constructor(options) { options = options ? options : {}; super({ handleDownEvent: TRUE, stopDown: FALSE, }); /*** * @type {SnapOnSignature} */ this.on; /*** * @type {SnapOnSignature} */ this.once; /*** * @type {SnapOnSignature} */ this.un; /** * @type {import("../source/Vector.js").default|null} * @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; /** * @private * @type {boolean} */ this.intersection_ = options.intersection !== undefined ? options.intersection : false; /** * @type {import("../Collection.js").default|null} * @private */ this.features_ = options.features ? options.features : null; /** * @type {Array} * @private */ this.featuresListenerKeys_ = []; /** * @type {Object} * @private */ this.featureChangeListenerKeys_ = {}; /** * Extents are preserved so indexed segment can be quickly removed * when its feature geometry changes * @type {Object} * @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} * @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} * @private */ this.rBush_ = new RBush(); /** * Holds information about the last snapped state. * @type {SnappedInfo|null} * @private */ this.snapped_ = null; /** * @type {Object} * @private */ this.segmenters_ = Object.assign( {}, GEOMETRY_SEGMENTERS, options.segmenters, ); } /** * Add a feature to the collection of features that we may snap to. * @param {import("../Feature.js").default} feature Feature. * @param {boolean} [register] Whether to listen to the feature change or not * Defaults to `true`. * @api */ addFeature(feature, register) { register = register !== undefined ? register : true; const feature_uid = getUid(feature); const geometry = feature.getGeometry(); if (geometry) { const segmenter = this.segmenters_[geometry.getType()]; if (segmenter) { this.indexedFeaturesExtents_[feature_uid] = geometry.getExtent(createEmpty()); const segments = segmenter.call( this.segmenters_, geometry, this.getMap().getView().getProjection(), ); let segmentCount = segments.length; for (let i = 0; i < segmentCount; ++i) { const segment = segments[i]; tempExtents[i] = boundingExtent(segment); tempSegmentData[i] = { feature: feature, segment: segment, }; } if (this.intersection_) { for (let j = 0, jj = segments.length; j < jj; ++j) { const segment = segments[j]; if (segment.length === 1) { continue; } const extent = tempExtents[j]; // Calculate intersections with own segments excluding self and // neighbors for (let k = 0, kk = j - 1; k < kk; ++k) { const otherSegment = segments[k]; if (!intersectsExtent(extent, tempExtents[k])) { continue; } const intersection = getIntersectionPoint(segment, otherSegment); if (!intersection) { continue; } const intersectionSegment = [intersection]; tempExtents[segmentCount] = boundingExtent(intersectionSegment); tempSegmentData[segmentCount++] = { feature, intersectionFeature: feature, segment: intersectionSegment, }; } // Calculate intersections with existing segments const otherSegments = this.rBush_.getInExtent(tempExtents[j]); for (let k = 0, kk = otherSegments.length; k < kk; ++k) { const otherSegment = otherSegments[k].segment; if (otherSegment.length === 1) { continue; } const intersection = getIntersectionPoint(segment, otherSegment); if (!intersection) { continue; } const intersectionSegment = [intersection]; tempExtents[segmentCount] = boundingExtent(intersectionSegment); tempSegmentData[segmentCount++] = { feature, intersectionFeature: otherSegments[k].feature, segment: intersectionSegment, }; } } } if (segmentCount === 1) { this.rBush_.insert(tempExtents[0], tempSegmentData[0]); } else { tempExtents.length = segmentCount; tempSegmentData.length = segmentCount; this.rBush_.load(tempExtents, tempSegmentData); } } } if (register) { if (this.featureChangeListenerKeys_[feature_uid]) { unlistenByKey(this.featureChangeListenerKeys_[feature_uid]); } this.featureChangeListenerKeys_[feature_uid] = listen( feature, EventType.CHANGE, this.handleFeatureChange_, this, ); } } /** * @return {import("../Collection.js").default|Array} Features. * @private */ getFeatures_() { /** @type {import("../Collection.js").default|Array} */ let features; if (this.features_) { features = this.features_; } else if (this.source_) { features = this.source_.getFeatures(); } return features; } /** * Checks if two snap data sets are equal. * Compares the segment and the feature. * * @param {SnappedInfo} data1 The first snap data set. * @param {SnappedInfo} data2 The second snap data set. * @return {boolean} `true` if the data sets are equal, otherwise `false`. * @private */ areSnapDataEqual_(data1, data2) { return data1.segment === data2.segment && data1.feature === data2.feature; } /** * @param {import("../MapBrowserEvent.js").default} evt Map browser event. * @return {boolean} `false` to stop event propagation. * @api * @override */ handleEvent(evt) { const result = this.snapTo(evt.pixel, evt.coordinate, evt.map); if (result) { evt.coordinate = result.vertex.slice(0, 2); evt.pixel = result.vertexPixel; // Dispatch UNSNAP event if already snapped if (this.snapped_ && !this.areSnapDataEqual_(this.snapped_, result)) { this.dispatchEvent(new SnapEvent(SnapEventType.UNSNAP, this.snapped_)); } this.snapped_ = { vertex: evt.coordinate, vertexPixel: evt.pixel, feature: result.feature, segment: result.segment, }; this.dispatchEvent(new SnapEvent(SnapEventType.SNAP, this.snapped_)); } else if (this.snapped_) { // Dispatch UNSNAP event if no longer snapped this.dispatchEvent(new SnapEvent(SnapEventType.UNSNAP, this.snapped_)); this.snapped_ = null; } return super.handleEvent(evt); } /** * @param {import("../source/Vector.js").VectorSourceEvent|import("../Collection.js").CollectionEvent} evt Event. * @private */ handleFeatureAdd_(evt) { const feature = getFeatureFromEvent(evt); if (feature) { this.addFeature(feature); } } /** * @param {import("../source/Vector.js").VectorSourceEvent|import("../Collection.js").CollectionEvent} evt Event. * @private */ handleFeatureRemove_(evt) { const feature = getFeatureFromEvent(evt); if (feature) { this.removeFeature(feature); delete this.pendingFeatures_[getUid(feature)]; } } /** * @param {import("../events/Event.js").default} evt Event. * @private */ handleFeatureChange_(evt) { const feature = /** @type {import("../Feature.js").default} */ (evt.target); if (this.handlingDownUpSequence) { this.pendingFeatures_[getUid(feature)] = feature; } else { this.updateFeature_(feature); } } /** * Handle pointer up events. * @param {import("../MapBrowserEvent.js").default} evt Event. * @return {boolean} If the event was consumed. * @override */ handleUpEvent(evt) { const featuresToUpdate = Object.values(this.pendingFeatures_); if (featuresToUpdate.length) { for (const feature of featuresToUpdate) { this.updateFeature_(feature); } clear(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} [unlisten] Whether to unlisten to the feature change * or not. Defaults to `true`. * @api */ removeFeature(feature, unlisten) { const unregister = unlisten !== undefined ? unlisten : true; const feature_uid = getUid(feature); const extent = this.indexedFeaturesExtents_[feature_uid]; if (extent) { const rBush = this.rBush_; rBush.getInExtent(extent).forEach((node) => { if (feature === node.feature || feature === node.intersectionFeature) { rBush.remove(node); } }); } if (unregister) { unlistenByKey(this.featureChangeListenerKeys_[feature_uid]); delete this.featureChangeListenerKeys_[feature_uid]; } } /** * Remove the interaction from its current map and attach it to the new map. * Subclasses may set up event handlers to get notified about changes to * the map here. * @param {import("../Map.js").default} map Map. * @override */ setMap(map) { const currentMap = this.getMap(); const keys = this.featuresListenerKeys_; let features = this.getFeatures_(); if (!Array.isArray(features)) { features = features.getArray(); } if (currentMap) { keys.forEach(unlistenByKey); keys.length = 0; this.rBush_.clear(); Object.values(this.featureChangeListenerKeys_).forEach(unlistenByKey); this.featureChangeListenerKeys_ = {}; } 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, ), ); } for (const feature of features) { this.addFeature(feature); } } } /** * @param {import("../pixel.js").Pixel} pixel Pixel * @param {import("../coordinate.js").Coordinate} pixelCoordinate Coordinate * @param {import("../Map.js").default} map Map. * @return {SnappedInfo|null} Snap result */ snapTo(pixel, pixelCoordinate, map) { const projection = map.getView().getProjection(); const projectedCoordinate = fromUserCoordinate(pixelCoordinate, projection); const box = toUserExtent( buffer( boundingExtent([projectedCoordinate]), map.getView().getResolution() * this.pixelTolerance_, ), projection, ); const segments = this.rBush_.getInExtent(box); const segmentsLength = segments.length; if (segmentsLength === 0) { return null; } let closestVertex; let minSquaredDistance = Infinity; let closestFeature; let closestSegment = null; const squaredPixelTolerance = this.pixelTolerance_ * this.pixelTolerance_; const getResult = () => { if (!closestVertex) { return null; } const vertexPixel = map.getPixelFromCoordinate(closestVertex); const squaredPixelDistance = squaredDistance(pixel, vertexPixel); if (squaredPixelDistance > squaredPixelTolerance) { return null; } return { vertex: closestVertex, vertexPixel: [Math.round(vertexPixel[0]), Math.round(vertexPixel[1])], feature: closestFeature, segment: closestSegment, }; }; if (this.vertex_ || this.intersection_) { for (let i = 0; i < segmentsLength; ++i) { const segmentData = segments[i]; if (segmentData.feature.getGeometry().getType() !== 'Circle') { for (const vertex of segmentData.segment) { const tempVertexCoord = fromUserCoordinate(vertex, projection); const delta = squaredDistance(projectedCoordinate, tempVertexCoord); if ( delta < minSquaredDistance && ((this.intersection_ && segmentData.intersectionFeature) || (this.vertex_ && !segmentData.intersectionFeature)) ) { closestVertex = vertex; minSquaredDistance = delta; closestFeature = segmentData.feature; } } } } const result = getResult(); if (result) { return result; } } if (this.edge_) { for (let i = 0; i < segmentsLength; ++i) { let vertex = null; const segmentData = segments[i]; if (segmentData.feature.getGeometry().getType() === 'Circle') { let circleGeometry = segmentData.feature.getGeometry(); const userProjection = getUserProjection(); if (userProjection) { circleGeometry = circleGeometry .clone() .transform(userProjection, projection); } vertex = closestOnCircle( projectedCoordinate, /** @type {import("../geom/Circle.js").default} */ (circleGeometry), ); } else { const [segmentStart, segmentEnd] = segmentData.segment; // points have only one coordinate if (segmentEnd) { tempSegment[0] = fromUserCoordinate(segmentStart, projection); tempSegment[1] = fromUserCoordinate(segmentEnd, projection); vertex = closestOnSegment(projectedCoordinate, tempSegment); } } if (vertex) { const delta = squaredDistance(projectedCoordinate, vertex); if (delta < minSquaredDistance) { closestVertex = toUserCoordinate(vertex, projection); closestSegment = segmentData.feature.getGeometry().getType() === 'Circle' ? null : segmentData.segment; minSquaredDistance = delta; closestFeature = segmentData.feature; } } } const result = getResult(); if (result) { return result; } } return null; } /** * @param {import("../Feature.js").default} feature Feature * @private */ updateFeature_(feature) { this.removeFeature(feature, false); this.addFeature(feature, false); } } export default Snap;