ol-rotate-feature
Version:
Rotate vector features interaction for OpenLayers
698 lines (621 loc) • 16.9 kB
JavaScript
/**
* This file is part of ol-rotate-feature package.
* @module ol-rotate-feature
* @license MIT
* @author Vladimir Vershinin
*/
/**
* Rotate interaction class.
* Adds controls to rotate vector features.
* Writes out total angle in radians (positive is counter-clockwise) to property for each feature.
*/
import { Pointer as PointerInteraction } from 'ol/interaction'
import { Collection, Feature } from 'ol'
import { Vector as VectorLayer } from 'ol/layer'
import VectorSource from 'ol/source/Vector'
import { GeometryCollection, Point, Polygon } from 'ol/geom'
import { Fill, RegularShape, Stroke, Style, Text } from 'ol/style'
import { getCenter as getExtentCenter } from 'ol/extent'
import { always, mouseOnly, touchOnly, penOnly } from 'ol/events/condition'
import { assert, identity, includes, isArray } from './util'
import RotateFeatureEvent, { RotateFeatureEventType } from './event'
import { mouseActionButton } from './shim'
const ANCHOR_KEY = 'rotate-anchor'
const ARROW_KEY = 'rotate-arrow'
const ANGLE_PROP = 'angle'
const ANCHOR_PROP = 'anchor'
/**
* @todo todo добавить опцию condition - для возможности переопределения клавиш
*/
export default class RotateFeatureInteraction extends PointerInteraction {
/**
* @param {InteractionOptions} options
*/
constructor (options = {}) {
super({
// handleEvent: handleEvent,
handleDownEvent,
handleUpEvent,
handleDragEvent,
handleMoveEvent,
})
/**
* @type {string}
* @private
*/
this.previousCursor_ = undefined
/**
* @type {Feature}
* @private
*/
this.anchorFeature_ = undefined
/**
* @type {Feature}
* @private
*/
this.arrowFeature_ = undefined
/**
* @type {Coordinate}
* @private
*/
this.lastCoordinate_ = undefined
/**
* @type {boolean}
* @private
*/
this.anchorMoving_ = false
/**
* @type {Vector}
* @private
*/
this.overlay_ = new VectorLayer({
style: options.style || getDefaultStyle(),
source: new VectorSource({
features: new Collection(),
}),
})
/**
* @private
* @type {module:ol/events/condition~Condition}
*/
this.condition_ = options.condition ? options.condition : always
/**
* @type {Collection<Feature>}
* @private
*/
this.features_ = undefined
if (options.features) {
if (isArray(options.features)) {
this.features_ = new Collection(options.features)
} else if (options.features instanceof Collection) {
this.features_ = options.features
} else {
throw new Error('Features option should be an array or collection of features, ' +
'got ' + (typeof options.features))
}
} else {
this.features_ = new Collection()
}
/**
* @type {boolean}
* @public
*/
this.allowAnchorMovement = options.allowAnchorMovement === undefined ? true : options.allowAnchorMovement
this.setAnchor(options.anchor || getFeaturesCentroid(this.features_))
this.setAngle(options.angle || 0)
this.features_.on('add', this.onFeatureAdd_.bind(this))
this.features_.on('remove', this.onFeatureRemove_.bind(this))
this.on('change:' + ANGLE_PROP, this.onAngleChange_.bind(this))
this.on('change:' + ANCHOR_PROP, this.onAnchorChange_.bind(this))
this.createOrUpdateAnchorFeature_()
this.createOrUpdateArrowFeature_()
}
/**
* @type {Collection<Feature>}
*/
get features () {
return this.features_
}
/**
* @type {number}
*/
get angle () {
return this.getAngle()
}
/**
* @param {number} angle
*/
set angle (angle) {
this.setAngle(angle)
}
/**
* @type {Coordinate|number[]|undefined}
*/
get anchor () {
return this.getAnchor()
}
/**
* @param {Coordinate|undefined} anchor
*/
set anchor (anchor) {
this.setAnchor(anchor)
}
/**
* @param {PluggableMap} map
*/
set map (map) {
this.setMap(map)
}
/**
* @type {PluggableMap}
*/
get map () {
return this.getMap()
}
/**
* @param {boolean} active
*/
set active (active) {
this.setActive(active)
}
/**
* @type {boolean}
*/
get active () {
return this.getActive()
}
/**
* @param {ol.Map} map
*/
setMap (map) {
this.overlay_.setMap(map)
super.setMap(map)
}
/**
* @param {boolean} active
*/
setActive (active) {
if (this.overlay_) {
this.overlay_.setMap(active ? this.map : undefined)
}
super.setActive(active)
}
/**
* Set current angle of interaction features.
*
* @param {number} angle
*/
setAngle (angle) {
assert(!isNaN(parseFloat(angle)), 'Numeric value passed')
this.set(ANGLE_PROP, parseFloat(angle))
}
/**
* Returns current angle of interaction features.
*
* @return {number}
*/
getAngle () {
return this.get(ANGLE_PROP)
}
/**
* Set current anchor position.
*
* @param {Coordinate | undefined} anchor
*/
setAnchor (anchor) {
assert(anchor == null || isArray(anchor) && anchor.length === 2, 'Array of two elements passed')
this.set(ANCHOR_PROP, anchor != null ? anchor.map(parseFloat) : getFeaturesCentroid(this.features_))
}
/**
* Returns current anchor position.
*
* @return {Coordinate | undefined}
*/
getAnchor () {
return this.get(ANCHOR_PROP)
}
/**
* @private
*/
createOrUpdateAnchorFeature_ () {
const angle = this.getAngle()
const anchor = this.getAnchor()
if (!anchor) return
if (this.anchorFeature_) {
this.anchorFeature_.getGeometry().setCoordinates(anchor)
this.anchorFeature_.set(ANGLE_PROP, angle)
} else {
this.anchorFeature_ = new Feature({
geometry: new Point(anchor),
[ANGLE_PROP]: angle,
[ANCHOR_KEY]: true,
})
this.overlay_.getSource().addFeature(this.anchorFeature_)
}
}
/**
* @private
*/
createOrUpdateArrowFeature_ () {
const angle = this.getAngle()
const anchor = this.getAnchor()
if (!anchor) return
if (this.arrowFeature_) {
this.arrowFeature_.getGeometry().setCoordinates(anchor)
this.arrowFeature_.set(ANGLE_PROP, angle)
} else {
this.arrowFeature_ = new Feature({
geometry: new Point(anchor),
[ANGLE_PROP]: angle,
[ARROW_KEY]: true,
})
this.overlay_.getSource().addFeature(this.arrowFeature_)
}
}
/**
* @private
*/
resetAngleAndAnchor_ () {
this.resetAngle_()
this.resetAnchor_()
}
/**
* @private
*/
resetAngle_ () {
this.set(ANGLE_PROP, 0, true)
this.arrowFeature_ && this.arrowFeature_.set(ANGLE_PROP, this.getAngle())
this.anchorFeature_ && this.anchorFeature_.set(ANGLE_PROP, this.getAngle())
}
/**
* @private
*/
resetAnchor_ () {
this.set(ANCHOR_PROP, getFeaturesCentroid(this.features_), true)
if (this.getAnchor()) {
this.arrowFeature_ && this.arrowFeature_.getGeometry().setCoordinates(this.getAnchor())
this.anchorFeature_ && this.anchorFeature_.getGeometry().setCoordinates(this.getAnchor())
}
}
/**
* @private
*/
onFeatureAdd_ () {
this.resetAngleAndAnchor_()
this.createOrUpdateAnchorFeature_()
this.createOrUpdateArrowFeature_()
}
/**
* @private
*/
onFeatureRemove_ () {
this.resetAngleAndAnchor_()
if (this.features_.getLength()) {
this.createOrUpdateAnchorFeature_()
this.createOrUpdateArrowFeature_()
} else {
this.overlay_.getSource().clear()
this.anchorFeature_ = this.arrowFeature_ = undefined
}
}
/**
* @private
*/
onAngleChange_ ({oldValue}) {
this.features_.forEach(feature => feature.getGeometry().rotate(this.getAngle() - oldValue, this.getAnchor()))
this.arrowFeature_ && this.arrowFeature_.set(ANGLE_PROP, this.getAngle())
this.anchorFeature_ && this.anchorFeature_.set(ANGLE_PROP, this.getAngle())
}
/**
* @private
*/
onAnchorChange_ () {
const anchor = this.getAnchor()
if (anchor) {
this.anchorFeature_ && this.anchorFeature_.getGeometry().setCoordinates(anchor)
this.arrowFeature_ && this.arrowFeature_.getGeometry().setCoordinates(anchor)
}
}
/**
* @param {Collection<Feature>} features
* @private
*/
dispatchRotateStartEvent_ (features) {
this.dispatchEvent(
new RotateFeatureEvent(
RotateFeatureEventType.START,
features,
this.getAngle(),
this.getAnchor(),
),
)
}
/**
* @param {Collection<Feature>} features
* @private
*/
dispatchRotatingEvent_ (features) {
this.dispatchEvent(
new RotateFeatureEvent(
RotateFeatureEventType.ROTATING,
features,
this.getAngle(),
this.getAnchor(),
),
)
}
/**
* @param {Collection<Feature>} features
* @private
*/
dispatchRotateEndEvent_ (features) {
this.dispatchEvent(
new RotateFeatureEvent(
RotateFeatureEventType.END,
features,
this.getAngle(),
this.getAnchor(),
),
)
}
}
/**
* @param {MapBrowserEvent} evt Map browser event.
* @return {boolean} `false` to stop event propagation.
* @this {RotateFeatureInteraction}
* @private
*/
// function handleEvent (evt) {
// // disable selection of inner features
// const foundFeature = evt.map.forEachFeatureAtPixel(evt.pixel, identity)
// if (
// includes([ 'click', 'singleclick', 'dblclick' ], evt.type) &&
// includes([ this.anchorFeature_, this.arrowFeature_ ], foundFeature)
// ) {
// return false
// }
//
// return this::baseHandleEvent(evt)
// }
/**
* @param {MapBrowserEvent} evt Event.
* @return {boolean}
* @this {RotateFeatureInteraction}
* @private
*/
function handleDownEvent (evt) {
if (!(mouseOnly(evt) || touchOnly(evt) || penOnly(evt))) {
return false
}
if (mouseActionButton(evt) && this.condition_(evt)) {
// disable selection of inner features
const foundFeature = evt.map.forEachFeatureAtPixel(evt.pixel, identity)
if (
includes(['click', 'singleclick', 'dblclick'], evt.type) &&
includes([this.anchorFeature_, this.arrowFeature_], foundFeature)
) {
return false
}
// handle click & drag on features for rotation
if (
foundFeature && !this.lastCoordinate_ &&
(
includes(this.features_.getArray(), foundFeature) ||
foundFeature === this.arrowFeature_
)
) {
this.lastCoordinate_ = evt.coordinate
handleMoveEvent.call(this, evt)
this.dispatchRotateStartEvent_(this.features_)
return true
}
// handle click & drag on rotation anchor feature
else if (foundFeature && foundFeature === this.anchorFeature_ && this.allowAnchorMovement) {
this.anchorMoving_ = true
handleMoveEvent.call(this, evt)
return true
}
}
return false
}
/**
* @param {MapBrowserEvent} evt Event.
* @return {boolean}
* @this {RotateFeatureInteraction}
* @private
*/
function handleUpEvent (evt) {
// stop drag sequence of features
if (this.lastCoordinate_) {
this.lastCoordinate_ = undefined
handleMoveEvent.call(this, evt)
this.dispatchRotateEndEvent_(this.features_)
return true
}
// stop drag sequence of the anchors
else if (this.anchorMoving_) {
this.anchorMoving_ = false
handleMoveEvent.call(this, evt)
return true
}
return false
}
/**
* @param {MapBrowserEvent} evt Event.
* @return {boolean}
* @this {RotateFeatureInteraction}
* @private
*/
function handleDragEvent ({coordinate}) {
const anchorCoordinate = this.anchorFeature_.getGeometry().getCoordinates()
// handle drag of features by angle
if (this.lastCoordinate_) {
// calculate vectors of last and current pointer positions
const lastVector = [
this.lastCoordinate_[0] - anchorCoordinate[0],
this.lastCoordinate_[1] - anchorCoordinate[1],
]
const newVector = [
coordinate[0] - anchorCoordinate[0],
coordinate[1] - anchorCoordinate[1],
]
// calculate angle between last and current vectors (positive angle counter-clockwise)
let angle = Math.atan2(
lastVector[0] * newVector[1] - newVector[0] * lastVector[1],
lastVector[0] * newVector[0] + lastVector[1] * newVector[1],
)
this.setAngle(this.getAngle() + angle)
this.dispatchRotatingEvent_(this.features_)
this.lastCoordinate_ = coordinate
}
// handle drag of the anchor
else if (this.anchorMoving_) {
this.setAnchor(coordinate)
}
}
/**
* @param {MapBrowserEvent} evt Event.
* @return {boolean}
* @this {RotateFeatureInteraction}
* @private
*/
function handleMoveEvent ({map, pixel}) {
const elem = map.getTargetElement()
const foundFeature = map.forEachFeatureAtPixel(pixel, identity)
const setCursor = (cursor, vendor = false) => {
if (vendor) {
elem.style.cursor = '-webkit-' + cursor
elem.style.cursor = '-moz-' + cursor
}
elem.style.cursor = cursor
}
if (this.lastCoordinate_) {
this.previousCursor_ = elem.style.cursor
setCursor('grabbing', true)
} else if (
foundFeature &&
(
includes(this.features_.getArray(), foundFeature) ||
foundFeature === this.arrowFeature_
)
) {
this.previousCursor_ = elem.style.cursor
setCursor('grab', true)
} else if ((foundFeature && foundFeature === this.anchorFeature_ && this.allowAnchorMovement) || this.anchorMoving_) {
this.previousCursor_ = elem.style.cursor
setCursor('crosshair')
} else {
setCursor(this.previousCursor_ || '')
this.previousCursor_ = undefined
}
}
/**
* @returns {StyleFunction}
* @private
*/
function getDefaultStyle () {
const white = [255, 255, 255, 0.8]
const blue = [0, 153, 255, 0.8]
const transparent = [255, 255, 255, 0.01]
const width = 2
const styles = {
[ANCHOR_KEY]: [
new Style({
image: new RegularShape({
fill: new Fill({
color: [0, 153, 255, 0.8],
}),
stroke: new Stroke({
color: blue,
width: 1,
}),
radius: 4,
points: 6,
}),
zIndex: Infinity,
}),
],
[ARROW_KEY]: [
new Style({
fill: new Fill({
color: transparent,
}),
stroke: new Stroke({
color: white,
width: width + 2,
}),
text: new Text({
font: '12px sans-serif',
offsetX: 20,
offsetY: -20,
fill: new Fill({
color: 'blue',
}),
stroke: new Stroke({
color: white,
width: width + 1,
}),
}),
zIndex: Infinity,
}),
new Style({
fill: new Fill({
color: transparent,
}),
stroke: new Stroke({
color: blue,
width,
}),
zIndex: Infinity,
}),
],
}
return function (feature, resolution) {
let style
const angle = feature.get(ANGLE_PROP) || 0
switch (true) {
case feature.get(ANCHOR_KEY):
style = styles[ANCHOR_KEY]
style[0].getImage().setRotation(-angle)
return style
case feature.get(ARROW_KEY):
style = styles[ARROW_KEY]
const coordinates = feature.getGeometry().getCoordinates()
// generate arrow polygon
const geom = new Polygon([
[
[coordinates[0], coordinates[1] - 6 * resolution],
[coordinates[0] + 8 * resolution, coordinates[1] - 12 * resolution],
[coordinates[0], coordinates[1] + 30 * resolution],
[coordinates[0] - 8 * resolution, coordinates[1] - 12 * resolution],
[coordinates[0], coordinates[1] - 6 * resolution],
],
])
// and rotate it according to current angle
geom.rotate(angle, coordinates)
style[0].setGeometry(geom)
style[1].setGeometry(geom)
style[0].getText().setText(Math.round(-angle * 180 / Math.PI) + '°')
return style
}
}
}
/**
* @param {Collection<Feature>|Array<Feature>} features
* @returns {Extent | number[] | undefined}
* @private
*/
function getFeaturesExtent (features) {
features = features instanceof Collection ? features.getArray() : features
if (!features.length) return
return new GeometryCollection(features.map(feature => feature.getGeometry())).getExtent()
}
/**
* @param {Collection<ol.Feature> | Array<Feature>} features
* @return {Coordinate | number[] | undefined}
*/
function getFeaturesCentroid (features) {
features = features instanceof Collection ? features.getArray() : features
if (!features.length) return
return getExtentCenter(getFeaturesExtent(features))
}