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;