var geo_event = require('../event');
var geo_action = require('../action');
var transform = require('../transform');
var util = require('../util');
var textFeature = require('../textFeature');
var annotationId = 0;
/**
* @alias geo.annotation.state
* @enum {string}
*/
var annotationState = {
create: 'create',
done: 'done',
highlight: 'highlight',
edit: 'edit',
cursor: 'cursor'
};
var annotationActionOwner = 'annotationAction';
/**
* These styles are applied to edit handles, and can be overridden by
* individual annotations.
*
* @alias geo.annotation.defaultEditHandleStyle
* @type {object}
* @default
*/
var defaultEditHandleStyle = {
fill: true,
fillColor: function (d) {
return d.selected ? {r: 0, g: 1, b: 1} : {r: 0.3, g: 0.3, b: 0.3};
},
fillOpacity: function (d) {
return d.selected ? 0.5 : 0.25;
},
radius: function (d) {
return d.type === 'edge' || d.type === 'rotate' ? 8 : 10;
},
scaled: false,
stroke: true,
strokeColor: {r: 0, g: 0, b: 1},
strokeOpacity: 1,
strokeWidth: function (d) {
return d.type === 'edge' || d.type === 'rotate' ? 2 : 3;
},
rotateHandleOffset: 24, // should be roughly twice radius + strokeWidth
rotateHandleRotation: -Math.PI / 4,
resizeHandleOffset: 48, // should be roughly twice radius + strokeWidth + rotateHandleOffset
resizeHandleRotation: -Math.PI / 4,
// handles may be a function to dynamically generate the results
handles: { // if `false`, the handle won't be created for editing
vertex: true,
edge: true,
center: true,
rotate: true,
resize: true
}
};
var editHandleFeatureLevel = 3;
/**
* General annotation specification.
*
* @typedef {object} geo.annotation.spec
* @property {string} [name] A name for the annotation. This defaults to the
* type with a unique ID suffixed to it.
* @property {geo.annotationLayer} [layer] A reference to the controlling
* layer. This is used for coordinate transforms.
* @property {string} [state] Initial annotation state. One of the
* {@link geo.annotation.state} values.
* @property {boolean|string[]} [showLabel=true] `true` to show the annotation
* label on annotations in done or edit states. Alternately, a list of
* states in which to show the label. Falsy to not show the label.
* @property {boolean} [allowBooleanOperations] This defaults to `true` for
* annotations that have area and `false` for those without area (e.g.,
* false for lines and points). If it is truthy, then, when the annotation
* is being created, it checks the metakeys on the first click that defines
* a coordinate to determine what boolean polygon operation should be
* performaned on the completion of the annotation.
*/
/**
* Base annotation class.
*
* @class
* @alias geo.annotation
* @param {string} type The type of annotation. These should be registered
* with {@link geo.registerAnnotation} and can be listed with
* {@link geo.listAnnotations}.
* @param {geo.annotation.spec?} [args] Options for the annotation.
* @returns {geo.annotation}
*/
var annotation = function (type, args) {
'use strict';
if (!(this instanceof annotation)) {
return new annotation(type, args);
}
var m_this = this,
m_options = util.deepMerge({}, this.constructor.defaults, args || {}),
m_id = m_options.annotationId;
delete m_options.annotationId;
if (m_id === undefined || (m_options.layer && m_options.layer.annotationById(m_id))) {
annotationId += 1;
if (m_id !== undefined) {
console.warn('Annotation id ' + m_id + ' is in use; using ' + annotationId + ' instead.');
}
m_id = annotationId;
} else {
if (m_id > annotationId) {
annotationId = m_id;
}
}
var m_name = m_options.name || (
type.charAt(0).toUpperCase() + type.substr(1) + ' ' + m_id),
m_label = m_options.label || null,
m_description = m_options.description || undefined,
m_type = type,
m_layer = m_options.layer,
/* one of annotationState.* */
m_state = m_options.state || annotationState.done;
delete m_options.state;
delete m_options.layer;
delete m_options.name;
delete m_options.label;
delete m_options.description;
if (m_options.constraint) {
if (util.isFunction(m_options.constraint)) {
this._selectionConstraint = m_options.constraint;
} else {
this._selectionConstraint = constrainAspectRatio(m_options.constraint);
}
}
/**
* Clean up any resources that the annotation is using.
*/
this._exit = function () {
if (m_this.layer()) {
m_this.layer().geoOff(geo_event.mousemove, m_this._cursorHandleMousemove);
}
};
/**
* Get a unique annotation id.
*
* @returns {number} The annotation id.
*/
this.id = function () {
return m_id;
};
/**
* Assign a new id to this annotation.
*
* @returns {this}
*/
this.newId = function () {
annotationId += 1;
m_id = annotationId;
return m_this;
};
/**
* Get or set the name of this annotation.
*
* @param {string|undefined} [arg] If `undefined`, return the name, otherwise
* change it. When setting the name, the value is trimmed of
* whitespace. The name will not be changed to an empty string.
* @returns {this|string} The current name or this annotation.
*/
this.name = function (arg) {
if (arg === undefined) {
return m_name;
}
if (arg !== null && ('' + arg).trim()) {
arg = ('' + arg).trim();
if (arg !== m_name) {
m_name = arg;
m_this.modified();
}
}
return m_this;
};
/**
* Get or set the label of this annotation.
*
* @param {string|null|undefined} [arg] If `undefined`, return the label,
* otherwise change it. `null` to clear the label.
* @param {boolean} [noFallback] If not truthy and the label is `null`,
* return the name, otherwise return the actual value for label.
* @returns {this|string} The current label or this annotation.
*/
this.label = function (arg, noFallback) {
if (arg === undefined) {
return m_label === null && !noFallback ? m_name : m_label;
}
if (arg !== m_label) {
m_label = arg;
m_this.modified();
}
return m_this;
};
/**
* Return the coordinate associated with the label.
*
* @returns {geo.geoPosition|undefined} The map gcs position for the label,
* or `undefined` if no such position exists.
*/
this._labelPosition = function () {
return util.centerFromPerimeter(m_this._coordinates());
};
/**
* Return the coordinate associated with the rotation handle for the
* annotation.
*
* @param {number} [offset] An additional offset from cetner to apply to the
* handle.
* @param {number} [rotation] An additional rotation to apply to the handle.
* @returns {geo.geoPosition|undefined} The map gcs position for the handle,
* or `undefined` if no such position exists.
*/
this._rotateHandlePosition = function (offset, rotation) {
var map = m_this.layer().map(),
coord = m_this._coordinates(),
center = util.centerFromPerimeter(m_this._coordinates()),
dispCenter = center ? map.gcsToDisplay(center, null) : undefined,
i, pos, maxr2 = 0, r;
if (!center) {
return;
}
offset = offset || 0;
rotation = rotation || 0;
coord = coord.outer ? coord.outer : coord;
for (i = 0; i < coord.length; i += 1) {
pos = map.gcsToDisplay(coord[i], null);
maxr2 = Math.max(maxr2, Math.pow(pos.x - dispCenter.x, 2) + Math.pow(pos.y - dispCenter.y, 2));
}
r = Math.sqrt(maxr2) + offset;
pos = map.displayToGcs({
x: dispCenter.x + r * Math.cos(rotation),
y: dispCenter.y - r * Math.sin(rotation)}, null);
return pos;
};
/**
* If the label should be shown, get a record of the label that can be used
* in a {@link geo.textFeature}.
*
* @returns {geo.annotationLayer.labelRecord|undefined} A label record, or
* `undefined` if it should not be shown.
*/
this.labelRecord = function () {
var show = m_this.options('showLabel');
if (!show) {
return;
}
var state = m_this.state();
if ((show === true && state === annotationState.create) ||
(show !== true && show.indexOf(state) < 0)) {
return;
}
var style = m_this.labelStyle();
var labelRecord = {
text: m_this.label(),
position: m_this._labelPosition()
};
if (!labelRecord.position) {
return;
}
if (style) {
labelRecord.style = style;
}
return labelRecord;
};
/**
* Get or set the description of this annotation.
*
* @param {string|undefined} arg If `undefined`, return the description,
* otherwise change it.
* @returns {this|string} The current description or this annotation.
*/
this.description = function (arg) {
if (arg === undefined) {
return m_description;
}
if (arg !== m_description) {
m_description = arg;
m_this.modified();
}
return m_this;
};
/**
* Get or set the annotation layer associated with this annotation.
*
* @param {geo.annotationLayer|undefined} arg if undefined, return the layer,
* otherwise change it.
* @returns {this|geo.annotationLayer} the current layer or this annotation.
*/
this.layer = function (arg) {
if (arg === undefined) {
return m_layer;
}
m_layer = arg;
return m_this;
};
this._cursorHandleMousemove = function (evt) {
m_this.layer()._handleMouseMoveModifiers(evt);
const center = m_this._cursorCenter;
const delta = {
x: evt.mapgcs.x - center.x,
y: evt.mapgcs.y - center.y
};
if (delta.x || delta.y) {
const curPts = m_this._coordinates();
var pts = m_this._coordinatesMapFunc(curPts, function (elem) {
return {x: elem.x + delta.x, y: elem.y + delta.y};
});
m_this._coordinates(pts);
m_this._cursorCenter = evt.mapgcs;
m_this.modified();
m_this.draw();
return true;
}
return false;
};
/**
* Get or set the state of this annotation.
*
* @param {string|undefined} [arg] If `undefined`, return the state,
* otherwise change it. This should be one of the
* {@link geo.annotation.state} values.
* @returns {this|string} The current state or this annotation.
* @fires geo.event.annotation.state
*/
this.state = function (arg) {
if (arg === undefined) {
return m_state;
}
if (m_state !== arg) {
m_state = arg;
if (m_this.layer()) {
m_this.layer().geoTrigger(geo_event.annotation.state, {
annotation: m_this
});
}
if (m_this.layer()) {
m_this.layer().geoOff(geo_event.mousemove, m_this._cursorHandleMousemove);
}
switch (m_state) {
case annotationState.cursor:
m_this._cursorCenter = util.centerFromPerimeter(m_this._coordinates());
if (m_this.layer()) {
m_this.layer().geoOn(geo_event.mousemove, m_this._cursorHandleMousemove);
}
break;
}
}
return m_this;
};
/**
* Return actions needed for the specified state of this annotation.
*
* @param {string} [state] The state to return actions for. Defaults to
* the current state.
* @returns {geo.actionRecord[]} A list of actions.
*/
this.actions = function (state) {
if (!state) {
state = m_this.state();
}
switch (state) {
case annotationState.edit:
return [{
action: geo_action.annotation_edit_handle,
name: 'annotation edit',
owner: annotationActionOwner,
input: 'left'
}, {
action: geo_action.annotation_edit_handle,
name: 'annotation edit',
owner: annotationActionOwner,
input: 'pan'
}];
case annotationState.cursor:
return [{
action: geo_action.annotation_cursor,
name: 'annotation cursor',
owner: annotationActionOwner,
input: 'pan'
}, {
action: geo_action.annotation_cursor,
name: 'annotation cursor',
owner: annotationActionOwner,
input: 'left'
}];
default:
return [];
}
};
/**
* Process any non-edit actions for this annotation.
*
* @param {geo.event} evt The action event.
* @returns {boolean|string} `true` to update the annotation, `'done'` if the
* annotation was completed (changed from create to done state),
* `'remove'` if the annotation should be removed, falsy to not update
* anything.
*/
this.processAction = function (evt) {
return undefined;
};
/**
* Process any edit actions for this annotation.
*
* @param {geo.event} evt The action event.
* @returns {boolean} `true` to update the annotation, falsy to not update
* anything.
*/
this.processEditAction = function (evt) {
if (!evt || !m_this._editHandle || !m_this._editHandle.handle) {
return;
}
switch (m_this._editHandle.handle.type) {
case 'vertex':
return m_this._processEditActionVertex(evt);
case 'edge':
return m_this._processEditActionEdge(evt);
case 'center':
return m_this._processEditActionCenter(evt);
case 'rotate':
return m_this._processEditActionRotate(evt);
case 'resize':
return m_this._processEditActionResize(evt);
}
};
/**
* Return a copy of the _coordinates or a geo.polygon record so that it
* doesn't share memory with the original.
*
* @param {geo.polygon} [coord] if specified, return a copy of this object.
* Otherwise, return a copy of this._coordinates.
* @returns {geo.polygon}
*/
this._copyOfCoordinates = function (coord) {
coord = coord || m_this._coordinates();
if (!coord.outer) {
return coord.slice();
}
return {outer: coord.outer.slice(), inner: (coord.inner || []).map((h) => h.slice())};
};
/**
* When an edit handle is selected or deselected (for instance, by moving the
* mouse on or off of it), mark if it is selected and record the current
* coordinates.
*
* @param {object} handle The data for the edit handle.
* @param {boolean} enable True to enable the handle, false to disable.
* @returns {this}
* @fires geo.event.annotation.select_edit_handle
*/
this.selectEditHandle = function (handle, enable) {
if (enable && m_this._editHandle && m_this._editHandle.handle &&
m_this._editHandle.handle.selected) {
m_this._editHandle.handle.selected = false;
}
handle.selected = enable;
var amountRotated = (m_this._editHandle || {}).amountRotated || 0;
m_this._editHandle = {
handle: handle,
startCoordinates: m_this._copyOfCoordinates(),
center: util.centerFromPerimeter(m_this._coordinates()),
rotatePosition: m_this._rotateHandlePosition(
handle.style.rotateHandleOffset, handle.style.rotateHandleRotation + amountRotated),
startAmountRotated: amountRotated,
amountRotated: amountRotated,
resizePosition: m_this._rotateHandlePosition(
handle.style.resizeHandleOffset, handle.style.resizeHandleRotation)
};
if (m_this.layer()) {
m_this.layer().geoTrigger(geo_event.annotation.select_edit_handle, {
annotation: m_this,
handle: m_this._editHandle,
enable: enable
});
}
return m_this;
};
/**
* Get or set options.
*
* @param {string|object} [arg1] If `undefined`, return the options object.
* If a string, either set or return the option of that name. If an
* object, update the options with the object's values.
* @param {object} [arg2] If `arg1` is a string and this is defined, set
* the option to this value.
* @returns {object|this} If options are set, return the annotation,
* otherwise return the requested option or the set of options.
* @fires geo.event.annotation.coordinates
*/
this.options = function (arg1, arg2) {
if (arg1 === undefined) {
return m_options;
}
if (typeof arg1 === 'string' && arg2 === undefined) {
return m_options[arg1];
}
var coordinatesSet;
if (arg2 === undefined) {
coordinatesSet = arg1[m_this._coordinateOption] !== undefined;
m_options = util.deepMerge(m_options, arg1);
/* For style objects, re-extend them without recursion. This allows
* setting colors without an opacity field, for instance. */
['style', 'createStyle', 'editStyle', 'editHandleStyle', 'labelStyle',
'highlightStyle', 'cursorStyle'
].forEach(function (key) {
if (arg1[key] !== undefined) {
Object.assign(m_options[key], arg1[key]);
}
});
} else {
coordinatesSet = arg1 === m_this._coordinateOption;
m_options[arg1] = arg2;
}
if (m_options.coordinates) {
var coord = m_options.coordinates;
delete m_options.coordinates;
m_this._coordinates(coord);
}
if (m_options.name !== undefined) {
var name = m_options.name;
delete m_options.name;
m_this.name(name);
}
if (m_options.label !== undefined) {
var label = m_options.label;
delete m_options.label;
m_this.label(label);
}
if (m_options.description !== undefined) {
var description = m_options.description;
delete m_options.description;
m_this.description(description);
}
m_this.modified();
if (coordinatesSet && m_this.layer()) {
m_this.layer().geoTrigger(geo_event.annotation.coordinates, {
annotation: m_this
});
}
return m_this;
};
/**
* Get or set style.
*
* @param {string|object} [arg1] If `undefined`, return the current style
* object. If a string and `arg2` is undefined, return the style
* associated with the specified key. If a string and `arg2` is defined,
* set the named style to the specified value. Otherwise, extend the
* current style with the values in the specified object.
* @param {*} [arg2] If `arg1` is a string, the new value for that style.
* @param {string} [styleType='style'] The name of the style type, such as
* `createStyle`, `editStyle`, `editHandleStyle`, `labelStyle`,
* `highlightStyle`, or `cursorStyle`.
* @returns {object|this} Either the entire style object, the value of a
* specific style, or the current class instance.
*/
this.style = function (arg1, arg2, styleType) {
styleType = styleType || 'style';
if (arg1 === undefined) {
return m_options[styleType];
}
if (typeof arg1 === 'string' && arg2 === undefined) {
return (m_options[styleType] || {})[arg1];
}
if (m_options[styleType] === undefined) {
m_options[styleType] = {};
}
if (arg2 === undefined) {
m_options[styleType] = util.deepMerge(m_options[styleType], arg1);
} else {
m_options[styleType][arg1] = arg2;
}
m_this.modified();
return m_this;
};
/**
* Calls {@link geo.annotation#style} with `styleType='createStyle'`.
* @function createStyle
* @memberof geo.annotation
* @instance
*/
/**
* Calls {@link geo.annotation#style} with `styleType='editStyle'`.
* @function editStyle
* @memberof geo.annotation
* @instance
*/
/**
* Calls {@link geo.annotation#style} with `styleType='editHandleStyle'`.
* @function editHandleStyle
* @memberof geo.annotation
* @instance
*/
/**
* Calls {@link geo.annotation#style} with `styleType='labelStyle'`.
* @function labelStyle
* @memberof geo.annotation
* @instance
*/
/**
* Calls {@link geo.annotation#style} with `styleType='highlightStyle'`.
* @function highlightStyle
* @memberof geo.annotation
* @instance
*/
/**
* Calls {@link geo.annotation#style} with `styleType='cursorStyle'`.
* @function cursorStyle
* @memberof geo.annotation
* @instance
*/
['createStyle', 'editStyle', 'editHandleStyle', 'labelStyle', 'highlightStyle', 'cursorStyle'
].forEach(function (styleType) {
m_this[styleType] = function (arg1, arg2) {
return m_this.style(arg1, arg2, styleType);
};
});
/**
* Return the style dictionary for a particular state.
* @param {string} [state] The state to return styles for. Defaults to the
* current state.
* @returns {object} The style object for the state. If there is no such
* style defined, the default style is used.
*/
this.styleForState = function (state) {
state = state || m_this.state();
/* for some states, fall back to the general style if they don't specify a
* value explicitly. */
if (state === annotationState.edit || state === annotationState.highlight) {
return Object.assign({}, m_options.style, m_options[state + 'Style']);
}
if (state === annotationState.create) {
return Object.assign({}, m_options.style, m_options.editStyle,
m_options[state + 'Style']);
}
if (state === annotationState.cursor) {
return Object.assign({}, m_options.style, m_options.editStyle,
m_options.createStyle, m_options[state + 'Style']);
}
return m_options[state + 'Style'] || m_options.style || {};
};
/**
* Get the type of this annotation.
*
* @returns {string} The annotation type.
*/
this.type = function () {
return m_type;
};
/**
* Get a list of renderable features for this annotation. The list index is
* functionally a z-index for the feature. Each entry is a dictionary with
* the key as the feature name (such as `line`, `quad`, or `polygon`), and
* the value a dictionary of values to pass to the feature constructor, such
* as `style` and `coordinates`.
*
* @returns {array} An array of features.
*/
this.features = function () {
return [];
};
/**
* Handle a mouse click on this annotation. If the event is processed,
* evt.handled should be set to `true` to prevent further processing.
*
* @param {geo.event} evt The mouse click event.
* @returns {boolean|string} `true` to update the annotation, `'done'` if
* the annotation was completed (changed from create to done state),
* `'remove'` if the annotation should be removed, falsy to not update
* anything.
*/
this.mouseClick = function (evt) {
return undefined;
};
/**
* Handle a mouse click on this annotation when in edit mode. If the event
* is processed, evt.handled should be set to `true` to prevent further
* processing.
*
* @param {geo.event} evt The mouse click event.
* @returns {boolean|string} `true` to update the annotation, `'done'` if
* the annotation was completed (changed from create to done state),
* `'remove'` if the annotation should be removed, falsy to not update
* anything.
*/
this.mouseClickEdit = function (evt) {
return undefined;
};
/**
* Handle a mouse move on this annotation.
*
* @param {geo.event} evt The mouse move event.
* @returns {boolean} Truthy to update the annotation, falsy to not
* update anything.
*/
this.mouseMove = function (evt) {
return undefined;
};
/**
* Get or set coordinates associated with this annotation in the map gcs
* coordinate system.
*
* @param {geo.geoPosition[]} [coordinates] An optional array of coordinates
* to set.
* @returns {geo.geoPosition[]} The current array of coordinates.
*/
this._coordinates = function (coordinates) {
return [];
};
this._coordinateOption = 'vertices';
/**
* Get coordinates associated with this annotation.
*
* @param {string|geo.transform|null} [gcs] `undefined` to use the interface
* gcs, `null` to use the map gcs, or any other transform.
* @returns {geo.geoPosition[]} An array of coordinates.
*/
this.coordinates = function (gcs) {
var coord = m_this._coordinates() || [];
if (!coord.length && (!coord.outer || !coord.outer.length)) {
coord = [];
}
if (m_this.layer()) {
var map = m_this.layer().map();
gcs = (gcs === null ? map.gcs() : (
gcs === undefined ? map.ingcs() : gcs));
if (gcs !== map.gcs()) {
coord = m_this._convertCoordinates(map.gcs(), gcs, coord);
}
}
return coord;
};
/**
* Mark this annotation as modified. This just marks the parent layer as
* modified.
*
* @returns {this} The annotation.
*/
this.modified = function () {
if (m_this.layer()) {
m_this.layer().modified();
}
return m_this;
};
/**
* Draw this annotation. This just updates and draws the parent layer.
*
* @returns {this} The annotation.
*/
this.draw = function () {
if (m_this.layer()) {
m_this.layer()._update();
m_this.layer().draw();
}
return m_this;
};
/**
* Return a list of styles that should be preserved in a geojson
* representation of the annotation.
*
* @returns {string[]} A list of style names to store.
*/
this._geojsonStyles = function () {
return [
'closed', 'fill', 'fillColor', 'fillOpacity', 'lineCap', 'lineJoin',
'radius', 'stroke', 'strokeColor', 'strokeOffset', 'strokeOpacity',
'strokeWidth'];
};
/**
* Return the coordinates to be stored in a geojson geometry object.
*
* @param {string|geo.transform|null} [gcs] `undefined` to use the interface
* gcs, `null` to use the map gcs, or any other transform.
* @returns {array} An array of flattened coordinates in the interface gcs
* coordinate system. `undefined` if this annotation is incomplete.
*/
this._geojsonCoordinates = function (gcs) {
return [];
};
/**
* Return the geometry type that is used to store this annotation in geojson.
*
* @returns {string} A geojson geometry type.
*/
this._geojsonGeometryType = function () {
return '';
};
/**
* Return the annotation as a geojson object.
*
* @param {string|geo.transform|null} [gcs] `undefined` to use the interface
* gcs, `null` to use the map gcs, or any other transform.
* @param {boolean} [includeCrs] If truthy, include the coordinate system.
* @returns {object} The annotation as a geojson object, or `undefined` if it
* should not be represented (for instance, while it is being created).
*/
this.geojson = function (gcs, includeCrs) {
var coord = m_this._geojsonCoordinates(gcs),
geotype = m_this._geojsonGeometryType(),
styles = m_this._geojsonStyles(),
objStyle = m_this.options('style') || {},
objLabelStyle = m_this.labelStyle() || {},
i, key, value;
if (!coord || !coord.length || !geotype) {
return;
}
var obj = {
type: 'Feature',
geometry: {
type: geotype,
coordinates: coord
},
properties: {
annotationType: m_type,
name: m_this.name(),
annotationId: m_this.id()
}
};
if (m_label) {
obj.properties.label = m_label;
}
if (m_description) {
obj.properties.description = m_description;
}
if (m_this.options('showLabel') === false) {
obj.properties.showLabel = m_this.options('showLabel');
}
for (i = 0; i < styles.length; i += 1) {
key = styles[i];
value = util.ensureFunction(objStyle[key])();
if (value !== undefined) {
let defvalue = ((m_this.constructor.defaults || {}).style || {})[key];
if (key.toLowerCase().match(/color$/)) {
value = util.convertColorToHex(value, 'needed');
defvalue = defvalue !== undefined ? util.convertColorToHex(defvalue, 'needed') : defvalue;
}
if (value !== defvalue) {
obj.properties[key] = value;
}
}
}
for (i = 0; i < textFeature.usedStyles.length; i += 1) {
key = textFeature.usedStyles[i];
value = util.ensureFunction(objLabelStyle[key])();
if (value !== undefined) {
if (key.toLowerCase().match(/color$/)) {
value = util.convertColorToHex(value, 'needed');
}
obj.properties['label' + key.charAt(0).toUpperCase() + key.slice(1)] = value;
}
}
if (includeCrs) {
var map = m_this.layer().map();
gcs = (gcs === null ? map.gcs() : (
gcs === undefined ? map.ingcs() : gcs));
obj.crs = {
type: 'name',
properties: {
type: 'proj4',
name: gcs
}
};
}
return obj;
};
/**
* Add edit handles to the feature list.
*
* @param {array} features The array of features to modify.
* @param {geo.geoPosition[]} vertices An array of vertices in map gcs
* coordinates.
* @param {object} [opts] If specified, the keys are the types of the
* handles. This matches the `editHandleStyle.handle` object. Any type
* that is set to `false` in either `opts` or `editHandleStyle.handle`
* will prevent those handles from being created.
* @param {boolean} [isOpen=false] If true, no edge handle will be created
* between the last and first vertices.
*/
this._addEditHandles = function (features, vertices, opts, isOpen) {
var editPoints,
style = Object.assign({}, defaultEditHandleStyle, m_this.editHandleStyle()),
handles = util.ensureFunction(style.handles)() || {},
selected = (
m_this._editHandle && m_this._editHandle.handle &&
m_this._editHandle.handle.selected ?
m_this._editHandle.handle : undefined);
/* opts specify which handles are allowed. They must be allowed by the
* original opts object and by the editHandleStyle.handle object. */
opts = Object.assign({}, opts);
Object.keys(handles).forEach(function (key) {
if (handles[key] === false) {
opts[key] = false;
}
});
if (!features[editHandleFeatureLevel]) {
features[editHandleFeatureLevel] = {point: []};
}
editPoints = features[editHandleFeatureLevel].point;
const vertexList = vertices.outer ? [vertices.outer].concat(vertices.inner || []) : [vertices];
vertexList.forEach((vert, vidx) => {
vert.forEach(function (pt, idx) {
if (opts.vertex !== false) {
editPoints.push(Object.assign({}, pt, {type: 'vertex', index: idx, vindex: vidx, style: style, editHandle: true}));
}
if (opts.edge !== false && idx !== vert.length - 1 && (pt.x !== vert[idx + 1].x || pt.y !== vert[idx + 1].y)) {
editPoints.push(Object.assign({
x: (pt.x + vert[idx + 1].x) / 2,
y: (pt.y + vert[idx + 1].y) / 2
}, {type: 'edge', index: idx, vindex: vidx, style: style, editHandle: true}));
}
if (opts.edge !== false && !isOpen && idx === vert.length - 1 && (pt.x !== vert[0].x || pt.y !== vert[0].y)) {
editPoints.push(Object.assign({
x: (pt.x + vert[0].x) / 2,
y: (pt.y + vert[0].y) / 2
}, {type: 'edge', index: idx, vindex: vidx, style: style, editHandle: true}));
}
});
});
if (opts.center !== false) {
editPoints.push(Object.assign({}, util.centerFromPerimeter(m_this._coordinates()), {type: 'center', style: style, editHandle: true}));
}
if (opts.rotate !== false) {
editPoints.push(Object.assign(m_this._rotateHandlePosition(
style.rotateHandleOffset,
style.rotateHandleRotation + (selected && selected.type === 'rotate' ? m_this._editHandle.amountRotated : 0)
), {type: 'rotate', style: style, editHandle: true}));
if (m_this._editHandle && (!selected || selected.type !== 'rotate')) {
m_this._editHandle.amountRotated = 0;
}
}
if (opts.resize !== false) {
editPoints.push(Object.assign(m_this._rotateHandlePosition(
style.resizeHandleOffset,
style.resizeHandleRotation
), {type: 'resize', style: style, editHandle: true}));
}
if (selected) {
editPoints.forEach(function (pt) {
if (pt.type === selected.type && pt.index === selected.index && pt.vindex === selected.vindex) {
pt.selected = true;
}
});
}
};
/**
* Apply a map function of a geo.polygon.
*
* @param {geo.polygon} coord The polygon to apply the function to.
* @param {function} func The function to apply.
* @returns {array} The map results.
*/
this._coordinatesMapFunc = function (coord, func) {
if (!coord.outer) {
return coord.map(func);
}
return {
outer: coord.outer.map(func),
inner: (coord.inner || []).map((h) => h.map(func))
};
};
/**
* Check if two geo.polygons differ in their first point.
*
* @param {geo.polygon} coord1 One polygon to compare.
* @param {geo.polygon} coord2 A second polygon to compare.
* @returns {boolean} true if the first point matches.
*/
this._firstPointDifferent = function (coord1, coord2) {
coord1 = coord1.outer ? coord1.outer : coord1;
coord2 = coord2.outer ? coord2.outer : coord2;
return (coord1[0].x !== coord2[0].x || coord1[0].y !== coord2[0].y);
};
/**
* Process the edit center action for a general annotation.
*
* @param {geo.event} evt The action event.
* @returns {boolean|string} `true` to update the annotation, falsy to not
* update anything.
*/
this._processEditActionCenter = function (evt) {
var start = m_this._editHandle.startCoordinates,
delta = {
x: evt.mouse.mapgcs.x - evt.state.origin.mapgcs.x,
y: evt.mouse.mapgcs.y - evt.state.origin.mapgcs.y
},
curPts = m_this._coordinates();
var pts = m_this._coordinatesMapFunc(start, function (elem) {
return {x: elem.x + delta.x, y: elem.y + delta.y};
});
if (m_this._firstPointDifferent(pts, curPts)) {
m_this._coordinates(pts);
return true;
}
return false;
};
/**
* Process the edit rotate action for a general annotation.
*
* @param {geo.event} evt The action event.
* @returns {boolean|string} `true` to update the annotation, falsy to not
* update anything.
*/
this._processEditActionRotate = function (evt) {
var handle = m_this._editHandle,
start = handle.startCoordinates,
delta = {
x: evt.mouse.mapgcs.x - evt.state.origin.mapgcs.x,
y: evt.mouse.mapgcs.y - evt.state.origin.mapgcs.y
},
ang1 = Math.atan2(
handle.rotatePosition.y - handle.center.y,
handle.rotatePosition.x - handle.center.x),
ang2 = Math.atan2(
handle.rotatePosition.y + delta.y - handle.center.y,
handle.rotatePosition.x + delta.x - handle.center.x),
ang = ang2 - ang1,
curPts = m_this._coordinates();
var pts = m_this._coordinatesMapFunc(start, function (elem) {
var delta = {x: elem.x - handle.center.x, y: elem.y - handle.center.y};
return {
x: delta.x * Math.cos(ang) - delta.y * Math.sin(ang) + handle.center.x,
y: delta.x * Math.sin(ang) + delta.y * Math.cos(ang) + handle.center.y
};
});
if (m_this._firstPointDifferent(pts, curPts)) {
m_this._coordinates(pts);
handle.amountRotated = handle.startAmountRotated + ang;
return true;
}
return false;
};
/**
* Process the edit resize action for a general annotation.
*
* @param {geo.event} evt The action event.
* @returns {boolean|string} `true` to update the annotation, falsy to not
* update anything.
*/
this._processEditActionResize = function (evt) {
var handle = m_this._editHandle,
start = handle.startCoordinates,
delta = {
x: evt.mouse.mapgcs.x - evt.state.origin.mapgcs.x,
y: evt.mouse.mapgcs.y - evt.state.origin.mapgcs.y
},
map = m_this.layer().map(),
p0 = map.gcsToDisplay(handle.center, null),
p1 = map.gcsToDisplay(handle.resizePosition, null),
p2 = map.gcsToDisplay({
x: handle.resizePosition.x + delta.x,
y: handle.resizePosition.y + delta.y
}, null),
d01 = Math.pow(Math.pow(p1.y - p0.y, 2) +
Math.pow(p1.x - p0.x, 2), 0.5) -
handle.handle.style.resizeHandleOffset,
d02 = Math.pow(Math.pow(p2.y - p0.y, 2) +
Math.pow(p2.x - p0.x, 2), 0.5) -
handle.handle.style.resizeHandleOffset,
curPts = m_this._coordinates();
if (d02 && d01) {
var scale = d02 / d01;
var pts = m_this._coordinatesMapFunc(start, function (elem) {
return {
x: (elem.x - handle.center.x) * scale + handle.center.x,
y: (elem.y - handle.center.y) * scale + handle.center.y
};
});
if (m_this._firstPointDifferent(pts, curPts)) {
m_this._coordinates(pts);
return true;
}
}
return false;
};
/**
* Process the edit edge action for a general annotation.
*
* @param {geo.event} evt The action event.
* @returns {boolean|string} `true` to update the annotation, falsy to not
* update anything.
*/
this._processEditActionEdge = function (evt) {
var handle = m_this._editHandle,
index = handle.handle.index,
vindex = handle.handle.vindex,
curPts = m_this._coordinates();
if (!curPts.outer) {
curPts.splice(index + 1, 0, {x: handle.handle.x, y: handle.handle.y});
} else {
const loop = vindex ? curPts.inner[vindex - 1] : curPts.outer;
loop.splice(index + 1, 0, {x: handle.handle.x, y: handle.handle.y});
}
handle.handle.type = 'vertex';
handle.handle.index += 1;
handle.startCoordinates = m_this._copyOfCoordinates(curPts);
m_this.modified();
return true;
};
/**
* Process the edit vertex action for a general annotation.
*
* @param {geo.event} evt The action event.
* @param {boolean} [canClose] if True, this annotation has a closed style
* that indicates if the first and last vertices are joined. If falsy, is
* allowed to be changed to true.
* @returns {boolean|string} `true` to update the annotation, `false` to
* prevent closure, any other falsy to not update anything.
*/
this._processEditActionVertex = function (evt, canClose) {
var handle = m_this._editHandle,
index = handle.handle.index,
vindex = handle.handle.vindex,
start = handle.startCoordinates,
ptsRef = m_this._coordinates(),
curPts = ptsRef.outer ? (vindex ? ptsRef.inner[vindex - 1] : ptsRef.outer) : ptsRef,
origLen = curPts.length,
origPt = curPts[index],
delta = {
x: evt.mouse.mapgcs.x - evt.state.origin.mapgcs.x,
y: evt.mouse.mapgcs.y - evt.state.origin.mapgcs.y
},
layer = m_this.layer(),
aPP = layer.options('adjacentPointProximity'),
near, atEnd;
if (start.outer) {
start = vindex ? start.inner[vindex - 1] : start.outer;
}
curPts[index] = {
x: start[index].x + delta.x,
y: start[index].y + delta.y
};
if (layer.displayDistance(curPts[index], null, start[index], null) <= aPP) {
/* if we haven't moved at least aPP from where the vertex started, don't
* allow it to be merged into another vertex. This prevents small scale
* edits from collapsing immediately. */
} else if (layer.displayDistance(
curPts[index], null,
curPts[(index + 1) % curPts.length], null) <= aPP) {
near = (index + 1) % curPts.length;
} else if (layer.displayDistance(
curPts[index], null,
curPts[(index + curPts.length - 1) % curPts.length], null) <= aPP) {
near = (index + curPts.length - 1) % curPts.length;
}
atEnd = ((near === 0 && index === curPts.length - 1) ||
(near === curPts.length - 1 && index === 0));
if (canClose === false && atEnd) {
near = undefined;
}
if (near !== undefined && curPts.length > (canClose || m_this.options('style').closed ? 3 : 2)) {
curPts[index] = {x: curPts[near].x, y: curPts[near].y};
if (evt.event === geo_event.actionup) {
if (canClose && atEnd) {
m_this.options('style').closed = true;
}
curPts.splice(index, 1);
}
}
if (curPts.length === origLen &&
curPts[index].x === origPt.x && curPts[index].y === origPt.y) {
return false;
}
m_this._coordinates(ptsRef);
return true;
};
/**
* Transform the annotations coordinates from one gcs to another.
*
* @param {string|geo.transform} oldgcs The current gcs.
* @param {string|geo.transform} newgcs The new gcs.
* @param {geo.polygon} [coord] If not specified, convert the coordinates in
* place. If specified, convert these coordinates and return them (don't
* alter the existing values).
* @returns {geo.polygon}
*/
this._convertCoordinates = function (oldgcs, newgcs, coord) {
const store = !coord;
coord = coord || m_this._coordinates();
if (!coord.outer) {
coord = transform.transformCoordinates(oldgcs, newgcs, coord);
} else {
coord = {
outer: transform.transformCoordinates(oldgcs, newgcs, coord.outer),
inner: (coord.inner || []).map((h) => transform.transformCoordinates(oldgcs, newgcs, h))
};
}
if (store) {
m_this._coordinates(coord);
}
return coord;
};
};
/* Functions used by multiple annotations */
/**
* Return actions needed for the specified state of this annotation.
*
* @param {object} m_this The current annotation instance.
* @param {function} s_actions The parent actions method.
* @param {string|undefined} state The state to return actions for. Defaults
* to the current state.
* @param {string} name The name of this annotation.
* @param {Array} originalArgs arguments to original call
* @returns {geo.actionRecord[]} A list of actions.
*/
function continuousVerticesActions(m_this, s_actions, state, name, originalArgs) {
if (!state) {
state = m_this.state();
}
switch (state) {
case annotationState.create:
return [{
action: geo_action['annotation_' + name],
name: name + ' create',
owner: annotationActionOwner,
input: 'left'
}, {
action: geo_action['annotation_' + name],
name: name + ' create',
owner: annotationActionOwner,
input: 'pan'
}];
default:
return s_actions.apply(m_this, originalArgs);
}
}
/**
* Process actions to allow drawing continuous vertices for an annotation.
*
* @param {object} m_this The current annotation instance.
* @param {geo.event} evt The action event.
* @param {string} name The name of this annotation.
* @returns {boolean|string} `true` to update the annotation, `'done'` if the
* annotation was completed (changed from create to done state),
* `'remove'` if the annotation should be removed, falsy to not update
* anything.
*/
function continuousVerticesProcessAction(m_this, evt, name) {
var layer = m_this.layer();
if (m_this.state() !== annotationState.create || !layer ||
evt.state.action !== geo_action['annotation_' + name]) {
return;
}
var cpp = layer.options('continuousPointProximity');
var cpc = layer.options('continuousPointCollinearity');
var ccp = layer.options('continuousCloseProximity');
if (cpp || cpp === 0) {
var vertices = m_this.options('vertices');
var update = false;
if (!vertices.length) {
vertices.push(evt.mouse.mapgcs);
vertices.push(evt.mouse.mapgcs);
return true;
}
var dist = layer.displayDistance(vertices[vertices.length - 2], null, evt.mouse.map, 'display');
if (dist && dist > cpp) {
// combine nearly collinear points
if (vertices.length >= (m_this._lastClickVertexCount || 1) + 3) {
var d01 = layer.displayDistance(vertices[vertices.length - 3], null, vertices[vertices.length - 2], null),
d12 = dist,
d02 = layer.displayDistance(vertices[vertices.length - 3], null, evt.mouse.map, 'display');
if (d01 && d02) {
var costheta = (d02 * d02 - d01 * d01 - d12 * d12) / (2 * d01 * d12);
if (costheta > Math.cos(cpc)) {
vertices.pop();
}
}
}
vertices[vertices.length - 1] = evt.mouse.mapgcs;
vertices.push(evt.mouse.mapgcs);
update = true;
}
if ((ccp || ccp === 0) && evt.event === geo_event.actionup &&
(ccp === true || layer.displayDistance(vertices[0], null, evt.mouse.map, 'display') <= cpp)) {
if (vertices.length < 3 + (name === 'polygon' ? 1 : 0)) {
return 'remove';
}
vertices.pop();
m_this.state(annotationState.done);
return 'done';
}
return update;
}
}
/**
* Return a function that can be used as a selectionConstraint that requires
* that the aspect ratio of a rectangle-like selection is a specific value or
* range of values.
*
* @param {number|number[]|geo.geoSize|geo.geoSize[]} ratio Either a single
* aspect ratio, a single size, or a list of allowed aspect ratios and sizes.
* For instance, 1 will require that the selection square, 2 would require
* that it is twice as wide as tall, [2, 1/2] would allow it to be twice as
* wide or half as wide as it is tall. Sizes (e.g., {width: 400, height:
* 500}) snap to that size.
* @returns {function} A function that can be passed to the mapIterator
* selectionConstraint or to an annotation constraint function.
*/
function constrainAspectRatio(ratio) {
const ratios = Array.isArray(ratio) ? ratio : [ratio];
/**
* Constrain a mouse action or annotation action to a list of aspect ratios.
*
* @param {geo.geoPosition} pos Mouse or new location in map gcs.
* @param {geo.geoPosition} origin Origin in map gcs when the activity
* started.
* @param {geo.geoPosition} [corners] If specified, an array of corner
* locations in mapgcs. This may be modified.
* @param {string?} [mode] 'edge', 'vertex' or falsy. A falsy value implies
* this is just the most recent point in the annotation, otherwise it is
* the portion of the annotation being modified.
* @param {number} [ang] A list of angles of each side of the polygon
* represented by corners or the original annotation.
* @param {integer} [index] The specific vertex or edge that is being
* modified.
* @returns {object} An object with the ``origin`` (this is what is passed
* in), a new position as ``pos``, and the updated corners as ``corners``.
*/
function constraintFunction(pos, origin, corners, mode, ang, index) {
let newpos = pos;
let best;
if (!corners) {
corners = [
{x: origin.x, y: origin.y},
{x: pos.x, y: origin.y},
{x: pos.x, y: pos.y},
{x: origin.x, y: pos.y}
];
}
if (mode) {
/* Edit a vertex or edge */
const i1 = (index + 1) % 4;
const i2 = (index + 2) % 4;
const i3 = (index + 3) % 4;
const dist1 = ((corners[index].x - corners[i1].x) ** 2 + (corners[index].y - corners[i1].y) ** 2) ** 0.5;
const dist3 = ((corners[index].x - corners[i3].x) ** 2 + (corners[index].y - corners[i3].y) ** 2) ** 0.5;
const area = Math.abs(dist1 * dist3);
let shape, edge;
ratios.forEach((ratio) => {
let width, height;
if (ratio.width) {
width = ratio.width;
height = ratio.height;
} else {
width = (area * ratio) ** 0.5;
height = width / ratio;
}
if (width !== height && !(index % 2)) {
[width, height] = [height, width];
}
const score = (width - dist3) ** 2 + (height - dist1) ** 2;
if (best === undefined || score < best) {
best = score;
shape = {
w: width,
h: height
};
}
});
const ang1 = ang[i1];
const delta1 = {
x: -shape.w * Math.cos(ang1),
y: -shape.w * Math.sin(ang1)
};
const ang2 = ang[index];
const delta2 = {
x: -shape.h * Math.cos(ang2),
y: -shape.h * Math.sin(ang2)
};
switch (mode) {
case 'vertex':
corners[index].x = corners[i2].x + delta1.x + delta2.x;
corners[index].y = corners[i2].y + delta1.y + delta2.y;
corners[i1].x = corners[i2].x + delta1.x;
corners[i1].y = corners[i2].y + delta1.y;
corners[i3].x = corners[i2].x + delta2.x;
corners[i3].y = corners[i2].y + delta2.y;
break;
case 'edge':
edge = {
x: (corners[i2].x + corners[i3].x) * 0.5,
y: (corners[i2].y + corners[i3].y) * 0.5
};
corners[i2].x = edge.x + delta2.x / 2;
corners[i2].y = edge.y + delta2.y / 2;
corners[index].x = edge.x + delta1.x - delta2.x / 2;
corners[index].y = edge.y + delta1.y - delta2.y / 2;
corners[i1].x = edge.x + delta1.x + delta2.x / 2;
corners[i1].y = edge.y + delta1.y + delta2.y / 2;
corners[i3].x = edge.x - delta2.x / 2;
corners[i3].y = edge.y - delta2.y / 2;
break;
}
} else {
/* Not in edit vertex or edge mode */
const area = Math.abs((pos.x - origin.x) * (pos.y - origin.y));
ratios.forEach((ratio) => {
let width, height;
if (ratio.width) {
width = ratio.width;
height = ratio.height;
} else {
width = (area * ratio) ** 0.5;
height = width / ratio;
}
const adjusted = {
x: origin.x + Math.sign(pos.x - origin.x) * width,
y: origin.y + Math.sign(pos.y - origin.y) * height
};
const score = (adjusted.x - pos.x) ** 2 + (adjusted.y - pos.y) ** 2;
if (best === undefined || score < best) {
best = score;
newpos = adjusted;
}
});
corners[0].y = corners[1].y = origin.y;
corners[0].x = corners[3].x = origin.x;
corners[1].x = corners[2].x = newpos.x;
corners[2].y = corners[3].y = newpos.y;
}
return {
corners: corners,
origin: origin,
pos: newpos
};
}
return constraintFunction;
}
/**
* This object contains the default options to initialize the class.
*/
annotation.defaults = {
showLabel: true
};
module.exports = {
state: annotationState,
actionOwner: annotationActionOwner,
annotation: annotation,
_editHandleFeatureLevel: editHandleFeatureLevel,
defaultEditHandleStyle,
constrainAspectRatio,
// these aren't exposed in index.js
annotationActionOwner,
continuousVerticesActions,
continuousVerticesProcessAction
};