From d8882ed7b56dac56203c1250ffa20599eea86195 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 11 Jan 2018 10:26:47 -0500 Subject: [PATCH] Edit annotations. When enabled, hovering over an annotation will highlight it. Clicking on the annotation will place it in edit mode. Dragging the edit handles will alter the annotation. Escape or clicking outside of edit handles will exit from edit mode. This PR can stand on its own, but there are a number of future improvements that are desired: - escape should revert the edit rather than just exit edit mode (and enter should exit edit mode, keeping the edit) - tab should switch between annotations; when an annotation is selected via the enter key, tab would switch between edit handles. - edit handles should be adjustable with the arrow keys - meta keys (shift, ctrl) should affect how moving edit handles is controlled (for instance, shift might constrain a rectangle's aspect ratio, or ensure 90 degree rotations). I'm not sure of the specifics. - the edit handles should be fancier than just points. I'd rather see squares on the vertices, a curved double-headed arrow on the rotation handle, and a double-headed arrow on the resize handle. This would benefit from a more generic marker option for points. - we should test with a touch device. - for lines, the ability to break or join a line could be disabled. - There could be other constraints, too, like discrete rotations or scales. --- examples/annotations/index.pug | 8 +- examples/annotations/main.css | 12 +- examples/annotations/main.js | 15 +- src/action.js | 3 +- src/annotation.js | 720 ++++++++++++++++++++++++++++++--- src/annotationLayer.js | 191 ++++++++- src/feature.js | 10 + src/mapInteractor.js | 19 +- tests/cases/annotation.js | 531 +++++++++++++++++++++++- tests/cases/annotationLayer.js | 144 ++++++- tests/cases/feature.js | 16 + 11 files changed, 1566 insertions(+), 103 deletions(-) diff --git a/examples/annotations/index.pug b/examples/annotations/index.pug index 37fd96e4f9..cf4bccfcb1 100644 --- a/examples/annotations/index.pug +++ b/examples/annotations/index.pug @@ -16,8 +16,11 @@ block append mainContent .annotation.point Left click to create a point. .annotation.line Left-click points in the line. Double click, right click, or click the starting point to end the line. .form-group(title='If enabled, left-click to add another annotation, and right-click to switch annotation type. Otherwise, you must click a button above.') - label(for='clickadd') Click to add annotation - input#clickadd(param-name='clickadd', type='checkbox', placeholder='true', checked='checked') + label(for='clickmode') Click mode + select#clickmode(param-name='clickmode', placeholder='edit') + option(value='edit') Select and Edit + option(value='add') Add New + option(value='none') None .form-group(title='If enabled, immediately after adding one annotation, you can add another without either left-clicking or selecting a button.') label(for='keepadding') Keep adding annotations input#keepadding(param-name='keepadding', type='checkbox', placeholder='false') @@ -31,6 +34,7 @@ block append mainContent #annotationlist .entry#sample span.entry-name Sample + a.entry-adjust(action='adjust', title='Modify geometry') ✜ a.entry-edit(action='edit', title='Edit name and properties') ✎ a.entry-remove(action='remove', title='Delete this annotation') ✖ .form-group diff --git a/examples/annotations/main.css b/examples/annotations/main.css index c6ae219f44..4612b72725 100644 --- a/examples/annotations/main.css +++ b/examples/annotations/main.css @@ -66,16 +66,14 @@ display: inline-block; } .entry .entry-name { - width: 86%; + width: 82%; } -.entry .entry-edit { - width: 7%; +.entry a { + width: 6%; text-align: right; - font-weight: bold; } -.entry .entry-remove { - width: 7%; - text-align: right; +.entry .entry-edit { + font-weight: bold; } .entry-remove-all { float: right; diff --git a/examples/annotations/main.js b/examples/annotations/main.js index 91cd8ae9a2..2a137f2d4a 100644 --- a/examples/annotations/main.js +++ b/examples/annotations/main.js @@ -10,7 +10,7 @@ $(function () { // get the query parameters and set controls appropriately var query = utils.getQuery(); - $('#clickadd').prop('checked', query.clickadd !== 'false'); + $('#clickmode').val(query.clickmode || 'edit'); $('#keepadding').prop('checked', query.keepadding === 'true'); $('#showLabels').prop('checked', query.labels !== 'false'); if (query.lastannotation) { @@ -54,7 +54,8 @@ $(function () { layer = map.createLayer('annotation', { renderer: query.renderer ? (query.renderer === 'html' ? null : query.renderer) : undefined, annotations: query.renderer ? undefined : geo.listAnnotations(), - showLabels: query.labels !== 'false' + showLabels: query.labels !== 'false', + clickToEdit: !query.clickmode || query.clickmode === 'edit' }); // bind to the mouse click and annotation mode events layer.geoOn(geo.event.mouseclick, mouseClickToStart); @@ -92,7 +93,7 @@ $(function () { * @param {geo.event} evt geojs event. */ function mouseClickToStart(evt) { - if (evt.handled || query.clickadd === 'false') { + if (evt.handled || query.clickmode !== 'add') { return; } if (evt.buttonsDown.left) { @@ -129,6 +130,10 @@ $(function () { layer.options('showLabels', '' + value !== 'false'); layer.draw(); break; + case 'clickmode': + layer.options('clickToEdit', value === 'edit'); + layer.draw(); + break; } query[param] = value; if (value === '' || (ctl.attr('placeholder') && @@ -274,6 +279,10 @@ $(function () { id = ctl.closest('.entry').attr('annotation-id'), annotation = layer.annotationById(id); switch (action) { + case 'adjust': + layer.mode(layer.modes.edit, annotation); + layer.draw(); + break; case 'edit': show_edit_dialog(id, annotation); break; diff --git a/src/action.js b/src/action.js index a5802175d1..6652b95ad5 100644 --- a/src/action.js +++ b/src/action.js @@ -15,7 +15,8 @@ var geo_action = { // annotation actions annotation_line: 'geo_annotation_line', annotation_polygon: 'geo_annotation_polygon', - annotation_rectangle: 'geo_annotation_rectangle' + annotation_rectangle: 'geo_annotation_rectangle', + annotation_edit_handle: 'geo_annotation_edit_handle' }; module.exports = geo_action; diff --git a/src/annotation.js b/src/annotation.js index f81e6a79f9..9dbe7d9025 100644 --- a/src/annotation.js +++ b/src/annotation.js @@ -15,11 +15,45 @@ var annotationId = 0; var annotationState = { create: 'create', done: 'done', + highlight: 'highlight', edit: 'edit' }; var annotationActionOwner = 'annotationAction'; +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; + /** * Base annotation class. * @@ -141,6 +175,36 @@ var annotation = function (type, args) { 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(), + coor = 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; + for (i = 0; i < coor.length; i += 1) { + pos = map.gcsToDisplay(coor[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 `geo.textFeature`. @@ -158,7 +222,7 @@ var annotation = function (type, args) { (show !== true && show.indexOf(state) < 0)) { return; } - var style = m_this.options('labelStyle'); + var style = m_this.labelStyle(); var labelRecord = { text: m_this.label(), position: m_this._labelPosition() @@ -235,12 +299,30 @@ var annotation = function (type, args) { * the current state. * @returns {geo.actionRecord[]} A list of actions. */ - this.actions = function () { - return []; + 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' + }]; + default: + return []; + } }; /** - * Process any actions for this annotation. + * 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 @@ -252,6 +334,61 @@ var annotation = function (type, args) { 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); + } + }; + + /** + * 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} + */ + 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._coordinates().slice(), + 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) + }; + return m_this; + }; + /** * Set or get options. * @@ -274,7 +411,9 @@ var annotation = function (type, args) { m_options = $.extend(true, m_options, arg1); /* For style objects, re-extend them without recursion. This allows * setting colors without an opacity field, for instance. */ - ['style', 'editStyle', 'labelStyle'].forEach(function (key) { + ['style', 'createStyle', 'editStyle', 'editHandleStyle', 'labelStyle', + 'highlightStyle' + ].forEach(function (key) { if (arg1[key] !== undefined) { $.extend(m_options[key], arg1[key]); } @@ -315,51 +454,70 @@ var annotation = function (type, args) { * 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`, or + * `highlightStyle`. * @returns {object|this} Either the entire style object, the value of a * specific style, or the current class instance. */ - this.style = function (arg1, arg2) { + this.style = function (arg1, arg2, styleType) { + styleType = styleType || 'style'; if (arg1 === undefined) { - return m_options.style; + return m_options[styleType]; } if (typeof arg1 === 'string' && arg2 === undefined) { - return m_options.style[arg1]; + return (m_options[styleType] || {})[arg1]; + } + if (m_options[styleType] === undefined) { + m_options[styleType] = {}; } if (arg2 === undefined) { - m_options.style = $.extend(true, m_options.style, arg1); + m_options[styleType] = $.extend(true, m_options[styleType], arg1); } else { - m_options.style[arg1] = arg2; + m_options[styleType][arg1] = arg2; } m_this.modified(); return m_this; }; + ['createStyle', 'editStyle', 'editHandleStyle', 'labelStyle', 'highlightStyle' + ].forEach(function (styleType) { + /** + * Set or get a specific style type. + * + * @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. + * @returns {object|this} Either the entire style object, the value of a + * specific style, or the current class instance. + */ + m_this[styleType] = function (arg1, arg2) { + return m_this.style(arg1, arg2, styleType); + }; + }); + /** - * Set or get edit style. These are the styles used in edit and create mode. - * - * @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. - * @returns {object|this} Either the entire style object, the value of a - * specific style, or the current class instance. + * 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.editStyle = function (arg1, arg2) { - if (arg1 === undefined) { - return m_options.editStyle; - } - if (typeof arg1 === 'string' && arg2 === undefined) { - return m_options.editStyle[arg1]; - } - if (arg2 === undefined) { - m_options.editStyle = $.extend(true, m_options.editStyle, arg1); - } else { - m_options.editStyle[arg1] = arg2; - } - m_this.modified(); - return m_this; + 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 $.extend({}, m_options.style, m_options[state + 'Style']); + } + if (state === annotationState.create) { + return $.extend({}, m_options.style, m_options.editStyle, + m_options[state + 'Style']); + } + return m_options[state + 'Style'] || m_options.style || {}; }; /** @@ -398,6 +556,21 @@ var annotation = function (type, args) { 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. * @@ -410,8 +583,8 @@ var annotation = function (type, args) { }; /** - * Get coordinates associated with this annotation in the map gcs coordinate - * system. + * 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. @@ -515,7 +688,7 @@ var annotation = function (type, args) { geotype = m_this._geojsonGeometryType(), styles = m_this._geojsonStyles(), objStyle = m_this.options('style') || {}, - objLabelStyle = m_this.options('labelStyle') || {}, + objLabelStyle = m_this.labelStyle() || {}, i, key, value; if (!coor || !coor.length || !geotype) { return; @@ -575,6 +748,260 @@ var annotation = function (type, args) { } 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 = $.extend({}, 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 = $.extend({}, opts); + Object.keys(handles).forEach(function (key) { + if (handles[key] === false) { + opts[key] = false; + } + }); + if (!features[editHandleFeatureLevel]) { + features[editHandleFeatureLevel] = {point: []}; + } + editPoints = features[editHandleFeatureLevel].point; + vertices.forEach(function (pt, idx) { + if (opts.vertex !== false) { + editPoints.push($.extend({}, pt, {type: 'vertex', index: idx, style: style, editHandle: true})); + } + if (opts.edge !== false && idx !== vertices.length - 1 && (pt.x !== vertices[idx + 1].x || pt.y !== vertices[idx + 1].y)) { + editPoints.push($.extend({ + x: (pt.x + vertices[idx + 1].x) / 2, + y: (pt.y + vertices[idx + 1].y) / 2 + }, {type: 'edge', index: idx, style: style, editHandle: true})); + } + if (opts.edge !== false && !isOpen && idx === vertices.length - 1 && (pt.x !== vertices[0].x || pt.y !== vertices[0].y)) { + editPoints.push($.extend({ + x: (pt.x + vertices[0].x) / 2, + y: (pt.y + vertices[0].y) / 2 + }, {type: 'edge', index: idx, style: style, editHandle: true})); + } + }); + if (opts.center !== false) { + editPoints.push($.extend({}, util.centerFromPerimeter(m_this._coordinates()), {type: 'center', style: style, editHandle: true})); + } + if (opts.rotate !== false) { + editPoints.push($.extend(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($.extend(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.selected = true; + } + }); + } + }; + + /** + * 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 = start.map(function (elem) { + return {x: elem.x + delta.x, y: elem.y + delta.y}; + }); + if (pts[0].x !== curPts[0].x || pts[0].y !== curPts[0].y) { + 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 = start.map(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 (pts[0].x !== curPts[0].x || pts[0].y !== curPts[0].y) { + 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 = start.map(function (elem) { + return { + x: (elem.x - handle.center.x) * scale + handle.center.x, + y: (elem.y - handle.center.y) * scale + handle.center.y + }; + }); + if (pts[0].x !== curPts[0].x || pts[0].y !== curPts[0].y) { + 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, + curPts = m_this._coordinates(); + curPts.splice(index + 1, 0, {x: handle.handle.x, y: handle.handle.y}); + handle.handle.type = 'vertex'; + handle.handle.index += 1; + handle.startCoordinates = curPts.slice(); + 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, + start = handle.startCoordinates, + curPts = m_this._coordinates(), + 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; + + 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(curPts); + return true; + }; }; /** @@ -628,16 +1055,15 @@ var rectangleAnnotation = function (args) { strokeWidth: 3, uniformPolygon: true }, - editStyle: { - fill: true, + highlightStyle: { + fillColor: {r: 0, g: 1, b: 1}, + fillOpacity: 0.5, + strokeWidth: 5 + }, + createStyle: { fillColor: {r: 0.3, g: 0.3, b: 0.3}, fillOpacity: 0.25, - polygon: function (d) { return d.polygon; }, - stroke: true, - strokeColor: {r: 0, g: 0, b: 1}, - strokeOpacity: 1, - strokeWidth: 3, - uniformPolygon: true + strokeColor: {r: 0, g: 0, b: 1} } }, args || {}); args.corners = args.corners || args.coordinates || []; @@ -645,7 +1071,8 @@ var rectangleAnnotation = function (args) { annotation.call(this, 'rectangle', args); var m_this = this, - s_actions = this.actions; + s_actions = this.actions, + s_processEditAction = this.processEditAction; /** * Return actions needed for the specified state of this annotation. @@ -723,7 +1150,7 @@ var rectangleAnnotation = function (args) { features = [{ polygon: { polygon: opt.corners, - style: opt.editStyle + style: m_this.styleForState(state) } }]; } @@ -732,9 +1159,12 @@ var rectangleAnnotation = function (args) { features = [{ polygon: { polygon: opt.corners, - style: opt.style + style: m_this.styleForState(state) } }]; + if (state === annotationState.edit) { + m_this._addEditHandles(features, opt.corners); + } break; } return features; @@ -876,6 +1306,84 @@ var rectangleAnnotation = function (args) { } }; + /** + * Process any edit actions for this annotation. + * + * @param {geo.event} evt The action event. + * @returns {boolean|string} `true` to update the annotation, falsy to not + * update anything. + */ + this.processEditAction = 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 + }, + type = m_this._editHandle.handle.type, + index = m_this._editHandle.handle.index, + ang = [ + Math.atan2(start[1].y - start[0].y, start[1].x - start[0].x), + Math.atan2(start[2].y - start[1].y, start[2].x - start[1].x), + Math.atan2(start[3].y - start[2].y, start[3].x - start[2].x), + Math.atan2(start[0].y - start[3].y, start[0].x - start[3].x) + ], + corners, delta1, delta2, ang1, ang2; + // an angle can be zero because it is horizontal or undefined. If opposite + // angles are both zero, this is a degenerate rectangle (a line or a point) + if (!ang[0] && !ang[1] && !ang[2] && !ang[3]) { + ang[1] = Math.PI / 2; + ang[2] = Math.PI; + ang[3] = -Math.PI / 2; + } + if (!ang[0] && !ang[2]) { + ang[0] = ang[1] - Math.PI / 2; + ang[2] = ang[1] + Math.PI / 2; + } + if (!ang[1] && !ang[3]) { + ang[1] = ang[2] - Math.PI / 2; + ang[3] = ang[2] + Math.PI / 2; + } + switch (type) { + case 'vertex': + corners = start.map(function (elem) { + return {x: elem.x, y: elem.y}; + }); + ang1 = ang[(index + 1) % 4]; + delta1 = { + x: (delta.x * Math.cos(ang1) + delta.y * Math.sin(ang1)) * Math.cos(ang1), + y: (delta.y * Math.sin(ang1) + delta.x * Math.cos(ang1)) * Math.sin(ang1) + }; + ang2 = ang[index]; + delta2 = { + x: (delta.x * Math.cos(ang2) + delta.y * Math.sin(ang2)) * Math.cos(ang2), + y: (delta.y * Math.sin(ang2) + delta.x * Math.cos(ang2)) * Math.sin(ang2) + }; + corners[index].x += delta.x; + corners[index].y += delta.y; + corners[(index + 1) % 4].x += delta1.x; + corners[(index + 1) % 4].y += delta1.y; + corners[(index + 3) % 4].x += delta2.x; + corners[(index + 3) % 4].y += delta2.y; + m_this.options('corners', corners); + return true; + case 'edge': + corners = start.map(function (elem) { + return {x: elem.x, y: elem.y}; + }); + ang1 = ang[(index + 1) % 4]; + delta = { + x: (delta.x * Math.cos(ang1) + delta.y * Math.sin(ang1)) * Math.cos(ang1), + y: (delta.y * Math.sin(ang1) + delta.x * Math.cos(ang1)) * Math.sin(ang1) + }; + corners[index].x += delta.x; + corners[index].y += delta.y; + corners[(index + 1) % 4].x += delta.x; + corners[(index + 1) % 4].y += delta.y; + m_this.options('corners', corners); + return true; + } + return s_processEditAction.apply(m_this, arguments); + }; }; inherit(rectangleAnnotation, annotation); @@ -933,9 +1441,13 @@ var polygonAnnotation = function (args) { strokeWidth: 3, uniformPolygon: true }, - editStyle: { + highlightStyle: { + fillColor: {r: 0, g: 1, b: 1}, + fillOpacity: 0.5, + strokeWidth: 5 + }, + createStyle: { closed: false, - fill: true, fillColor: {r: 0.3, g: 0.3, b: 0.3}, fillOpacity: 0.25, line: function (d) { @@ -944,15 +1456,11 @@ var polygonAnnotation = function (args) { return Array.apply(null, Array(m_this.options('vertices').length)).map( function () { return d; }); }, - polygon: function (d) { return d.polygon; }, position: function (d, i) { return m_this.options('vertices')[i]; }, stroke: false, - strokeColor: {r: 0, g: 0, b: 1}, - strokeOpacity: 1, - strokeWidth: 3, - uniformPolygon: true + strokeColor: {r: 0, g: 0, b: 1} } }, args || {}); args.vertices = args.vertices || args.coordinates || []; @@ -971,6 +1479,7 @@ var polygonAnnotation = function (args) { this.features = function () { var opt = m_this.options(), state = m_this.state(), + style = m_this.styleForState(state), features; switch (state) { case annotationState.create: @@ -979,7 +1488,7 @@ var polygonAnnotation = function (args) { features[1] = { polygon: { polygon: opt.vertices, - style: opt.editStyle + style: style } }; } @@ -987,7 +1496,7 @@ var polygonAnnotation = function (args) { features[2] = { line: { line: opt.vertices, - style: opt.editStyle + style: style } }; } @@ -996,9 +1505,12 @@ var polygonAnnotation = function (args) { features = [{ polygon: { polygon: opt.vertices, - style: opt.style + style: style } }]; + if (state === annotationState.edit) { + m_this._addEditHandles(features, opt.vertices); + } break; } return features; @@ -1197,7 +1709,10 @@ var lineAnnotation = function (args) { lineCap: 'butt', lineJoin: 'miter' }, - editStyle: { + highlightStyle: { + strokeWidth: 5 + }, + createStyle: { line: function (d) { /* Return an array that has the same number of items as we have * vertices. */ @@ -1220,7 +1735,8 @@ var lineAnnotation = function (args) { annotation.call(this, 'line', args); var m_this = this, - s_actions = this.actions; + s_actions = this.actions, + s_processEditAction = this.processEditAction; /** * Get a list of renderable features for this annotation. @@ -1236,7 +1752,7 @@ var lineAnnotation = function (args) { features = [{ line: { line: opt.vertices, - style: opt.editStyle + style: m_this.styleForState(state) } }]; break; @@ -1244,9 +1760,12 @@ var lineAnnotation = function (args) { features = [{ line: { line: opt.vertices, - style: opt.style + style: m_this.styleForState(state) } }]; + if (state === annotationState.edit) { + m_this._addEditHandles(features, opt.vertices, undefined, !m_this.style('closed')); + } break; } return features; @@ -1462,6 +1981,63 @@ var lineAnnotation = function (args) { 'closed', 'lineCap', 'lineJoin', 'strokeColor', 'strokeOffset', 'strokeOpacity', 'strokeWidth']; }; + + /** + * Process any edit actions for this annotation. + * + * @param {geo.event} evt The action event. + * @returns {boolean|string} `true` to update the annotation, falsy to not + * update anything. + */ + this.processEditAction = function (evt) { + switch (m_this._editHandle.handle.type) { + case 'vertex': + return m_this._processEditActionVertex(evt, true); + } + return s_processEditAction.apply(m_this, arguments); + }; + + /** + * 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) { + // if we get a left double click on an edge on a closed line, break the + // line at that edge + var layer = m_this.layer(), + handle = m_this._editHandle, + split; + // ensure we are in edit mode and this is a left click + if (m_this.state() !== annotationState.edit || !layer || !evt.buttonsDown.left) { + return; + } + // ensure this is an edge on a closed line + if (!handle || !handle.handle.selected || handle.handle.type !== 'edge' || !m_this.options('style').closed) { + return; + } + evt.handled = true; + if (m_this._lastClick && evt.time - m_this._lastClick < layer.options('dblClickTime')) { + split = true; + } + m_this._lastClick = evt.time; + if (split) { + var index = handle.handle.index, + curPts = m_this._coordinates(), + pts = curPts.slice(index + 1).concat(curPts.slice(0, index + 1)); + m_this._coordinates(pts); + m_this.options('style').closed = false; + handle.handle.index = undefined; + return true; + } + }; + }; inherit(lineAnnotation, annotation); @@ -1515,6 +2091,16 @@ var pointAnnotation = function (args) { strokeColor: {r: 0, g: 0, b: 0}, strokeOpacity: 1, strokeWidth: 3 + }, + createStyle: { + fillColor: {r: 0.3, g: 0.3, b: 0.3}, + fillOpacity: 0.25, + strokeColor: {r: 0, g: 0, b: 1} + }, + highlightStyle: { + fillColor: {r: 0, g: 1, b: 1}, + fillOpacity: 0.5, + strokeWidth: 5 } }, args || {}); args.position = args.position || (args.coordinates ? args.coordinates[0] : undefined); @@ -1537,7 +2123,7 @@ var pointAnnotation = function (args) { features = []; break; default: - style = opt.style; + style = m_this.styleForState(state); if (opt.style.scaled || opt.style.scaled === 0) { if (opt.style.scaled === true) { opt.style.scaled = m_this.layer().map().zoom(); @@ -1563,6 +2149,11 @@ var pointAnnotation = function (args) { scaleOnZoom: scaleOnZoom } }]; + if (state === annotationState.edit) { + m_this._addEditHandles( + features, [opt.position], + {edge: false, center: false, resize: false, rotate: false}); + } break; } return features; @@ -1659,5 +2250,6 @@ module.exports = { lineAnnotation: lineAnnotation, pointAnnotation: pointAnnotation, polygonAnnotation: polygonAnnotation, - rectangleAnnotation: rectangleAnnotation + rectangleAnnotation: rectangleAnnotation, + _editHandleFeatureLevel: editHandleFeatureLevel }; diff --git a/src/annotationLayer.js b/src/annotationLayer.js index d1d4672799..8c4a9b80bb 100644 --- a/src/annotationLayer.js +++ b/src/annotationLayer.js @@ -19,7 +19,8 @@ var textFeature = require('./textFeature'); /** * Layer to handle direct interactions with different features. Annotations * (features) can be created by calling mode() or cancelled - * with mode(null). + * with mode(null). There is also an "edit" mode which is used when modifying + * an annotation. * * @class * @alias geo.annotationLayer @@ -44,6 +45,8 @@ var textFeature = require('./textFeature'); * start point. * @param {boolean} [args.showLabels=true] Truthy to show feature labels that * are allowed by the associated feature to be shown. + * @param {boolean} [args.clickToEdit=false] Truthy to allow clicking an + * annotation to place it in edit mode. * @param {object} [args.defaultLabelStyle] Default styles for labels. * @returns {geo.annotationLayer} */ @@ -113,7 +116,8 @@ var annotationLayer = function (args) { // being coliner continuousPointColinearity: 1.0 * Math.PI / 180, finalPointProximity: 10, // in pixels, 0 is exact - showLabels: true + showLabels: true, + clickToEdit: false }, args); /** @@ -127,7 +131,14 @@ var annotationLayer = function (args) { if (evt.state && evt.state.actionRecord && evt.state.actionRecord.owner === geo_annotation.actionOwner && m_this.currentAnnotation) { - update = m_this.currentAnnotation.processAction(evt); + switch (m_this.mode()) { + case m_this.modes.edit: + update = m_this.currentAnnotation.processEditAction(evt); + break; + default: + update = m_this.currentAnnotation.processAction(evt); + break; + } } m_this._updateFromEvent(update); }; @@ -172,6 +183,78 @@ var annotationLayer = function (args) { } }; + /** + * Select or deselect an edit handle. + * + * @param {geo.event} evt The mouse move event. + * @param {boolean} enable Truthy to select the handle, falsy to deselect it. + * @returns {this} + */ + this._selectEditHandle = function (evt, enable) { + if (!evt.data || !evt.data.editHandle) { + return; + } + $.each(m_features[geo_annotation._editHandleFeatureLevel], function (type, feature) { + feature.feature.modified(); + }); + m_this.currentAnnotation.selectEditHandle(evt.data, enable); + m_this.draw(); + m_this.map().node().toggleClass('annotation-input', !!enable); + m_this.map().interactor().removeAction( + undefined, undefined, geo_annotation.actionOwner); + if (enable) { + var actions = m_this.currentAnnotation.actions(geo_annotation.state.edit); + $.each(actions, function (idx, action) { + m_this.map().interactor().addAction(action); + }); + } + return m_this; + }; + + /** + * Handle mouse on events. If there is no current annotation and + * clickToEdit is enabled, any hovered annotation is highlighted. + * event is sent to it. + * + * @param {geo.event} evt The mouse move event. + */ + this._handleMouseOn = function (evt) { + if (!evt.data || !evt.data.annotation) { + return; + } + if (m_this.mode() === m_this.modes.edit && m_this.currentAnnotation) { + m_this._selectEditHandle(evt, true); + return; + } + if (m_this.mode() || m_this.currentAnnotation || !m_this.options('clickToEdit')) { + return; + } + evt.data.annotation.state(geo_annotation.state.highlight); + m_this.modified(); + m_this.draw(); + }; + + /** + * Handle mouse off events. If the specific annotation is in the highlight + * state, move it back to the done state. + * + * @param {geo.event} evt The mouse move event. + */ + this._handleMouseOff = function (evt) { + if (!evt.data || !evt.data.annotation) { + return; + } + if (m_this.mode() === m_this.modes.edit && evt.data.editHandle && evt.data.selected) { + m_this._selectEditHandle(evt, false); + return; + } + if (evt.data.annotation.state() === geo_annotation.state.highlight) { + evt.data.annotation.state(geo_annotation.state.done); + m_this.modified(); + m_this.draw(); + } + }; + /** * Handle mouse clicks. If there is a current annotation, the click event is * sent to it. @@ -179,9 +262,39 @@ var annotationLayer = function (args) { * @param {geo.event} evt The mouse click event. */ this._handleMouseClick = function (evt) { - if (m_this.mode() && m_this.currentAnnotation) { - var update = m_this.currentAnnotation.mouseClick(evt); + var retrigger = false, update; + if (m_this.mode() === m_this.modes.edit) { + if (m_this.map().interactor().hasAction(undefined, undefined, geo_annotation.actionOwner)) { + update = m_this.currentAnnotation.mouseClickEdit(evt); + m_this._updateFromEvent(update); + return; + } + m_this.mode(null); + m_this.draw(); + $.each(m_features, function (idx, featureLevel) { + $.each(featureLevel, function (type, feature) { + feature.feature._clearSelectedFeatures(); + }); + }); + retrigger = true; + } else if (m_this.mode() && m_this.currentAnnotation) { + update = m_this.currentAnnotation.mouseClick(evt); m_this._updateFromEvent(update); + retrigger = !m_this.mode(); + } else if (!m_this.mode() && !m_this.currentAnnotation && m_this.options('clickToEdit')) { + var highlighted = m_this.annotations().filter(function (ann) { + return ann.state() === geo_annotation.state.highlight; + }); + if (highlighted.length !== 1) { + return; + } + m_this.mode(m_this.modes.edit, highlighted[0]); + m_this.draw(); + retrigger = true; + } + if (retrigger) { + // retrigger mouse move to ensure the correct events are attached + m_this.map().interactor().retriggerMouseMove(); } }; @@ -356,22 +469,31 @@ var annotationLayer = function (args) { } }; + /* A list of special modes */ + this.modes = { + edit: 'edit' + }; + /** * Get or set the current mode. * * @param {string|null} [arg] `undefined` to get the current mode, `null` to - * stop creating/editing, or the name of the type of annotation to create. + * stop creating/editing, `this.modes.edit` (`'edit'`) plus an annotation + * to switch to edit mode, or the name of the type of annotation to + * create. + * @param {geo.annotation} [editAnnotation] If `arg === this.modes.edit`, + * this is the annotation that should be edited. * @returns {string|null|this} The current mode or the layer. */ - this.mode = function (arg) { + this.mode = function (arg, editAnnotation) { if (arg === undefined) { return m_mode; } - if (arg !== m_mode) { + if (arg !== m_mode || (arg === m_this.modes.edit && editAnnotation !== m_this.editAnnotation)) { var createAnnotation, actions, mapNode = m_this.map().node(), oldMode = m_mode; m_mode = arg; - mapNode.toggleClass('annotation-input', !!m_mode); + mapNode.toggleClass('annotation-input', !!(m_mode && m_mode !== m_this.modes.edit)); if (m_mode) { Mousetrap(mapNode[0]).bind('esc', function () { m_this.mode(null); }); } else { @@ -382,10 +504,20 @@ var annotationLayer = function (args) { case geo_annotation.state.create: m_this.removeAnnotation(m_this.currentAnnotation); break; + case geo_annotation.state.edit: + m_this.currentAnnotation.state(geo_annotation.state.done); + m_this.modified(); + m_this.draw(); + break; } m_this.currentAnnotation = null; } switch (m_mode) { + case m_this.modes.edit: + m_this.currentAnnotation = editAnnotation; + m_this.currentAnnotation.state(geo_annotation.state.edit); + m_this.modified(); + break; case 'line': createAnnotation = geo_annotation.lineAnnotation; break; @@ -414,6 +546,9 @@ var annotationLayer = function (args) { } m_this.geoTrigger(geo_event.annotation.mode, { mode: m_mode, oldMode: oldMode}); + if (oldMode === m_this.modes.edit) { + m_this.modified(); + } } return m_this; }; @@ -735,7 +870,8 @@ var annotationLayer = function (args) { */ this._update = function () { if (m_this.getMTime() > m_buildTime.getMTime()) { - var labels = m_this.options('showLabels') ? [] : null; + var labels = m_this.options('showLabels') ? [] : null, + editable = m_this.options('clickToEdit') || m_this.mode() === m_this.modes.edit; /* Interally, we have a set of feature levels (to provide z-index * support), each of which can have data from multiple annotations. We * clear the data on each of these features, then build it up from each @@ -764,7 +900,8 @@ var annotationLayer = function (args) { /* Create features as needed */ if (!m_features[idx][type]) { var feature = m_this.createFeature(type, { - gcs: m_this.map().gcs() + gcs: m_this.map().gcs(), + selectionAPI: editable }); if (!feature) { /* We can't create the desired feature, porbably because of the @@ -777,6 +914,11 @@ var annotationLayer = function (args) { } return; } + if (editable) { + feature.geoOn(geo_event.feature.mouseon, m_this._handleMouseOn); + feature.geoOn(geo_event.feature.mouseoff, m_this._handleMouseOff); + } + /* Since each annotation can have separate styles, the styles are * combined together with a meta-style function. Any style that * could be used should be in this list. Color styles may be @@ -814,12 +956,32 @@ var annotationLayer = function (args) { style: style, data: [] }; + } else { + feature = m_features[idx][type].feature; + // update whether we check for selections on existing features + if (feature.selectionAPI() !== !!editable) { + feature.selectionAPI(editable); + if (editable) { + feature.geoOn(geo_event.feature.mouseon, m_this._handleMouseOn); + feature.geoOn(geo_event.feature.mouseoff, m_this._handleMouseOff); + } else { + feature.geoOff(geo_event.feature.mouseon, m_this._handleMouseOn); + feature.geoOff(geo_event.feature.mouseoff, m_this._handleMouseOff); + } + } } /* Collect the data for each feature */ - m_features[idx][type].data.push(featureSpec.data || featureSpec); - if (featureSpec.scaleOnZoom) { - m_features[idx][type].feature.scaleOnZoom = true; + var dataEntry = featureSpec.data || featureSpec; + if (!Array.isArray(dataEntry)) { + dataEntry = [dataEntry]; } + dataEntry.forEach(function (dataElement) { + dataElement.annotation = annotation; + m_features[idx][type].data.push(dataElement); + if (featureSpec.scaleOnZoom) { + m_features[idx][type].feature.scaleOnZoom = true; + } + }); }); }); }); @@ -941,6 +1103,7 @@ var annotationLayer = function (args) { } m_this.geoOn(geo_event.actionselection, m_this._processAction); m_this.geoOn(geo_event.actionmove, m_this._processAction); + m_this.geoOn(geo_event.actionup, m_this._processAction); m_this.geoOn(geo_event.mouseclick, m_this._handleMouseClick); m_this.geoOn(geo_event.mousemove, m_this._handleMouseMove); diff --git a/src/feature.js b/src/feature.js index 5e55d45e88..44ec938f06 100644 --- a/src/feature.js +++ b/src/feature.js @@ -287,6 +287,16 @@ var feature = function (arg) { } }; + /** + * Clear our tracked selected features. + * + * @returns {this} + */ + this._clearSelectedFeatures = function () { + m_selectedFeatures = []; + return m_this; + }; + /** * Private mouseclick handler. This uses `pointSearch` to determine which * features the mouse is over, then fires a click event for each such diff --git a/src/mapInteractor.js b/src/mapInteractor.js index 0f3da24429..17ab26fa66 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -359,14 +359,10 @@ var mapInteractor = function (args) { // default mouse object m_mouse = { - page: { // mouse position relative to the page - x: 0, - y: 0 - }, - map: { // mouse position relative to the map - x: 0, - y: 0 - }, + page: {x: 0, y: 0}, // mouse position relative to the page + map: {x: 0, y: 0}, // mouse position relative to the map + geo: {x: 0, y: 0}, // mouse position in map interface gcs + mapgcs: {x: 0, y: 0}, // mouse position in map gcs // mouse button status buttons: { left: false, @@ -644,6 +640,13 @@ var mapInteractor = function (args) { } }; + /** + * Retrigger a mouse movement with the current mouse state. + */ + this.retriggerMouseMove = function () { + m_this.map().geoTrigger(geo_event.mousemove, m_this.mouse()); + }; + /** * Connects events to a map. If the map is not set, then this does nothing. * @returns {this} diff --git a/tests/cases/annotation.js b/tests/cases/annotation.js index edb86ddcea..07896f31cf 100644 --- a/tests/cases/annotation.js +++ b/tests/cases/annotation.js @@ -19,6 +19,47 @@ describe('geo.annotation', function () { restoreVGLRenderer(); }); + /** + * Create an annotation with a set of points based on screen coordinates. + * The returned annotation has a property _handles which references the + * generated edit handles. + * + * @param {geo.map} map The map to use. + * @param {geo.annotationLayer} layer The annotation layer. + * @param {geo.screenPosition[]} pts The vertices of the annotation. + * @returns {geo.annotation} + */ + function createEditableAnnotation(map, layer, pts) { + var ann = geo.annotation.annotation('test', {layer: layer, style: {}}), + features = []; + ann._coordinates = function (coor) { + return ann.options.call(ann, 'vertices', coor); + }; + ann._coordinates(map.displayToGcs(pts, null)); + ann._addEditHandles(features, ann._coordinates()); + ann._handles = features[geo.annotation._editHandleFeatureLevel].point; + return ann; + } + + /** + * Create an event that can be passed to edit handle functions with mouse + * movement. This translates screen coordinates to appropriate mapgcs + * values. + * + * @param {geo.map} map The map to use. + * @param {geo.screenPosition} start Where the mouse started its drag. + * @param {geo.screenPosition} end Where the mouse is currently located. + * @returns {object} an object that can be used as a edit handle event. + */ + function editHandleEvent(map, start, end) { + return { + mouse: {mapgcs: map.displayToGcs(end, null)}, + state: {origin: {mapgcs: map.displayToGcs(start, null)}}, + buttonsDown: {}, + time: new Date().getTime() + }; + } + describe('geo.annotation.annotation', function () { var map, layer, stateEvent = 0, lastStateEvent; it('create', function () { @@ -36,8 +77,11 @@ describe('geo.annotation', function () { expect(ann.features()).toEqual([]); expect(ann.coordinates()).toEqual([]); expect(ann.actions()).toEqual([]); + expect(ann.actions(geo.annotation.state.edit).length).toEqual(2); expect(ann.processAction()).toBe(undefined); + expect(ann.processEditAction()).toBe(undefined); expect(ann.mouseClick()).toBe(undefined); + expect(ann.mouseClickEdit()).toBe(undefined); expect(ann.mouseMove()).toBe(undefined); expect(ann._coordinates()).toEqual([]); expect(ann.geojson()).toBe(undefined); @@ -261,6 +305,35 @@ describe('geo.annotation', function () { expect(pos.x).toBeCloseTo(4.447); expect(pos.y).toBeCloseTo(6.539); }); + it('_rotateHandlePosition', function () { + var pos; + var ann = geo.annotation.annotation('test', {layer: layer}); + expect(ann._rotateHandlePosition()).toBe(undefined); + ann._coordinates = function () { + return [{x: 1, y: 2}]; + }; + pos = ann._rotateHandlePosition(); + expect(pos.x).toBeCloseTo(1); + expect(pos.y).toBeCloseTo(2); + pos = ann._rotateHandlePosition(10); + expect(pos.x).toBeCloseTo(97840, 0); + expect(pos.y).toBeCloseTo(2); + pos = ann._rotateHandlePosition(10, -Math.PI / 4); + expect(pos.x).toBeCloseTo(69184, 0); + expect(pos.y).toBeCloseTo(-69181, 0); + ann._coordinates = function () { + return [{x: 1, y: 2}, {x: 3, y: 5}, {x: 8, y: 11}]; + }; + pos = ann._rotateHandlePosition(); + expect(pos.x).toBeCloseTo(10.15, 2); + expect(pos.y).toBeCloseTo(6.54, 2); + pos = ann._rotateHandlePosition(10); + expect(pos.x).toBeCloseTo(97850, 0); + expect(pos.y).toBeCloseTo(6.54, 2); + pos = ann._rotateHandlePosition(10, -Math.PI / 4); + expect(pos.x).toBeCloseTo(69191, 0); + expect(pos.y).toBeCloseTo(-69180, 0); + }); it('labelRecord', function () { var ann = geo.annotation.annotation('test', { layer: layer, @@ -280,6 +353,244 @@ describe('geo.annotation', function () { ann.options('showLabel', false); expect(ann.labelRecord()).toBe(undefined); }); + it('styleForState', function () { + var testStyles = { + style: {strokeWidth: 1}, + createStyle: {strokeWidth: 2, fill: false}, + editStyle: {strokeWidth: 3, strokeOpacity: 0.5}, + highlightStyle: {strokeWidth: 4, fillOpacity: 0.5} + }; + var ann = geo.annotation.annotation('test', testStyles); + expect(ann.styleForState()).toEqual(testStyles.style); + expect(ann.styleForState()).toEqual(ann.styleForState(geo.annotation.state.done)); + expect(ann.styleForState(geo.annotation.state.done)).toEqual(testStyles.style); + // create extends edit, so it is special + expect(ann.styleForState(geo.annotation.state.create)).toEqual({ + strokeWidth: 2, fill: false, strokeOpacity: 0.5 + }); + expect(ann.styleForState(geo.annotation.state.edit)).toEqual(testStyles.editStyle); + expect(ann.styleForState(geo.annotation.state.highlight)).toEqual(testStyles.highlightStyle); + ann.state(geo.annotation.state.create); + expect(ann.styleForState()).toEqual(ann.styleForState(geo.annotation.state.create)); + }); + it('_addEditHandles', function () { + var ann = geo.annotation.annotation('test', {layer: layer}), + features = [], handles; + ann._coordinates = function () { + return [{x: 1, y: 2}, {x: 3, y: 5}, {x: 8, y: 11}]; + }; + expect(ann._addEditHandles(features, ann._coordinates(), {edge: false, center: false, rotate: false, resize: false})).toBe(undefined); + // just vertices + handles = features[geo.annotation._editHandleFeatureLevel].point; + expect(handles.length).toBe(3); + // all handles + handles.splice(0, handles.length); + ann._addEditHandles(features, ann._coordinates()); + expect(handles.length).toBe(9); + expect(handles.map(function (h) { return h.type; })).toEqual([ + 'vertex', 'edge', 'vertex', 'edge', 'vertex', 'edge', 'center', 'rotate', 'resize']); + expect(handles.map(function (h) { return h.index; })).toEqual([ + 0, 0, 1, 1, 2, 2, undefined, undefined, undefined]); + expect(handles.map(function (h) { return h.selected; })).toEqual([ + undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined]); + // add handles with an edge selected + handles.splice(0, handles.length); + ann._editHandle = {handle: {selected: true, type: 'edge', index: 1}}; + ann._addEditHandles(features, ann._coordinates()); + expect(handles.map(function (h) { return h.selected; })).toEqual([ + undefined, undefined, undefined, true, undefined, undefined, + undefined, undefined, undefined]); + // all handles but edges + handles.splice(0, handles.length); + ann._addEditHandles(features, ann._coordinates(), {edge: false}); + expect(handles.length).toBe(6); + expect(handles.map(function (h) { return h.type; })).toEqual([ + 'vertex', 'vertex', 'vertex', 'center', 'rotate', 'resize']); + expect(handles.map(function (h) { return h.index; })).toEqual([ + 0, 1, 2, undefined, undefined, undefined]); + // vertices and center + handles.splice(0, handles.length); + ann._addEditHandles(features, ann._coordinates(), {edge: false, resize: false, rotate: false}); + expect(handles.length).toBe(4); + expect(handles[3].type).toBe('center'); + // vertices and rotate + handles.splice(0, handles.length); + ann._addEditHandles(features, ann._coordinates(), {edge: false, center: false, resize: false}); + expect(handles.length).toBe(4); + expect(handles[3].type).toBe('rotate'); + expect(ann._editHandle.amountRotated).toBe(0); + // vertices and resize + handles.splice(0, handles.length); + ann._addEditHandles(features, ann._coordinates(), {edge: false, center: false, rotate: false}); + expect(handles.length).toBe(4); + expect(handles[3].type).toBe('resize'); + // style can override + ann.editHandleStyle('handles', {vertex: false, rotate: false}); + handles.splice(0, handles.length); + ann._addEditHandles(features, ann._coordinates()); + expect(handles.length).toBe(5); + }); + it('selectEditHandle', function () { + var ann = geo.annotation.annotation('test', {layer: layer}), + features = [], handles; + ann._coordinates = function () { + return [{x: 1, y: 2}, {x: 3, y: 5}, {x: 8, y: 11}]; + }; + ann._addEditHandles(features, ann._coordinates()); + handles = features[geo.annotation._editHandleFeatureLevel].point; + expect(ann.selectEditHandle(handles[0], true)).toBe(ann); + expect(ann._editHandle.handle).toBe(handles[0]); + expect(handles[0].selected).toBe(true); + expect(ann.selectEditHandle(handles[0], false)).toBe(ann); + expect(handles[0].selected).toBe(false); + expect(ann.selectEditHandle(handles[1], true)).toBe(ann); + expect(ann.selectEditHandle(handles[2], true)).toBe(ann); + expect(handles[1].selected).toBe(false); + expect(handles[2].selected).toBe(true); + }); + // processEditAction gets tested as well + it('_processEditActionCenter', function () { + var pts = [{x: 10, y: 20}, {x: 30, y: 50}, {x: 80, y: 110}], + ann = createEditableAnnotation(map, layer, pts), + handles = ann._handles, + evt, check; + expect(ann.selectEditHandle(handles[6], true)).toBe(ann); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + expect(ann.processEditAction(evt)).toBe(false); + expect(ann._processEditActionCenter(evt)).toBe(false); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 63}); + expect(ann.processEditAction(evt)).toBe(true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 65}); + expect(ann._processEditActionCenter(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[1].x).toBeCloseTo(30); + expect(check[1].y).toBeCloseTo(55); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 42, y: 68}); + expect(ann._processEditActionCenter(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[1].x).toBeCloseTo(32); + expect(check[1].y).toBeCloseTo(58); + }); + it('_processEditActionRotate', function () { + var pts = [{x: 10, y: 20}, {x: 30, y: 50}, {x: 80, y: 110}], + ann = createEditableAnnotation(map, layer, pts), + handles = ann._handles, + evt, check; + expect(ann.selectEditHandle(handles[7], true)).toBe(ann); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + expect(ann.processEditAction(evt)).toBe(false); + expect(ann._processEditActionRotate(evt)).toBe(false); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 63}); + expect(ann.processEditAction(evt)).toBe(true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 65}); + expect(ann._processEditActionRotate(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[1].x).toBeCloseTo(30.66); + expect(check[1].y).toBeCloseTo(49.41); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 42, y: 68}); + expect(ann._processEditActionRotate(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[1].x).toBeCloseTo(30.76); + expect(check[1].y).toBeCloseTo(49.32); + }); + it('_processEditActionResize', function () { + var pts = [{x: 10, y: 20}, {x: 30, y: 50}, {x: 80, y: 110}], + ann = createEditableAnnotation(map, layer, pts), + handles = ann._handles, + evt, check; + expect(ann.selectEditHandle(handles[8], true)).toBe(ann); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + expect(ann.processEditAction(evt)).toBe(false); + expect(ann._processEditActionResize(evt)).toBe(false); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 63}); + expect(ann.processEditAction(evt)).toBe(true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 65}); + expect(ann._processEditActionResize(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[1].x).toBeCloseTo(29.09); + expect(check[1].y).toBeCloseTo(49.03); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 42, y: 68}); + expect(ann._processEditActionResize(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[1].x).toBeCloseTo(28.19); + expect(check[1].y).toBeCloseTo(48.07); + }); + it('_processEditActionEdge', function () { + var pts = [{x: 10, y: 20}, {x: 30, y: 50}, {x: 80, y: 110}], + ann = createEditableAnnotation(map, layer, pts), + handles = ann._handles, + evt; + expect(ann.selectEditHandle(handles[3], true)).toBe(ann); + expect(ann._editHandle.handle.type).toBe('edge'); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + expect(ann._processEditActionEdge(evt)).toBe(true); + expect(ann._coordinates().length).toBe(4); + expect(ann._editHandle.handle.type).toBe('vertex'); + ann = createEditableAnnotation(map, layer, pts); + handles = ann._handles; + expect(ann.selectEditHandle(handles[3], true)).toBe(ann); + expect(ann.processEditAction(evt)).toBe(true); + }); + it('_processEditActionVertex', function () { + var pts = [{x: 10, y: 20}, {x: 30, y: 50}, {x: 32, y: 50}, {x: 80, y: 110}], + ann = createEditableAnnotation(map, layer, pts), + handles = ann._handles, + evt, check; + expect(ann.selectEditHandle(handles[2], true)).toBe(ann); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + expect(ann.processEditAction(evt)).toBe(false); + expect(ann._processEditActionVertex(evt)).toBe(false); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 41, y: 63}); + expect(ann.processEditAction(evt)).toBe(true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 41, y: 60}); + expect(ann._processEditActionVertex(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[1].x).toBeCloseTo(31); + expect(check[1].y).toBeCloseTo(50); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 20, y: 31}); + expect(ann._processEditActionVertex(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check.length).toBe(4); + expect(check[1].x).toBeCloseTo(10); + expect(check[1].y).toBeCloseTo(20); + evt.event = geo.event.actionup; + expect(ann._processEditActionVertex(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check.length).toBe(3); + // reset to where we started + ann = createEditableAnnotation(map, layer, pts); + handles = ann._handles; + expect(ann.selectEditHandle(handles[6], true)).toBe(ann); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: -30, y: -29}); + expect(ann._processEditActionVertex(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[3].x).toBeCloseTo(10); + expect(check[3].y).toBeCloseTo(20); + expect(ann._processEditActionVertex(evt, false)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[3].x).toBeCloseTo(10); + expect(check[3].y).toBeCloseTo(21); + expect(ann._processEditActionVertex(evt, true)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[3].x).toBeCloseTo(10); + expect(check[3].y).toBeCloseTo(20); + evt.event = geo.event.actionup; + expect(ann._processEditActionVertex(evt, true)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check.length).toBe(3); + }); + it('defaultEditHandleStyle functions', function () { + var pts = [{x: 10, y: 20}, {x: 30, y: 50}, {x: 80, y: 110}], + ann = createEditableAnnotation(map, layer, pts), + handles = ann._handles; + ann.selectEditHandle(handles[0], true); + expect(handles[0].style.fillColor(handles[0])).not.toEqual(handles[1].style.fillColor(handles[1])); + expect(handles[2].style.fillColor(handles[2])).toEqual(handles[1].style.fillColor(handles[1])); + expect(handles[0].style.fillOpacity(handles[0])).not.toEqual(handles[1].style.fillOpacity(handles[1])); + expect(handles[0].style.radius(handles[0])).not.toEqual(handles[1].style.radius(handles[1])); + expect(handles[0].style.strokeWidth(handles[0])).not.toEqual(handles[1].style.strokeWidth(handles[1])); + }); }); describe('geo.annotation.rectangleAnnotation', function () { @@ -291,13 +602,20 @@ describe('geo.annotation', function () { expect(ann.type()).toBe('rectangle'); }); it('features', function () { - var ann = geo.annotation.rectangleAnnotation({corners: corners}); + var map = createMap(); + var layer = map.createLayer('annotation', { + annotations: ['rectangle'] + }); + var ann = geo.annotation.rectangleAnnotation({layer: layer, corners: corners}); var features = ann.features(); expect(features.length).toBe(1); expect(features[0].polygon.polygon).toEqual(corners); expect(features[0].polygon.style.fillOpacity).toBe(0.25); expect(features[0].polygon.style.fillColor.g).toBe(1); expect(features[0].polygon.style.polygon({polygon: 'a'})).toBe('a'); + ann.state(geo.annotation.state.edit); + features = ann.features(); + expect(features.length).toBe(4); ann.state(geo.annotation.state.create); features = ann.features(); expect(features.length).toBe(1); @@ -455,6 +773,119 @@ describe('geo.annotation', function () { mapgcs: map.displayToGcs(corners[1], null) })).toBe('remove'); }); + it('processEditAction', function () { + var pts = [{x: 10, y: 15}, {x: 40, y: 15}, {x: 40, y: 25}, {x: 10, y: 25}], + pts2 = [{x: 20, y: 15}, {x: 60, y: 45}, {x: 75, y: 65}, {x: 5, y: 35}], + pts3 = [{x: 20, y: 15}, {x: 20, y: 15}, {x: 20, y: 15}, {x: 20, y: 15}], + pts4 = [{x: 20, y: 15}, {x: 20, y: 15}, {x: 40, y: 25}, {x: 40, y: 25}], + pts5 = [{x: 20, y: 15}, {x: 40, y: 25}, {x: 40, y: 25}, {x: 20, y: 15}], + map = createMap(), + layer = map.createLayer('annotation', {annotations: ['rectangle']}), + ann = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: map.displayToGcs(pts, null), + state: geo.annotation.state.edit + }), + features = ann.features(), + handles = features[geo.annotation._editHandleFeatureLevel].point, + evt, check; + // select a vertex + ann.selectEditHandle(handles[2], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 42, y: 65}); + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(10); + expect(check[0].y).toBeCloseTo(20); + expect(check[1].x).toBeCloseTo(42); + expect(check[1].y).toBeCloseTo(20); + expect(check[2].x).toBeCloseTo(42); + expect(check[2].y).toBeCloseTo(25); + expect(check[3].x).toBeCloseTo(10); + expect(check[3].y).toBeCloseTo(25); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + expect(ann.processEditAction(evt)).toBe(true); + // select an edge + ann.selectEditHandle(handles[3], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 42, y: 65}); + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(10); + expect(check[0].y).toBeCloseTo(15); + expect(check[1].x).toBeCloseTo(42); + expect(check[1].y).toBeCloseTo(15); + expect(check[2].x).toBeCloseTo(42); + expect(check[2].y).toBeCloseTo(25); + expect(check[3].x).toBeCloseTo(10); + expect(check[3].y).toBeCloseTo(25); + // test with a rotated rectangle + ann._coordinates(map.displayToGcs(pts2, null)); + // select a vertex + ann.selectEditHandle(handles[2], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 46, y: 68}); + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(26); + expect(check[0].y).toBeCloseTo(23); + expect(check[1].x).toBeCloseTo(66); + expect(check[1].y).toBeCloseTo(53); + expect(check[2].x).toBeCloseTo(82.97); + expect(check[2].y).toBeCloseTo(68.41); + expect(check[3].x).toBeCloseTo(5); + expect(check[3].y).toBeCloseTo(35); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + expect(ann.processEditAction(evt)).toBe(true); + // select an edge + ann.selectEditHandle(handles[3], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 46, y: 68}); + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(20); + expect(check[0].y).toBeCloseTo(15); + expect(check[1].x).toBeCloseTo(67.97); + expect(check[1].y).toBeCloseTo(48.41); + expect(check[2].x).toBeCloseTo(82.97); + expect(check[2].y).toBeCloseTo(68.41); + expect(check[3].x).toBeCloseTo(5); + expect(check[3].y).toBeCloseTo(35); + // test that super class method is called + ann.selectEditHandle(handles[8], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 63}); + expect(ann.processEditAction(evt)).toBe(true); + // test degenerate rectangles + ann._coordinates(map.displayToGcs(pts3, null)); + ann.selectEditHandle(handles[2], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 46, y: 68}); + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(20); + expect(check[0].y).toBeCloseTo(23); + expect(check[1].x).toBeCloseTo(26); + expect(check[1].y).toBeCloseTo(23); + expect(check[2].x).toBeCloseTo(26); + expect(check[2].y).toBeCloseTo(15); + ann._coordinates(map.displayToGcs(pts4, null)); + ann.selectEditHandle(handles[2], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 46, y: 68}); + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(28); + expect(check[0].y).toBeCloseTo(19); + expect(check[1].x).toBeCloseTo(26); + expect(check[1].y).toBeCloseTo(23); + expect(check[2].x).toBeCloseTo(38); + expect(check[2].y).toBeCloseTo(29); + ann._coordinates(map.displayToGcs(pts5, null)); + ann.selectEditHandle(handles[2], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 46, y: 68}); + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(18); + expect(check[0].y).toBeCloseTo(19); + expect(check[1].x).toBeCloseTo(46); + expect(check[1].y).toBeCloseTo(33); + expect(check[2].x).toBeCloseTo(48); + expect(check[2].y).toBeCloseTo(29); + }); }); describe('geo.annotation.polygonAnnotation', function () { @@ -466,13 +897,20 @@ describe('geo.annotation', function () { expect(ann.type()).toBe('polygon'); }); it('features', function () { - var ann = geo.annotation.polygonAnnotation({vertices: vertices}); + var map = createMap(); + var layer = map.createLayer('annotation', { + annotations: ['polygon'] + }); + var ann = geo.annotation.polygonAnnotation({layer: layer, vertices: vertices}); var features = ann.features(); expect(features.length).toBe(1); expect(features[0].polygon.polygon).toEqual(vertices); expect(features[0].polygon.style.fillOpacity).toBe(0.25); expect(features[0].polygon.style.fillColor.g).toBe(1); expect(features[0].polygon.style.polygon({polygon: 'a'})).toBe('a'); + ann.state(geo.annotation.state.edit); + features = ann.features(); + expect(features.length).toBe(4); ann.state(geo.annotation.state.create); features = ann.features(); expect(features.length).toBe(3); @@ -600,6 +1038,9 @@ describe('geo.annotation', function () { expect(features.length).toBe(1); expect(features[0].point.x).toEqual(point.x); expect(features[0].point.style.radius).toBe(10); + ann.state(geo.annotation.state.edit); + features = ann.features(); + expect(features.length).toBe(4); ann.state(geo.annotation.state.create); features = ann.features(); expect(features.length).toBe(0); @@ -693,7 +1134,11 @@ describe('geo.annotation', function () { expect(ann.type()).toBe('line'); }); it('features', function () { - var ann = geo.annotation.lineAnnotation({vertices: vertices}); + var map = createMap(); + var layer = map.createLayer('annotation', { + annotations: ['line'] + }); + var ann = geo.annotation.lineAnnotation({layer: layer, vertices: vertices}); var features = ann.features(); expect(features.length).toBe(1); expect(features[0].line.line).toEqual(vertices); @@ -701,6 +1146,9 @@ describe('geo.annotation', function () { expect(features[0].line.style.strokeColor.b).toBe(0); expect(features[0].line.style.line().length).toBe(vertices.length); expect(features[0].line.style.position(0, 1)).toEqual(vertices[1]); + ann.state(geo.annotation.state.edit); + features = ann.features(); + expect(features.length).toBe(4); ann.state(geo.annotation.state.create); features = ann.features(); expect(features.length).toBe(1); @@ -902,6 +1350,72 @@ describe('geo.annotation', function () { expect(ann.processAction(evt)).toBe(true); expect(ann.options('vertices').length).toBe(4); }); + it('processEditAction', function () { + var map = createMap(), + layer = map.createLayer('annotation', {annotations: ['line']}), + ann = geo.annotation.lineAnnotation({ + layer: layer, + vertices: map.displayToGcs(vertices, null), + state: geo.annotation.state.edit + }), + features = ann.features(), + handles = features[geo.annotation._editHandleFeatureLevel].point, + evt, check; + // select a vertex and close the line + ann.selectEditHandle(handles[6], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 51}); + evt.event = geo.event.actionup; + expect(ann.processEditAction(evt)).toBe(true); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check.length).toBe(3); + expect(ann.style('closed')).toBe(true); + // test that super class method is called + features = ann.features(); + handles = features[geo.annotation._editHandleFeatureLevel].point; + ann.selectEditHandle(handles[6], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 63}); + expect(ann.processEditAction(evt)).toBe(true); + }); + it('mouseClickEdit', function () { + var map = createMap(), + layer = map.createLayer('annotation', {annotations: ['line']}), + ann = geo.annotation.lineAnnotation({ + layer: layer, + vertices: map.displayToGcs(vertices, null), + state: geo.annotation.state.edit, + style: {closed: true} + }), + features = ann.features(), + handles = features[geo.annotation._editHandleFeatureLevel].point, + evt, check; + // select a vertex -- this will do nothing + ann.selectEditHandle(handles[2], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + evt.buttonsDown.left = true; + expect(ann.mouseClickEdit(evt)).toBe(undefined); + expect(ann.mouseClickEdit(evt)).toBe(undefined); + // now use + ann.selectEditHandle(handles[1], true); + evt = editHandleEvent(map, {x: 40, y: 60}, {x: 40, y: 60}); + // with no button down specified, the event does nothing + expect(ann.mouseClickEdit(evt)).toBe(undefined); + evt.buttonsDown.left = true; + // the first click does nothing + expect(ann.mouseClickEdit(evt)).toBe(undefined); + evt.time += 20000; + // the second click does nothing if there is too much delay + expect(ann.mouseClickEdit(evt)).toBe(undefined); + // but will split if there is less delay + expect(ann.mouseClickEdit(evt)).toBe(true); + expect(ann.style('closed')).toBe(false); + check = map.gcsToDisplay(ann._coordinates(), null); + expect(check[0].x).toBeCloseTo(50); + expect(check[0].y).toBeCloseTo(0); + // we can't break an already open line + ann.selectEditHandle(handles[1], true); + expect(ann.mouseClickEdit(evt)).toBe(undefined); + expect(ann.mouseClickEdit(evt)).toBe(undefined); + }); }); describe('annotation registry', function () { @@ -916,14 +1430,21 @@ describe('geo.annotation', function () { }); it('registerAnnotation', function () { var func = function () { newshapeCount += 1; return 'newshape return'; }; + sinon.stub(console, 'warn', function () {}); expect($.inArray('newshape', geo.listAnnotations()) >= 0).toBe(false); expect(geo.registerAnnotation('newshape', func)).toBe(undefined); expect($.inArray('newshape', geo.listAnnotations()) >= 0).toBe(true); + expect(console.warn.calledOnce).toBe(false); expect(geo.registerAnnotation('newshape', func).func).toBe(func); + expect(console.warn.calledOnce).toBe(true); expect($.inArray('newshape', geo.listAnnotations()) >= 0).toBe(true); + console.warn.restore(); }); it('createAnnotation', function () { + sinon.stub(console, 'warn', function () {}); expect(geo.createAnnotation('unknown')).toBe(undefined); + expect(console.warn.calledOnce).toBe(true); + console.warn.restore(); expect(newshapeCount).toBe(0); expect(geo.createAnnotation('newshape')).toBe('newshape return'); expect(newshapeCount).toBe(1); @@ -955,11 +1476,15 @@ describe('geo.annotation', function () { expect($.inArray('point', features) >= 0).toBe(false); }); it('rendererForAnnotations', function () { + sinon.stub(console, 'warn', function () {}); expect(geo.rendererForAnnotations(['polygon'])).toBe('vgl'); + expect(console.warn.calledOnce).toBe(false); expect(geo.rendererForAnnotations(['point'])).toBe('vgl'); geo.gl.vglRenderer.supported = function () { return false; }; expect(geo.rendererForAnnotations(['polygon'])).toBe(false); + expect(console.warn.calledOnce).toBe(true); expect(geo.rendererForAnnotations(['point'])).toBe('d3'); + console.warn.restore(); }); }); }); diff --git a/tests/cases/annotationLayer.js b/tests/cases/annotationLayer.js index 27cfa76fe8..52a83ec24e 100644 --- a/tests/cases/annotationLayer.js +++ b/tests/cases/annotationLayer.js @@ -81,6 +81,15 @@ describe('geo.annotationLayer', function () { expect(layer.mode(null)).toBe(layer); expect(layer.mode()).toBe(null); expect(map.interactor().hasAction(undefined, undefined, geo.annotation.actionOwner)).toBeNull(); + var rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}]}); + layer.addAnnotation(rect, map.gcs()); + expect(layer.mode(layer.modes.edit, rect)).toBe(layer); + expect(layer.mode()).toBe(layer.modes.edit); + expect(layer.mode(null)).toBe(layer); + expect(layer.mode()).toBe(null); + layer.removeAllAnnotations(); }); it('annotations', function () { var poly = geo.annotation.polygonAnnotation({ @@ -325,6 +334,7 @@ describe('geo.annotationLayer', function () { describe('Private utility functions', function () { var map, layer, point, rect, rect2; it('_update', function () { + sinon.stub(console, 'warn', function () {}); /* Most of update is covered as a side effect of other code. This tests * some edge conditions */ map = createMap(); @@ -344,7 +354,15 @@ describe('geo.annotationLayer', function () { layer.addAnnotation(point); layer.addAnnotation(rect); layer.addAnnotation(rect2); - expect(layer.features.length).toBe(1); + expect(layer.features().length).toBe(1); + expect(layer.features()[0].geoIsOn(geo.event.feature.mouseon)).toBe(false); + layer.options('clickToEdit', true); + layer._update(); + expect(layer.features()[0].geoIsOn(geo.event.feature.mouseon)).toBe(true); + layer.options('clickToEdit', false); + layer._update(); + expect(layer.features()[0].geoIsOn(geo.event.feature.mouseon)).toBe(false); + console.warn.restore(); }); it('_updateLabels and _removeLabelFeature', function () { var numChild, canvasLayer, canvasLine; @@ -434,6 +452,96 @@ describe('geo.annotationLayer', function () { mapgcs: map.displayToGcs({x: 30, y: 20}, null) }); expect(layer.annotations().length).toBe(0); + // test with clickToEdit + var rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: map.displayToGcs([{x: 30, y: 40}, {x: 100, y: 40}, {x: 100, y: 70}, {x: 30, y: 70}], null)}), + evt = { + buttonsDown: {left: true}, + time: time, + map: {x: 40, y: 50}, + mapgcs: map.displayToGcs({x: 40, y: 50}, null) + }; + layer.addAnnotation(rect); + layer.options('clickToEdit', true); + layer.draw(); + // if no annotation is highlighted, clicking does nothing + layer._handleMouseClick(evt); + expect(layer.mode()).toBe(null); + // if an annotation is highlighted, clicking switches to edit mode + rect.state(geo.annotation.state.highlight); + layer.modified(); + layer.draw(); + layer._handleMouseClick(evt); + expect(layer.mode()).toBe(layer.modes.edit); + // if an edit handle is selected, clicking passes the event to the + // annotation and we stay in annotation mode + layer._selectEditHandle({ + data: layer.features()[layer.features().length - 1].data()[0] + }, true); + layer._handleMouseClick(evt); + expect(layer.mode()).toBe(layer.modes.edit); + // if no edit handle is selected, clicking exits edit mode + layer._selectEditHandle({ + data: layer.features()[layer.features().length - 1].data()[0] + }, false); + layer._handleMouseClick(evt); + expect(layer.mode()).toBe(null); + layer.options('clickToEdit', false); + }); + it('_handleMouseOn', function () { + var rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: map.displayToGcs([{x: 30, y: 40}, {x: 100, y: 40}, {x: 100, y: 70}, {x: 30, y: 70}], null)}); + layer.removeAllAnnotations(); + layer.addAnnotation(rect); + expect(layer._handleMouseOn({})).toBe(undefined); + expect(layer.mode()).toBe(null); + // if in edit mode and over an edit handle, that handle gets selected + layer.mode(layer.modes.edit, rect); + layer.draw(); + expect(layer.features()[layer.features().length - 1].data()[0].selected).not.toBe(true); + expect(layer._handleMouseOn({ + data: layer.features()[layer.features().length - 1].data()[0] + })).toBe(undefined); + expect(layer.features()[layer.features().length - 1].data()[0].selected).toBe(true); + // if we aren't in null mode or clickToEdit is disabled, do nothing + layer.mode(null); + expect(layer._handleMouseOn({ + data: {annotation: rect} + })).toBe(undefined); + expect(rect.state()).toBe(geo.annotation.state.done); + // otherwise, highlight the annotation + layer.options('clickToEdit', true); + expect(layer._handleMouseOn({ + data: {annotation: rect} + })).toBe(undefined); + expect(rect.state()).toBe(geo.annotation.state.highlight); + layer.options('clickToEdit', false); + }); + it('_handleMouseOff', function () { + var rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: map.displayToGcs([{x: 30, y: 40}, {x: 100, y: 40}, {x: 100, y: 70}, {x: 30, y: 70}], null)}); + layer.removeAllAnnotations(); + layer.addAnnotation(rect); + expect(layer._handleMouseOff({})).toBe(undefined); + expect(layer.mode()).toBe(null); + // if in edit mode and a handle is selected, it is deselected + layer.mode(layer.modes.edit, rect); + layer.draw(); + layer.features()[layer.features().length - 1].data()[0].selected = true; + expect(layer._handleMouseOff({ + data: layer.features()[layer.features().length - 1].data()[0] + })).toBe(undefined); + expect(layer.features()[layer.features().length - 1].data()[0].selected).toBe(false); + layer.mode(null); + // if not in edit mode and an annotation is highlighted, de-highlight it + rect.state(geo.annotation.state.highlight); + expect(layer._handleMouseOff({ + data: {annotation: rect} + })).toBe(undefined); + expect(rect.state()).toBe(geo.annotation.state.done); }); it('_handleMouseMove', function () { layer.removeAllAnnotations(); @@ -495,6 +603,40 @@ describe('geo.annotationLayer', function () { expect(layer.annotations().length).toBe(1); expect(layer.annotations()[0].type()).toBe('rectangle'); expect(layer.annotations()[0].state()).toBe(geo.annotation.state.done); + // test edit mode + layer.mode(layer.modes.edit, layer.annotations()[0]); + layer.draw(); + layer._selectEditHandle({ + data: layer.features()[layer.features().length - 1].data()[0] + }, true); + expect(layer.annotations()[0].coordinates()[0].y).toBeCloseTo(14.77); + layer._processAction({ + mouse: {mapgcs: map.displayToGcs({x: 10, y: 33}, null)}, + state: { + actionRecord: {owner: geo.annotation.actionOwner}, + origin: {mapgcs: map.displayToGcs({x: 10, y: 20}, null)} + } + }); + expect(layer.annotations()[0].coordinates()[0].y).toBeCloseTo(13.67); + }); + it('_selectEditHandle', function () { + layer.removeAllAnnotations(); + rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}]}); + layer.addAnnotation(rect); + layer.mode(layer.modes.edit, rect); + layer.draw(); + layer._selectEditHandle({ + data: layer.features()[layer.features().length - 1].data()[0] + }, true); + expect(rect._editHandle.handle).toBe(layer.features()[layer.features().length - 1].data()[0]); + layer._selectEditHandle({ + data: layer.features()[layer.features().length - 1].data()[1] + }, true); + expect(rect._editHandle.handle).toBe(layer.features()[layer.features().length - 1].data()[1]); + layer._selectEditHandle({}, true); + expect(rect._editHandle.handle).toBe(layer.features()[layer.features().length - 1].data()[1]); }); it('_geojsonFeatureToAnnotation', function () { map.deleteLayer(layer); diff --git a/tests/cases/feature.js b/tests/cases/feature.js index 7c4cd79a72..99f2707f10 100644 --- a/tests/cases/feature.js +++ b/tests/cases/feature.js @@ -150,6 +150,22 @@ describe('geo.feature', function () { expect(Object.getOwnPropertyNames(events)).toEqual(['brushend']); expect(events.brushend.index).toEqual(9); }); + it('_clearSelectedFeatures', function () { + points.index = []; + events = {}; + feat._handleMousemove(); + points.index = [4, 5]; + events = {}; + feat._handleMousemove(); + expect(Object.getOwnPropertyNames(events).sort()).toEqual(['mousemove', 'mouseon', 'mouseover']); + // moving again shouldn't report a mouseon or mouseover + feat._handleMousemove(); + expect(Object.getOwnPropertyNames(events).sort()).toEqual(['mousemove', 'mouseon', 'mouseover']); + expect(feat._clearSelectedFeatures()).toBe(feat); + // moving again should now report a mouseon and mouseover + feat._handleMousemove(); + expect(Object.getOwnPropertyNames(events).sort()).toEqual(['mousemove', 'mouseon', 'mouseover']); + }); }); describe('Check public class methods', function () { var map, layer, feat;