diff --git a/examples/annotations/example.json b/examples/annotations/example.json new file mode 100644 index 0000000000..11d603ae4b --- /dev/null +++ b/examples/annotations/example.json @@ -0,0 +1,9 @@ +{ + "path": "annotations", + "title": "Annotation Layer", + "exampleCss": ["main.css"], + "exampleJs": ["main.js"], + "about": { + "text": "This example shows how to add annotations, such as marked rectangles, to a map. Left click to add a polygon, right click to add a rectangle." + } +} diff --git a/examples/annotations/index.jade b/examples/annotations/index.jade new file mode 100644 index 0000000000..bd78a8a2d5 --- /dev/null +++ b/examples/annotations/index.jade @@ -0,0 +1,75 @@ +extends ../common/templates/index.jade + +block append mainContent + #controls + .form-group.annotationtype(title='Select the type of annotation to add.') + .shortlabel Add + button#rectangle.lastused(next='polygon') Rectangle + button#polygon(next='point') Polygon + button#point(next='rectangle') Point + .form-group + #instructions(annotation='none') + .annotation.none + .annotation.polygon Left-click points in the polygon. Double click, right click, or click the starting point to close the polygon. + .annotation.rectangle Left click-and-drag to draw a rectangle. + .annotation.point Left click to create a point. + .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') + .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') + .form-group#annotationheader + .shortlabel Created Annotations + a.entry-remove-all(action='remove-all', title='Delete all annotations') ✖ + .form-group + #annotationlist + .entry#sample + span.entry-name Sample + a.entry-edit(action='edit', title='Edit name and properties') ✎ + a.entry-remove(action='remove', title='Delete this annotation') ✖ + // add buttons to copy annotations as geojson and to paste geojson to annotations + + #editdialog.modal.fade + .modal-dialog + form.modal-content + .modal-header + button.close(type='button', data-dismiss='modal') × + h4.modal-title Edit Annotation + .modal-body + .form-group + label(for='edit-name') Name + input#edit-name(option='name') + .form-group(annotation-types='point') + label(for='edit-radius') Radius + input#edit-radius(option='radius', format='positive') + .form-group(annotation-types='point polygon rectangle') + label(for='edit-fill') Fill + select#edit-stroke(option='fill', format='boolean') + option(value='true') Yes + option(value='false') No + .form-group(annotation-types='point polygon rectangle') + label(for='edit-fillColor') Fill Color + input#edit-fillColor(option='fillColor', format='color') + .form-group(annotation-types='point polygon rectangle') + label(for='edit-fillOpacity') Fill Opacity + input#edit-fillOpacity(option='fillOpacity', format='opacity') + .form-group(annotation-types='point polygon rectangle') + label(for='edit-stroke') Stroke + select#edit-stroke(option='stroke', format='boolean') + option(value='true') Yes + option(value='false') No + .form-group(annotation-types='point polygon rectangle') + label(for='edit-strokeWidth') Stroke Width + input#edit-strokeWidth(option='strokeWidth', format='positive') + .form-group(annotation-types='point polygon rectangle') + label(for='edit-strokeColor') Stroke Color + input#edit-strokeColor(option='strokeColor', format='color') + .form-group(annotation-types='point polygon rectangle') + label(for='edit-strokeOpacity') Stroke Opacity + input#edit-strokeOpacity(option='strokeOpacity', format='opacity') + .form-group + #edit-validation-error + .modal-footer + button.btn.btn-sm.btn-primary#edit-update(type='submit') Update + button.btn.btn-sm.btn-secondary(data-dismiss='modal') Cancel diff --git a/examples/annotations/main.css b/examples/annotations/main.css new file mode 100644 index 0000000000..c8eeb444fb --- /dev/null +++ b/examples/annotations/main.css @@ -0,0 +1,101 @@ +#controls { + overflow-x: hidden; + overflow-y: auto; + position: absolute; + left: 10px; + top: 80px; + z-index: 1; + border-radius: 5px; + border: 1px solid grey; + box-shadow: 1px 1px 3px black; + opacity: 0.5; + transition: opacity 250ms ease; + background: #CCC; + color: black; + padding: 4px; + font-size: 14px; + max-height: calc(100% - 100px); + min-width: 310px; +} +#controls:hover { + opacity: 1; +} +#controls .form-group { + margin-bottom: 0; +} +#controls label { + min-width: 160px; +} +#controls.no-controls { + display: none; +} +.annotationtype button.lastused { + color: #373a3c; + background-color: #e6e6e6; + border-color: #adadad; +} +.annotationtype button.active { + color: white; + background-color: #025aa5; + border-color: #01549b; +} +.shortlabel { + margin-bottom: 5px; + font-weight: bold; + display: inline-block; +} +#instructions .annotation { + display: none; + max-width: 300px; + font-size: 12px; + line-height: 1.42857em; + min-height: 2.85714em; +} +#instructions[annotation="polygon"] .annotation.polygon, +#instructions[annotation="rectangle"] .annotation.rectangle, +#instructions[annotation="point"] .annotation.point, +#instructions[annotation="none"] .annotation.none { + display: block; +} +#controls a { + color: #333; + cursor: pointer; +} +.entry span, .entry a { + display: inline-block; +} +.entry .entry-name { + width: 86%; +} +.entry .entry-edit { + width: 7%; + text-align: right; + font-weight: bold; +} +.entry .entry-remove { + width: 7%; + text-align: right; +} +.entry-remove-all { + float: right; +} +#annotationheader, .entry#sample { + display: none; +} +#editdialog { + font-size: 14px; +} +#editdialog .form-group { + margin-bottom: 0px; +} +#editdialog #edit-update { + float: right; + margin-left: 10px; +} +#editdialog label { + min-width: 105px; +} +#editdialog #edit-validation-error { + color: red; + font-weight: bold; +} diff --git a/examples/annotations/main.js b/examples/annotations/main.js new file mode 100644 index 0000000000..4b9a900dc1 --- /dev/null +++ b/examples/annotations/main.js @@ -0,0 +1,342 @@ +/* globals $, geo, utils */ + +var annotationDebug = {}; + +// Run after the DOM loads +$(function () { + 'use strict'; + + var layer, fromButtonSelect; + + // get the query parameters and set controls appropriately + var query = utils.getQuery(); + $('#clickadd').prop('checked', query.clickadd !== 'false'); + $('#keepadding').prop('checked', query.keepadding === 'true'); + if (query.lastannotation) { + $('.annotationtype button').removeClass('lastused'); + $('.annotationtype button#' + query.lastannotation).addClass('lastused'); + } + + $('#controls').on('change', change_controls); + $('#controls').on('click', 'a', select_control); + $('.annotationtype button').on('click', select_annotation); + $('#editdialog').on('submit', edit_update); + + $('#controls').toggleClass('no-controls', query.controls === 'false'); + + var map = geo.map({ + node: '#map', + center: { + x: query.x ? +query.x : -119.5420833, + y: query.y ? +query.y : 37.4958333 + }, + zoom: query.zoom ? +query.zoom : 8, + rotation: query.rotation ? +query.rotation * Math.PI / 180 : 0 + }); + // allow some query parameters without controls to specify what map we will + // show + if (query.map !== 'false') { + if (query.map !== 'satellite') { + annotationDebug.mapLayer = map.createLayer('osm'); + } + if (query.map === 'satellite' || query.map === 'dual') { + annotationDebug.satelliteLayer = map.createLayer('osm', {url: 'http://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png', opacity: query.map === 'dual' ? 0.25 : 1}); + } + } + // create an annotation layer + layer = map.createLayer('annotation', { + renderer: query.renderer ? (query.renderer === 'html' ? null : query.renderer) : undefined, + features: query.renderer ? undefined : ['polygon', 'line', 'point'] + }); + // bind to the mouse click and annotation mode events + layer.geoOn(geo.event.mouseclick, mouseClickToStart); + layer.geoOn(geo.event.annotation.mode, handleModeChange); + layer.geoOn(geo.event.annotation.add, handleAnnotationChange); + layer.geoOn(geo.event.annotation.remove, handleAnnotationChange); + layer.geoOn(geo.event.annotation.state, handleAnnotationChange); + + map.draw(); + + // pick which button is initially highlighted based on query parameters. + if (query.lastused || query.active) { + if (query.active) { + layer.mode(query.active); + } else { + $('.annotationtype button').removeClass('lastused active'); + $('.annotationtype button#' + query.lastused).addClass('lastused'); + } + } + + // expose some internal parameters so you can examine them from the console + annotationDebug.map = map; + annotationDebug.layer = layer; + annotationDebug.query = query; + + /** + * When the mouse is clicked, switch adding an annotation if appropriate. + * + * @param {geo.event} evt geojs event. + */ + function mouseClickToStart(evt) { + if (evt.handled || query.clickadd === 'false') { + return; + } + if (evt.buttonsDown.left) { + if ($('.annotationtype button.lastused').hasClass('active') && query.keepadding === 'true') { + return; + } + select_button('.annotationtype button.lastused'); + } else if (evt.buttonsDown.right) { + select_button('.annotationtype button#' + + $('.annotationtype button.lastused').attr('next')); + } + } + + /** + * Handle changes to our controls. + * + * @param evt jquery evt that triggered this call. + */ + function change_controls(evt) { + var ctl = $(evt.target), + param = ctl.attr('param-name'), + value = ctl.val(); + if (ctl.is('[type="checkbox"]')) { + value = ctl.is(':checked') ? 'true' : 'false'; + } + if (value === '' && ctl.attr('placeholder')) { + value = ctl.attr('placeholder'); + } + if (!param || value === query[param]) { + return; + } + query[param] = value; + if (value === '' || (ctl.attr('placeholder') && + value === ctl.attr('placeholder'))) { + delete query[param]; + } + utils.setQuery(query); + } + + /** + * Handle selecting an annotation button. + * + * @param evt jquery evt that triggered this call. + */ + function select_annotation(evt) { + select_button(evt.target); + } + + /** + * Select an annotation button by jquery selector. + * + * @param {object} ctl a jquery selector or element. + */ + function select_button(ctl) { + ctl = $(ctl); + var wasactive = ctl.hasClass('active'), + id = ctl.attr('id'); + fromButtonSelect = true; + layer.mode(wasactive ? null : id); + fromButtonSelect = false; + } + + /** + * When the annotation mode changes, update the controls to reflect it. + * + * @param {geo.event} evt a geojs mode change event. + */ + function handleModeChange(evt) { + // highlight the current buttons based on the current mode + var mode = layer.mode(); + $('.annotationtype button').removeClass('active'); + if (mode) { + $('.annotationtype button').removeClass('lastused active'); + $('.annotationtype button#' + mode).addClass('lastused active'); + } + $('#instructions').attr( + 'annotation', $('.annotationtype button.active').attr('id') || 'none'); + query.active = $('.annotationtype button.active').attr('id') || undefined; + query.lastused = query.active ? undefined : $('.annotationtype button.lastused').attr('id'); + utils.setQuery(query); + // if we are in keep-adding mode, and the mode changed to null, and that + // wasn't caused by clicking the button, reenable the annotation mode. + if (!mode && !fromButtonSelect && query.keepadding === 'true') { + layer.mode($('.annotationtype button.lastused').attr('id')); + } + } + + /** + * When an annotation is created or removed, update our list of annotations. + * + * @param {geo.event} evt a geojs mode change event. + */ + function handleAnnotationChange(evt) { + var annotations = layer.annotations(); + var ids = annotations.map(function (annotation) { + return annotation.id(); + }); + var present = []; + $('#annotationlist .entry').each(function () { + var entry = $(this); + if (entry.attr('id') === 'sample') { + return; + } + var id = entry.attr('annotation-id'); + // Remove deleted annotations + if ($.inArray(id, ids) < 0) { + entry.remove(); + return; + } + present.push(id); + // update existing elements + entry.find('.entry-name').text(layer.annotationById(id).name()); + }); + // Add if new and fully created + $.each(ids, function (idx, id) { + if ($.inArray(id, present) >= 0) { + return; + } + var annotation = layer.annotationById(id); + if (annotation.state() === geo.annotation.state.create) { + return; + } + var entry = $('#annotationlist .entry#sample').clone(); + entry.attr({id: '', 'annotation-id': id}); + entry.find('.entry-name').text(annotation.name()); + $('#annotationlist').append(entry); + }); + $('#annotationheader').css( + 'display', $('#annotationlist .entry').length <= 1 ? 'none' : 'block'); + } + + /** + * Handle selecting a control. + * + * @param evt jquery evt that triggered this call. + */ + function select_control(evt) { + var mode, + ctl = $(evt.target), + action = ctl.attr('action'), + id = ctl.closest('.entry').attr('annotation-id'), + annotation = layer.annotationById(id); + switch (action) { + case 'edit': + show_edit_dialog(id, annotation); + break; + case 'remove': + layer.removeAnnotation(annotation); + break; + case 'remove-all': + fromButtonSelect = true; + mode = layer.mode(); + layer.mode(null); + layer.removeAllAnnotations(); + layer.mode(mode); + fromButtonSelect = false; + break; + } + } + + /** + * Show the edit dialog for a particular annotation. + * + * @param {number} id the annotation id to edit. + */ + function show_edit_dialog(id) { + var annotation = layer.annotationById(id), + type = annotation.type(), + typeMatch = new RegExp('(^| )' + type + '( |$)'), + opt = annotation.options(), + dlg = $('#editdialog'); + + $('#edit-validation-error', dlg).text(''); + dlg.attr('annotation-id', id); + dlg.attr('annotation-type', type); + $('[option="name"]', dlg).val(annotation.name()); + $('.form-group[annotation-types]').each(function () { + var ctl = $(this), + key = $('[option]', ctl).attr('option'), + format = $('[option]', ctl).attr('format'), + value; + if (!ctl.attr('annotation-types').match(typeMatch)) { + return; + } + value = opt.style[key]; + switch (format) { + case 'color': + value = geo.util.convertColor(value); + if (!value.r && !value.g && !value.b) { + value = '#000000'; + } else { + value = '#' + ((1 << 24) + (Math.round(value.r * 255) << 16) + + (Math.round(value.g * 255) << 8) + + Math.round(value.b * 255)).toString(16).slice(1); + } + break; + } + $('[option]', ctl).val('' + value); + }); + dlg.one('shown.bs.modal', function () { + $('[option="name"]', dlg).focus(); + }); + dlg.modal(); + } + + /** + * Update an annotation from values in the edit dialog. + * + * @param evt jquery evt that triggered this call. + */ + function edit_update(evt) { + evt.preventDefault(); + var dlg = $('#editdialog'), + id = dlg.attr('annotation-id'), + annotation = layer.annotationById(id), + type = annotation.type(), + typeMatch = new RegExp('(^| )' + type + '( |$)'), + error, + newopt = {}; + + $('.form-group[annotation-types]').each(function () { + var ctl = $(this), + key = $('[option]', ctl).attr('option'), + value; + if (!ctl.attr('annotation-types').match(typeMatch)) { + return; + } + value = $('[option]', ctl).val(); + switch ($('[option]', ctl).attr('format')) { + case 'boolean': + value = ('' + value).toLowerCase() === 'true'; + break; + case 'color': + value = geo.util.convertColor(value); + break; + case 'opacity': + value = +value; + if (value < 0 || value > 1 || isNaN(value)) { + error = $('label', ctl).text() + ' must be a between 0 and 1, inclusive.'; + } + break; + case 'positive': + value = +value; + if (value <= 0 || isNaN(value)) { + error = $('label', ctl).text() + ' must be a positive number.'; + } + break; + } + newopt[key] = value; + }); + if (error) { + $('#edit-validation-error', dlg).text(error); + return; + } + annotation.name($('[option="name"]', dlg).val()); + annotation.options({style: newopt}).draw(); + + dlg.modal('hide'); + handleAnnotationChange(); + } +}); diff --git a/examples/common/js/examples.js b/examples/common/js/examples.js index 32aad8d40c..ae01d7e14c 100644 --- a/examples/common/js/examples.js +++ b/examples/common/js/examples.js @@ -13,6 +13,23 @@ var exampleUtils = { return this; }.bind({}))[0]; return query; + }, + + /* Encode a dictionary of parameters to the query string, setting the window + * location and history. This will also remove undefined values from the + * set properites of params. + * + * @param {object} params: the query parameters as a dictionary. + */ + setQuery: function (params) { + $.each(params, function (key, value) { + if (value === undefined) { + delete params[key]; + } + }); + var newurl = window.location.protocol + '//' + window.location.host + + window.location.pathname + '?' + $.param(params); + window.history.replaceState(params, '', newurl); } }; diff --git a/package.json b/package.json index 908dc2fcdc..17c9ed8f05 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "karma-sinon": "^1.0.4", "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^1.7.0", + "mousetrap": "^1.6.0", "node-fs-extra": "^0.8.1", "phantomjs-prebuilt": "^2.1.5", "proj4": "^2.3.14", diff --git a/src/action.js b/src/action.js index 7f12c69ad0..e65e832a8f 100644 --- a/src/action.js +++ b/src/action.js @@ -11,7 +11,11 @@ var geo_action = { select: 'geo_action_select', unzoomselect: 'geo_action_unzoomselect', zoom: 'geo_action_zoom', - zoomselect: 'geo_action_zoomselect' + zoomselect: 'geo_action_zoomselect', + + // annotation actions + annotation_polygon: 'geo_annotation_polygon', + annotation_rectangle: 'geo_annotation_rectangle' }; module.exports = geo_action; diff --git a/src/annotation.js b/src/annotation.js new file mode 100644 index 0000000000..bc377a7247 --- /dev/null +++ b/src/annotation.js @@ -0,0 +1,607 @@ +var $ = require('jquery'); +var inherit = require('./inherit'); +var geo_event = require('./event'); +var transform = require('./transform'); + +var annotationId = 0; + +var annotationState = { + create: 'create', + done: 'done', + edit: 'edit' +}; + +///////////////////////////////////////////////////////////////////////////// +/** + * Base annotation class + * + * @class geo.annotation + * @param {string} type the type of annotation. + * @param {object?} options Inidividual annotations have additional options. + * @param {string} [options.name] A name for the annotation. This defaults to + * the type with a unique ID suffixed to it. + * @param {geo.annotationLayer} [options.layer] a reference to the controlling + * layer. This is used for coordinate transforms. + * @param {string} [options.state] initial annotation state. One of the + * annotation.state values. + * @returns {geo.annotation} + */ +///////////////////////////////////////////////////////////////////////////// +var annotation = function (type, args) { + 'use strict'; + if (!(this instanceof annotation)) { + return new annotation(type, args); + } + + annotationId += 1; + var m_options = $.extend({}, args || {}), + m_id = annotationId, + m_name = m_options.name || ( + type.charAt(0).toUpperCase() + type.substr(1) + ' ' + annotationId), + m_type = type, + m_layer = m_options.layer, + /* one of annotationState.* */ + m_state = m_options.state || annotationState.done; + delete m_options.state; + delete m_options.layer; + delete m_options.name; + + /** + * Clean up any resources that the annotation is using. + */ + this._exit = function () { + }; + + /** + * Get a unique annotation id. + * + * @returns {number} the annotation id. + */ + this.id = function () { + return m_id; + }; + + /** + * Get or set the name of this annotation. + * + * @param {string|undefined} arg if undefined, return the name, otherwise + * change it. + * @returns {this|string} the current name or this annotation. + */ + this.name = function (arg) { + if (arg === undefined) { + return m_name; + } + if (arg !== null && ('' + arg).trim()) { + m_name = ('' + arg).trim(); + } + return this; + }; + + /** + * Get or set the annotation layer associated with this annotation. + * + * @param {geo.annotationLayer|undefined} arg if undefined, return the layer, + * otherwise change it. + * @returns {this|geo.annotationLayer} the current layer or this annotation. + */ + this.layer = function (arg) { + if (arg === undefined) { + return m_layer; + } + m_layer = arg; + return this; + }; + + /** + * Get or set the state of this annotation. + * + * @param {string|undefined} arg if undefined, return the state, otherwise + * change it. + * @returns {this|string} the current state or this annotation. + */ + this.state = function (arg) { + if (arg === undefined) { + return m_state; + } + if (m_state !== arg) { + m_state = arg; + if (this.layer()) { + this.layer().geoTrigger(geo_event.annotation.state, { + annotation: this + }); + } + } + return this; + }; + + /** + * Set or get options. + * + * @param {string|object} arg1 if undefined, return the options object. If + * a string, either set or return the option of that name. If an object, + * update the options with the object's values. + * @param {object} arg2 if arg1 is a string and this is defined, set the + * option to this value. + * @returns {object|this} if options are set, return the layer, otherwise + * return the requested option or the set of options. + */ + this.options = function (arg1, arg2) { + if (arg1 === undefined) { + return m_options; + } + if (typeof arg1 === 'string' && arg2 === undefined) { + return m_options[arg1]; + } + if (arg2 === undefined) { + m_options = $.extend(true, m_options, arg1); + } else { + m_options[arg1] = arg2; + } + this.modified(); + return this; + }; + + /** + * Get the type of this annotation. + * + * @returns {string} the annotation type. + */ + this.type = function () { + return m_type; + }; + + /** + * Get a list of renderable features for this annotation. The list index is + * functionally a z-index for the feature. Each entry is a dictionary with + * the key as the feature name (such as line, quad, or polygon), and the + * value a dictionary of values to pass to the feature constructor, such as + * style and coordinates. + * + * @returns {array} an array of features. + */ + this.features = function () { + return []; + }; + + /** + * Handle a mouse click on this annotation. If the event is processed, + * evt.handled should be set to true to prevent further processing. + * + * @param {geo.event} evt the mouse click event. + * @returns {boolean|string} true to update the annotation, 'done' if the + * annotation was completed (changed from create to done state), 'remove' + * if the annotation should be removed, falsy to not update anything. + */ + this.mouseClick = function (evt) { + }; + + /** + * Handle a mouse move on this annotation. + * + * @param {geo.event} evt the mouse move event. + * @returns {boolean|string} true to update the annotation, falsy to not + * update anything. + */ + this.mouseMove = function (evt) { + }; + + /** + * Get coordinates associated with this annotation in the map gcs coordinate + * system. + * + * @returns {array} an array of coordinates. + */ + this._coordinates = function () { + return []; + }; + + /** + * Get coordinates associated with this annotation. + * + * @param {string|geo.transform} [gcs] undefined to use the interface gcs, + * null to use the map gcs, or any other transform. + * @returns {array} an array of coordinates. + */ + this.coordinates = function (gcs) { + var coord = this._coordinates(); + if (this.layer()) { + var map = this.layer().map(); + gcs = (gcs === null ? map.gcs() : ( + gcs === undefined ? map.ingcs() : gcs)); + if (gcs !== map.gcs()) { + coord = transform.transformCoordinates(map.gcs(), gcs, coord); + } + return coord; + } + }; + + /** + * Mark this annotation as modified. This just marks the parent layer as + * modified. + */ + this.modified = function () { + if (this.layer()) { + this.layer().modified(); + } + return this; + }; + + /** + * Draw this annotation. This just updates and draws the parent layer. + */ + this.draw = function () { + if (this.layer()) { + this.layer()._update(); + this.layer().draw(); + } + return this; + }; + + /** + * TODO: return the annotation as a geojson object + */ + this.geojson = function () { + return 'not implemented'; + }; +}; + +///////////////////////////////////////////////////////////////////////////// +/** + * Rectangle annotation class + * + * Rectangles are always rendered as polygons. This could be changed -- if no + * stroke is specified, the quad feature would be sufficient and work on more + * renderers. + * + * Must specify: + * corners: a list of four corners {x: x, y: y} in map gcs coordinates. + * May specify: + * style. + * fill, fillColor, fillOpacity, stroke, strokeWidth, strokeColor, + * strokeOpacity + */ +///////////////////////////////////////////////////////////////////////////// +var rectangleAnnotation = function (args) { + 'use strict'; + if (!(this instanceof rectangleAnnotation)) { + return new rectangleAnnotation(args); + } + args = $.extend(true, {}, { + style: { + fill: true, + fillColor: {r: 0, g: 1, b: 0}, + fillOpacity: 0.25, + polygon: function (d) { return d.polygon; }, + stroke: true, + strokeColor: {r: 0, g: 0, b: 0}, + strokeOpacity: 1, + strokeWidth: 3, + uniformPolygon: true + } + }, args || {}); + annotation.call(this, 'rectangle', args); + + /** + * Get a list of renderable features for this annotation. + * + * @returns {array} an array of features. + */ + this.features = function () { + var opt = this.options(); + return [{ + polygon: { + polygon: opt.corners, + style: opt.style + } + }]; + }; + + /** + * Get coordinates associated with this annotation in the map gcs coordinate + * system. + * + * @returns {array} an array of coordinates. + */ + this._coordinates = function () { + return this.options('corners'); + }; +}; +inherit(rectangleAnnotation, annotation); + +///////////////////////////////////////////////////////////////////////////// +/** + * Polygon annotation class + * + * When complete, polygons are rendered as polygons. During creation they are + * rendered as lines and polygons. + * + * Must specify: + * vertices: a list of vertices {x: x, y: y} in map gcs coordinates. + * May specify: + * style. + * fill, fillColor, fillOpacity, stroke, strokeWidth, strokeColor, + * strokeOpacity + * editstyle. + * fill, fillColor, fillOpacity, stroke, strokeWidth, strokeColor, + * strokeOpacity + */ +///////////////////////////////////////////////////////////////////////////// +var polygonAnnotation = function (args) { + 'use strict'; + if (!(this instanceof polygonAnnotation)) { + return new polygonAnnotation(args); + } + + var m_this = this; + + args = $.extend(true, {}, { + vertices: [], + style: { + fill: true, + fillColor: {r: 0, g: 1, b: 0}, + fillOpacity: 0.25, + polygon: function (d) { return d.polygon; }, + stroke: true, + strokeColor: {r: 0, g: 0, b: 0}, + strokeOpacity: 1, + strokeWidth: 3, + uniformPolygon: true + }, + editstyle: { + closed: false, + fill: true, + fillColor: {r: 0.3, g: 0.3, b: 0.3}, + fillOpacity: 0.25, + line: function (d) { + /* Return an array that has the same number of items as we have + * vertices. */ + 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 + } + }, args || {}); + annotation.call(this, 'polygon', args); + + /** + * Get a list of renderable features for this annotation. When the polygon + * is done, this is just a single polygon. During creation this can be a + * polygon and line at z-levels 1 and 2. + * + * @returns {array} an array of features. + */ + this.features = function () { + var opt = this.options(), + state = this.state(), + features; + switch (state) { + case annotationState.create: + features = []; + if (opt.vertices && opt.vertices.length >= 3) { + features[1] = { + polygon: { + polygon: opt.vertices, + style: opt.editstyle + } + }; + } + if (opt.vertices && opt.vertices.length >= 2) { + features[2] = { + line: { + line: opt.vertices, + style: opt.editstyle + } + }; + } + break; + default: + features = [{ + polygon: { + polygon: opt.vertices, + style: opt.style + } + }]; + break; + } + return features; + }; + + /** + * Get coordinates associated with this annotation in the map gcs coordinate + * system. + * + * @returns {array} an array of coordinates. + */ + this._coordinates = function () { + return this.options('vertices'); + }; + + /** + * Handle a mouse move on this annotation. + * + * @param {geo.event} evt the mouse move event. + * @returns {boolean|string} true to update the annotation, falsy to not + * update anything. + */ + this.mouseMove = function (evt) { + if (this.state() !== annotationState.create) { + return; + } + var vertices = this.options('vertices'); + if (vertices.length) { + vertices[vertices.length - 1] = evt.mapgcs; + return true; + } + }; + + /** + * Handle a mouse click on this annotation. If the event is processed, + * evt.handled should be set to true to prevent further processing. + * + * @param {geo.event} evt the mouse click event. + * @returns {boolean|string} true to update the annotation, 'done' if the + * annotation was completed (changed from create to done state), 'remove' + * if the annotation should be removed, falsy to not update anything. + */ + this.mouseClick = function (evt) { + var layer = this.layer(); + if (this.state() !== annotationState.create || !layer) { + return; + } + var end = !!evt.buttonsDown.right, skip; + if (!evt.buttonsDown.left && !evt.buttonsDown.right) { + return; + } + var vertices = this.options('vertices'); + if (evt.buttonsDown.right && !vertices.length) { + return; + } + evt.handled = true; + if (evt.buttonsDown.left) { + if (vertices.length) { + if (vertices.length >= 2 && layer.displayDistance( + vertices[vertices.length - 2], null, evt.map, 'display') <= + layer.options('adjacentPointProximity')) { + skip = true; + if (this.lastClick && + evt.time - this.lastClick < layer.options('dblClickTime')) { + end = true; + } + } else if (vertices.length >= 2 && layer.displayDistance( + vertices[0], null, evt.map, 'display') <= + layer.options('finalPointProximity')) { + end = true; + } else { + vertices[vertices.length - 1] = evt.mapgcs; + } + } else { + vertices.push(evt.mapgcs); + } + if (!end && !skip) { + vertices.push(evt.mapgcs); + } + this.lastClick = evt.time; + } + if (end) { + if (vertices.length < 4) { + return 'remove'; + } + vertices.pop(); + this.state(annotationState.done); + return 'done'; + } + return (end || !skip); + }; +}; +inherit(polygonAnnotation, annotation); + +///////////////////////////////////////////////////////////////////////////// +/** + * Point annotation class + * + * Must specify: + * position: {x: x, y: y} in map gcs coordinates. + * May specify: + * style. + * radius, fill, fillColor, fillOpacity, stroke, strokeWidth, strokeColor, + * strokeOpacity + */ +///////////////////////////////////////////////////////////////////////////// +var pointAnnotation = function (args) { + 'use strict'; + if (!(this instanceof pointAnnotation)) { + return new pointAnnotation(args); + } + args = $.extend(true, {}, { + style: { + fill: true, + fillColor: {r: 0, g: 1, b: 0}, + fillOpacity: 0.25, + radius: 10, + stroke: true, + strokeColor: {r: 0, g: 0, b: 0}, + strokeOpacity: 1, + strokeWidth: 3 + } + }, args || {}); + annotation.call(this, 'point', args); + + /** + * Get a list of renderable features for this annotation. + * + * @returns {array} an array of features. + */ + this.features = function () { + var opt = this.options(), + state = this.state(), + features; + switch (state) { + case annotationState.create: + features = []; + break; + default: + features = [{ + point: { + x: opt.position.x, + y: opt.position.y, + style: opt.style + } + }]; + break; + } + return features; + }; + + /** + * Get coordinates associated with this annotation in the map gcs coordinate + * system. + * + * @returns {array} an array of coordinates. + */ + this._coordinates = function () { + if (this.state() === annotationState.create) { + return []; + } + return [this.options('position')]; + }; + + /** + * Handle a mouse click on this annotation. If the event is processed, + * evt.handled should be set to true to prevent further processing. + * + * @param {geo.event} evt the mouse click event. + * @returns {boolean|string} true to update the annotation, 'done' if the + * annotation was completed (changed from create to done state), 'remove' + * if the annotation should be removed, falsy to not update anything. + */ + this.mouseClick = function (evt) { + if (this.state() !== annotationState.create) { + return; + } + if (!evt.buttonsDown.left) { + return; + } + evt.handled = true; + this.options('position', evt.mapgcs); + this.state(annotationState.done); + return 'done'; + }; +}; +inherit(pointAnnotation, annotation); + +module.exports = { + state: annotationState, + annotation: annotation, + pointAnnotation: pointAnnotation, + polygonAnnotation: polygonAnnotation, + rectangleAnnotation: rectangleAnnotation +}; diff --git a/src/annotationLayer.js b/src/annotationLayer.js new file mode 100644 index 0000000000..aabcc7fadf --- /dev/null +++ b/src/annotationLayer.js @@ -0,0 +1,486 @@ +var inherit = require('./inherit'); +var featureLayer = require('./featureLayer'); +var geo_action = require('./action'); +var geo_annotation = require('./annotation'); +var geo_event = require('./event'); +var registry = require('./registry'); +var $ = require('jquery'); +var Mousetrap = require('mousetrap'); + +///////////////////////////////////////////////////////////////////////////// +/** + * Layer to handle direct interactions with different features. Annotations + * (features) can be created by calling mode() or cancelled + * with mode(null). + * + * @class geo.annotationLayer + * @extends geo.featureLayer + * @param {object?} options + * @param {number} [options.dblClickTime=300] The delay in milliseconds that + * is treated as a double-click when working with annotations. + * @param {number} [options.adjacentPointProximity=5] The minimum distance in + * display coordinates (pixels) between two adjacent points when creating a + * polygon. A value of 0 requires an exact match. + * @param {number} [options.finalPointProximity=10] The maximum distance in + * display coordinates (pixels) between the starting point and the mouse + * coordinates to signal closing a polygon. A value of 0 requires an exact + * match. A negative value disables closing a polygon by clicking on the + * start point. + * @returns {geo.annotationLayer} + */ +///////////////////////////////////////////////////////////////////////////// +var annotationLayer = function (args) { + 'use strict'; + if (!(this instanceof annotationLayer)) { + return new annotationLayer(args); + } + featureLayer.call(this, args); + + var mapInteractor = require('./mapInteractor'); + var timestamp = require('./timestamp'); + var util = require('./util'); + + var m_this = this, + s_init = this._init, + s_exit = this._exit, + s_update = this._update, + m_buildTime = timestamp(), + m_options, + m_actions, + m_mode = null, + m_annotations = [], + m_features = []; + + m_options = $.extend(true, {}, { + dblClickTime: 300, + adjacentPointProximity: 5, // in pixels, 0 is exact + finalPointProximity: 10 // in pixels, 0 is exact + }, args); + + m_actions = { + rectangle: { + action: geo_action.annotation_rectangle, + owner: 'annotationLayer', + input: 'left', + modifiers: {shift: false, ctrl: false}, + selectionRectangle: true + } + }; + + /** + * Process a selection event. If we are in rectangle-creation mode, this + * creates a rectangle. + * + * @param {geo.event} evt the selection event. + */ + this._processSelection = function (evt) { + if (m_this.mode() === 'rectangle') { + m_this.mode(null); + if (evt.state.action === geo_action.annotation_rectangle) { + var map = m_this.map(); + var params = { + corners: [ + /* Keep in map gcs, not interface gcs to avoid wrapping issues */ + map.displayToGcs({x: evt.lowerLeft.x, y: evt.lowerLeft.y}, null), + map.displayToGcs({x: evt.lowerLeft.x, y: evt.upperRight.y}, null), + map.displayToGcs({x: evt.upperRight.x, y: evt.upperRight.y}, null), + map.displayToGcs({x: evt.upperRight.x, y: evt.lowerLeft.y}, null) + ], + layer: this + }; + this.addAnnotation(geo_annotation.rectangleAnnotation(params)); + } + } + }; + + /** + * Handle mouse movement. If there is a current annotation, the movement + * event is sent to it. + * + * @param {geo.event} evt the mouse move event. + */ + this._handleMouseMove = function (evt) { + if (this.mode() && this.currentAnnotation) { + var update = this.currentAnnotation.mouseMove(evt); + if (update) { + m_this.modified(); + m_this._update(); + m_this.draw(); + } + } + }; + + /** + * Handle mouse clicks. If there is a current annotation, the click event is + * sent to it. + * + * @param {geo.event} evt the mouse click event. + */ + this._handleMouseClick = function (evt) { + if (this.mode() && this.currentAnnotation) { + var update = this.currentAnnotation.mouseClick(evt); + switch (update) { + case 'remove': + m_this.removeAnnotation(m_this.currentAnnotation, false); + m_this.mode(null); + break; + case 'done': + m_this.mode(null); + break; + } + if (update) { + m_this.modified(); + m_this._update(); + m_this.draw(); + } + } + }; + + /** + * Set or get options. + * + * @param {string|object} arg1 if undefined, return the options object. If + * a string, either set or return the option of that name. If an object, + * update the options with the object's values. + * @param {object} arg2 if arg1 is a string and this is defined, set the + * option to this value. + * @returns {object|this} if options are set, return the layer, otherwise + * return the requested option or the set of options. + */ + this.options = function (arg1, arg2) { + if (arg1 === undefined) { + return m_options; + } + if (typeof arg1 === 'string' && arg2 === undefined) { + return m_options[arg1]; + } + if (arg2 === undefined) { + m_options = $.extend(true, m_options, arg1); + } else { + m_options[arg1] = arg2; + } + this.modified(); + return this; + }; + + /** + * Calculate the display distance for two coordinate in the current map. + * + * @param {object} coord1 the first coordinates. + * @param {string|geo.transform} [gcs1] undefined to use the interface gcs, + * null to use the map gcs, 'display' if the coordinates are already in + * display coordinates, or any other transform. + * @param {object} coord2 the second coordinates. + * @param {string|geo.transform} [gcs2] undefined to use the interface gcs, + * null to use the map gcs, 'display' if the coordinates are already in + * display coordinates, or any other transform. + * @returns {number} the Euclidian distance between the two coordinates. + */ + this.displayDistance = function (coord1, gcs1, coord2, gcs2) { + var map = this.map(); + if (gcs1 !== 'display') { + gcs1 = (gcs1 === null ? map.gcs() : ( + gcs1 === undefined ? map.ingcs() : gcs1)); + coord1 = map.gcsToDisplay(coord1, gcs1); + } + if (gcs2 !== 'display') { + gcs2 = (gcs2 === null ? map.gcs() : ( + gcs2 === undefined ? map.ingcs() : gcs2)); + coord2 = map.gcsToDisplay(coord2, gcs2); + } + var dist = Math.sqrt(Math.pow(coord1.x - coord2.x, 2) + + Math.pow(coord1.y - coord2.y, 2)); + return dist; + }; + + /** + * Add an annotation to the layer. The annotation could be in any state. + * + * @param {object} annotation the annotation to add. + */ + this.addAnnotation = function (annotation) { + var pos = $.inArray(annotation, m_annotations); + if (pos < 0) { + m_this.geoTrigger(geo_event.annotation.add_before, { + annotation: annotation + }); + m_annotations.push(annotation); + this.modified(); + this._update(); + this.draw(); + m_this.geoTrigger(geo_event.annotation.add, { + annotation: annotation + }); + } + return this; + }; + + /** + * Remove an annotation from the layer. + * + * @param {object} annotation the annotation to remove. + * @param {boolean} update if false, don't update the layer after removing + * the annotation. + * @returns {boolean} true if an annotation was removed. + */ + this.removeAnnotation = function (annotation, update) { + var pos = $.inArray(annotation, m_annotations); + if (pos >= 0) { + if (annotation === this.currentAnnotation) { + this.currentAnnotation = null; + } + annotation._exit(); + m_annotations.splice(pos, 1); + if (update !== false) { + this.modified(); + this._update(); + this.draw(); + } + m_this.geoTrigger(geo_event.annotation.remove, { + annotation: annotation + }); + } + return pos >= 0; + }; + + /** + * Remove all annotations from the layer. + * + * @param {boolean} skipCreating: if true, don't remove annotations that are + * in the create state. + * @returns {number} the number of annotations that were removed. + */ + this.removeAllAnnotations = function (skipCreating) { + var removed = 0, annotation, pos = 0; + while (pos < m_annotations.length) { + annotation = m_annotations[pos]; + if (skipCreating && annotation.state() === geo_annotation.state.create) { + pos += 1; + continue; + } + this.removeAnnotation(annotation, false); + removed += 1; + } + if (removed) { + this.modified(); + this._update(); + this.draw(); + } + return removed; + }; + + /** + * Get the list of annotations on the layer. + * + * @returns {array} An array of annotations. + */ + this.annotations = function () { + return m_annotations.slice(); + }; + + /** + * Get an annotation by its id. + * + * @returns {geo.annotation} The selected annotation or undefined. + */ + this.annotationById = function (id) { + if (id !== undefined && id !== null) { + id = +id; /* Cast to int */ + } + var annotations = m_annotations.filter(function (annotation) { + return annotation.id() === id; + }); + if (annotations.length) { + return annotations[0]; + } + }; + + /** + * Get or set the current mode. The mode is either null for nothing being + * created, or the name of the type of annotation that is being created. + * + * @param {string|null} arg the new mode or undefined to get the current + * mode. + * @returns {string|null|this} The current mode or the layer. + */ + this.mode = function (arg) { + if (arg === undefined) { + return m_mode; + } + if (arg !== m_mode) { + var createAnnotation, mapNode = m_this.map().node(), oldMode = m_mode; + m_mode = arg; + mapNode.css('cursor', m_mode ? 'crosshair' : ''); + if (m_mode) { + Mousetrap(mapNode[0]).bind('esc', function () { m_this.mode(null); }); + } else { + Mousetrap(mapNode[0]).unbind('esc'); + } + if (this.currentAnnotation) { + switch (this.currentAnnotation.state()) { + case geo_annotation.state.create: + this.removeAnnotation(this.currentAnnotation); + break; + } + this.currentAnnotation = null; + } + switch (m_mode) { + case 'point': + createAnnotation = geo_annotation.pointAnnotation; + break; + case 'polygon': + createAnnotation = geo_annotation.polygonAnnotation; + break; + case 'rectangle': + m_this.map().interactor().addAction(m_actions.rectangle); + break; + } + if (createAnnotation) { + this.currentAnnotation = createAnnotation({ + state: geo_annotation.state.create, + layer: this + }); + this.addAnnotation(m_this.currentAnnotation); + } + if (m_mode !== 'rectangle') { + m_this.map().interactor().removeAction(m_actions.rectangle); + } + m_this.geoTrigger(geo_event.annotation.mode, { + mode: m_mode, oldMode: oldMode}); + } + return this; + }; + + /////////////////////////////////////////////////////////////////////////// + /** + * Update layer + */ + /////////////////////////////////////////////////////////////////////////// + this._update = function (request) { + if (m_this.getMTime() > m_buildTime.getMTime()) { + /* 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 + * annotation. Eventually, it may be necessary to optimize this and + * only update the features that are changed. + */ + $.each(m_features, function (idx, featureLevel) { + $.each(featureLevel, function (type, feature) { + feature.data = []; + }); + }); + $.each(m_annotations, function (annotation_idx, annotation) { + var features = annotation.features(); + $.each(features, function (idx, featureLevel) { + if (m_features[idx] === undefined) { + m_features[idx] = {}; + } + $.each(featureLevel, function (type, featureSpec) { + /* Create features as needed */ + if (!m_features[idx][type]) { + try { + var feature = m_this.createFeature(type, { + gcs: m_this.map().gcs() + }); + } catch (err) { + /* We can't create the desired feature, porbably because of the + * selected renderer. Issue one warning only. */ + var key = 'error_feature_' + type; + if (!m_this[key]) { + console.warn('Cannot create a ' + type + ' feature for ' + + 'annotations.'); + m_this[key] = true; + } + return; + } + /* 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 + * restricted to {r, g, b} objects for efficiency, but this + * hasn't been tested. + */ + var style = {}; + $.each(['fill', 'fillColor', 'fillOpacity', 'line', 'polygon', + 'position', 'radius', 'stroke', 'strokeColor', + 'strokeOpacity', 'strokeWidth', 'uniformPolygon' + ], function (keyidx, key) { + var origFunc; + if (feature.style()[key] !== undefined) { + origFunc = feature.style.get(key); + } + style[key] = function (d, i, d2, i2) { + var style = ( + (d && d.style) ? d.style : (d && d[2] && d[2].style) ? + d[2].style : d2.style); + var result = style ? style[key] : d; + if (util.isFunction(result)) { + result = result(d, i, d2, i2); + } + if (result === undefined && origFunc) { + result = origFunc(d, i, d2, i2); + } + return result; + }; + }); + feature.style(style); + m_features[idx][type] = { + feature: feature, + style: style, + data: [] + }; + } + /* Collect the data for each feature */ + m_features[idx][type].data.push(featureSpec.data || featureSpec); + }); + }); + }); + /* Update the data for each feature */ + $.each(m_features, function (idx, featureLevel) { + $.each(featureLevel, function (type, feature) { + feature.feature.data(feature.data); + }); + }); + m_buildTime.modified(); + } + s_update.call(m_this, request); + }; + + /////////////////////////////////////////////////////////////////////////// + /** + * Initialize + */ + /////////////////////////////////////////////////////////////////////////// + this._init = function () { + /// Call super class init + s_init.call(m_this); + + if (!m_this.map().interactor()) { + m_this.map().interactor(mapInteractor({actions: []})); + } + m_this.geoOn(geo_event.actionselection, m_this._processSelection); + + m_this.geoOn(geo_event.mouseclick, m_this._handleMouseClick); + m_this.geoOn(geo_event.mousemove, m_this._handleMouseMove); + + return m_this; + }; + + /////////////////////////////////////////////////////////////////////////// + /** + * Free all resources + */ + /////////////////////////////////////////////////////////////////////////// + this._exit = function () { + /// Call super class exit + s_exit.call(m_this); + m_annotations = []; + m_features = []; + return m_this; + }; + + return m_this; +}; + +inherit(annotationLayer, featureLayer); +registry.registerLayer('annotation', annotationLayer); +module.exports = annotationLayer; diff --git a/src/event.js b/src/event.js index 85e49ea156..d9f323c092 100644 --- a/src/event.js +++ b/src/event.js @@ -428,4 +428,57 @@ geo_event.camera.projection = 'geo_camera_projection'; ////////////////////////////////////////////////////////////////////////////// geo_event.camera.viewport = 'geo_camera_viewport'; +//////////////////////////////////////////////////////////////////////////// +/** + * These events are triggered by the annotation layer. + * @namespace geo.event.annotation + */ +//////////////////////////////////////////////////////////////////////////// +geo_event.annotation = {}; + +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered when an annotation has been added. + * + * @property {geo.annotation} annotation The annotation that was added. + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.annotation.add = 'geo_annotation_add'; + +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered when an annotation is about to be added. + * + * @property {geo.annotation} annotation The annotation that will be added. + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.annotation.add_before = 'geo_annotation_add_before'; + +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered when an annotation has been removed. + * + * @property {geo.annotation} annotation The annotation that was removed. + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.annotation.remove = 'geo_annotation_remove'; + +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered when an annotation's state changes. + * + * @property {geo.annotation} annotation The annotation that changed/ + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.annotation.state = 'geo_annotation_state'; + +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered when the annotation mode is changed. + * + * @property {string|null} mode the new annotation mode. + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.annotation.mode = 'geo_annotation_mode'; + module.exports = geo_event; diff --git a/src/feature.js b/src/feature.js index 39cbe275f4..cae1b1077b 100644 --- a/src/feature.js +++ b/src/feature.js @@ -380,7 +380,7 @@ var feature = function (arg) { */ this.featureGcsToDisplay = function (c) { var map = m_renderer.layer().map(); - c = map.gcsToWorld(c, map.ingcs()); + c = map.gcsToWorld(c, m_this.gcs()); c = map.worldToDisplay(c); if (m_renderer.baseToLocal) { c = m_renderer.baseToLocal(c); diff --git a/src/gl/lineFeature.js b/src/gl/lineFeature.js index 89bbf969d6..4ad76cbac0 100644 --- a/src/gl/lineFeature.js +++ b/src/gl/lineFeature.js @@ -146,6 +146,9 @@ var gl_lineFeature = function (arg) { for (i = 0; i < data.length; i += 1) { lineItem = m_this.line()(data[i], i); + if (lineItem.length < 2) { + continue; + } numSegments += lineItem.length - 1; for (j = 0; j < lineItem.length; j += 1) { pos = posFunc(lineItem[j], j, lineItem, i); @@ -188,6 +191,9 @@ var gl_lineFeature = function (arg) { for (i = posIdx3 = dest = dest3 = 0; i < data.length; i += 1) { lineItem = m_this.line()(data[i], i); + if (lineItem.length < 2) { + continue; + } firstPosIdx3 = posIdx3; for (j = 0; j < lineItem.length + (closed[i] === 2 ? 1 : 0); j += 1, posIdx3 += 3) { lidx = j; diff --git a/src/gl/polygonFeature.js b/src/gl/polygonFeature.js index 21f77810e1..443827e3c7 100644 --- a/src/gl/polygonFeature.js +++ b/src/gl/polygonFeature.js @@ -98,7 +98,7 @@ var gl_polygonFeature = function (arg) { * recalculate the style. */ function createGLPolygons(onlyStyle) { - var posBuf, posFunc, + var posBuf, posFunc, polyFunc, fillColor, fillColorFunc, fillColorVal, fillOpacity, fillOpacityFunc, fillOpacityVal, fillFunc, fillVal, @@ -121,11 +121,15 @@ var gl_polygonFeature = function (arg) { uniformPolyFunc = m_this.style.get('uniformPolygon'); if (!onlyStyle) { - posFunc = m_this.position(); + posFunc = m_this.style.get('position'); + polyFunc = m_this.style.get('polygon'); m_this.data().forEach(function (item, itemIndex) { var polygon, outer, geometry, c; - polygon = m_this.polygon()(item, itemIndex); + polygon = polyFunc(item, itemIndex); + if (!polygon) { + return; + } outer = polygon.outer || (polygon instanceof Array ? polygon : []); /* expand to an earcut polygon geometry. We had been using a map call, @@ -170,8 +174,10 @@ var gl_polygonFeature = function (arg) { item: item, itemIndex: itemIndex }; - items.push(record); - numPts += record.triangles.length; + if (record.triangles.length) { + items.push(record); + numPts += record.triangles.length; + } }); posBuf = util.getGeomBuffer(geom, 'pos', numPts * 3); indices = geom.primitive(0).indices(); diff --git a/src/index.js b/src/index.js index ba776d58d8..dd7359547f 100644 --- a/src/index.js +++ b/src/index.js @@ -31,6 +31,8 @@ var $ = require('jquery'); require('./polyfills'); module.exports = $.extend({ + annotation: require('./annotation'), + annotationLayer: require('./annotationLayer'), camera: require('./camera'), choroplethFeature: require('./choroplethFeature'), clock: require('./clock'), diff --git a/src/map.js b/src/map.js index 520d92c2bb..b11ec40da9 100644 --- a/src/map.js +++ b/src/map.js @@ -675,15 +675,15 @@ var map = function (arg) { var oldCenter = m_this.center(); m_x = x; m_y = y; - m_width = w; - m_height = h; + m_width = w || m_width; + m_height = h || m_height; reset_minimum_zoom(); var newZoom = fix_zoom(m_zoom); if (newZoom !== m_zoom) { m_this.zoom(newZoom); } - m_this.camera().viewport = {width: w, height: h}; + m_this.camera().viewport = {width: m_width, height: m_height}; m_this.center(oldCenter); m_this.geoTrigger(geo_event.resize, { @@ -691,8 +691,8 @@ var map = function (arg) { target: m_this, x: m_x, y: m_y, - width: w, - height: h + width: m_width, + height: m_height }); m_this.modified(); @@ -972,6 +972,10 @@ var map = function (arg) { // this makes it possible to set a null interactor // i.e. map.interactor(null); if (m_interactor) { + /* If we set a map interactor, make sure we have a tabindex */ + if (!m_node.attr('tabindex')) { + m_node.attr('tabindex', 0); + } m_interactor.map(m_this); } return m_this; @@ -1902,7 +1906,9 @@ var map = function (arg) { // Now update to the correct center and zoom level this.center($.extend({}, arg.center || m_center), undefined); - this.interactor(arg.interactor || mapInteractor({discreteZoom: m_discreteZoom})); + if (arg.interactor !== null) { + this.interactor(arg.interactor || mapInteractor({discreteZoom: m_discreteZoom})); + } this.clock(arg.clock || clock()); function resizeSelf() { diff --git a/src/mapInteractor.js b/src/mapInteractor.js index d6c1a06a80..ae4803ac52 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -39,6 +39,8 @@ var mapInteractor = function (args) { m_selectionLayer = null, m_selectionQuad, m_paused = false, + // if m_clickMaybe is not false, it contains the x, y, and buttons that + // were present when the mouse down event occurred. m_clickMaybe = false, m_clickMaybeTimeout, m_callZoom = function () {}; @@ -652,7 +654,11 @@ var mapInteractor = function (args) { (!m_mouse.buttons.left || m_options.click.buttons.left) && (!m_mouse.buttons.right || m_options.click.buttons.right) && (!m_mouse.buttons.middle || m_options.click.buttons.middle)) { - m_this._setClickMaybe({x: m_mouse.page.x, y: m_mouse.page.y}); + m_this._setClickMaybe({ + x: m_mouse.page.x, + y: m_mouse.page.y, + buttons: $.extend({}, m_mouse.buttons) + }); if (m_options.click.duration > 0) { m_clickMaybeTimeout = window.setTimeout(function () { m_clickMaybe = false; @@ -1057,12 +1063,12 @@ var mapInteractor = function (args) { */ //////////////////////////////////////////////////////////////////////////// this._handleMouseUp = function (evt) { - if (m_paused) { return; } m_this._getMouseButton(evt); + if (m_clickMaybe) { m_this._handleMouseClick(evt); } @@ -1078,6 +1084,14 @@ var mapInteractor = function (args) { //////////////////////////////////////////////////////////////////////////// this._handleMouseClick = function (evt) { + /* Cancel a selection if it is occurring */ + if (m_state.actionRecord && m_state.actionRecord.selectionRectangle) { + m_selectionLayer.clear(); + m_this.map().deleteLayer(m_selectionLayer); + m_selectionLayer = null; + m_selectionQuad = null; + m_state.action = m_state.actionRecord = null; + } m_this._getMouseButton(evt); m_this._getMouseModifiers(evt); @@ -1087,12 +1101,14 @@ var mapInteractor = function (args) { // unbind temporary handlers on document $(document).off('.geojs'); m_state.boundDocumentHandlers = false; + // add information about the button state to the event information + var details = m_this.mouse(); + details.buttonsDown = m_clickMaybe.buttons; // reset click detector variable m_this._setClickMaybe(false); - // fire a click event - m_this.map().geoTrigger(geo_event.mouseclick, m_this.mouse()); + m_this.map().geoTrigger(geo_event.mouseclick, details); }; //////////////////////////////////////////////////////////////////////////// @@ -1588,10 +1604,10 @@ var mapInteractor = function (args) { } } ); + $node.trigger(evt); if (type.indexOf('.geojs') >= 0) { $(document).trigger(evt); } - $node.trigger(evt); }; this._connectEvents(); return this; diff --git a/src/polygonFeature.js b/src/polygonFeature.js index a964429a6c..c89f3aa630 100644 --- a/src/polygonFeature.js +++ b/src/polygonFeature.js @@ -34,6 +34,8 @@ var feature = require('./feature'); * values, or an object with r, g, b on a [0-1] scale. * @param {number|Function} [arg.style.strokeOpacity] Opacity for each polygon * stroke. The opacity can vary by vertex. Opacity is on a [0-1] scale. + * @param {number|Function} [arg.style.strokeWidth] The weight of the polygon + * stroke in pixels. The width can vary by vertex. * @param {boolean|Function} [arg.style.uniformPolygon] Boolean indicating if * each polygon has a uniform style (uniform fill color, fill opacity, stroke * color, and stroke opacity). Defaults to false. Can vary by polygon. @@ -56,8 +58,6 @@ var polygonFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// var m_this = this, - m_position, - m_polygon, m_lineFeature, s_init = this._init, s_exit = this._exit, @@ -67,22 +67,6 @@ var polygonFeature = function (arg) { s_style = this.style, m_coordinates = []; - if (arg.polygon === undefined) { - m_polygon = function (d) { - return d; - }; - } else { - m_polygon = arg.polygon; - } - - if (arg.position === undefined) { - m_position = function (d) { - return d; - }; - } else { - m_position = arg.position; - } - //////////////////////////////////////////////////////////////////////////// /** * Get/set data. @@ -97,6 +81,7 @@ var polygonFeature = function (arg) { var ret = s_data(arg); if (arg !== undefined) { getCoordinates(); + this._checkForStroke(); } return ret; }; @@ -112,10 +97,13 @@ var polygonFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// function getCoordinates() { - var posFunc = m_this.position(), - polyFunc = m_this.polygon(); + var posFunc = m_this.style.get('position'), + polyFunc = m_this.style.get('polygon'); m_coordinates = m_this.data().map(function (d, i) { var poly = polyFunc(d); + if (!poly) { + return; + } var outer, inner, range, coord, j, x, y; coord = poly.outer || (poly instanceof Array ? poly : []); @@ -161,9 +149,9 @@ var polygonFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// this.polygon = function (val) { if (val === undefined) { - return m_polygon; + return m_this.style('polygon'); } else { - m_polygon = val; + m_this.style('polygon', val); m_this.dataTime().modified(); m_this.modified(); getCoordinates(); @@ -184,9 +172,9 @@ var polygonFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// this.position = function (val) { if (val === undefined) { - return m_position; + return m_this.style('position'); } else { - m_position = val; + m_this.style('position', val); m_this.dataTime().modified(); m_this.modified(); getCoordinates(); @@ -278,7 +266,7 @@ var polygonFeature = function (arg) { } if (!m_lineFeature) { m_lineFeature = m_this.layer().createFeature( - 'line', {selectionAPI: false}); + 'line', {selectionAPI: false, gcs: this.gcs()}); } var polyStyle = m_this.style(); m_lineFeature.style({ @@ -291,12 +279,16 @@ var polygonFeature = function (arg) { } }); var data = this.data(), - posFunc = this.position(); + posFunc = this.style.get('position'), + polyFunc = this.style.get('polygon'); if (data !== m_lineFeature._lastData || posFunc !== m_lineFeature._posFunc) { var lineData = [], i, polygon, loop; for (i = 0; i < data.length; i += 1) { - polygon = m_this.polygon()(data[i], i); + polygon = polyFunc(data[i], i); + if (!polygon) { + continue; + } loop = polygon.outer || (polygon instanceof Array ? polygon : []); lineData.push(m_this._getLoopData(data[i], i, loop)); if (polygon.inner) { @@ -365,9 +357,10 @@ var polygonFeature = function (arg) { arg = arg || {}; s_init.call(m_this, arg); - var defaultStyle = $.extend( + var style = $.extend( {}, { + // default style fill: true, fillColor: {r: 0.0, g: 0.5, b: 0.5}, fillOpacity: 1.0, @@ -375,16 +368,21 @@ var polygonFeature = function (arg) { strokeWidth: 1.0, strokeStyle: 'solid', strokeColor: {r: 0.0, g: 1.0, b: 1.0}, - strokeOpacity: 1.0 + strokeOpacity: 1.0, + polygon: function (d) { return d; }, + position: function (d) { return d; } }, arg.style === undefined ? {} : arg.style ); - m_this.style(defaultStyle); - - if (m_position) { - m_this.dataTime().modified(); + if (arg.polygon !== undefined) { + style.polygon = arg.polygon; + } + if (arg.position !== undefined) { + style.position = arg.position; } + m_this.style(style); + this._checkForStroke(); }; diff --git a/src/util/init.js b/src/util/init.js index c5aad2a650..2cfd1b9591 100644 --- a/src/util/init.js +++ b/src/util/init.js @@ -121,7 +121,13 @@ if (geo.util.cssColors.hasOwnProperty(color)) { color = geo.util.cssColors[color]; } else if (color.charAt(0) === '#') { - color = parseInt(color.slice(1), 16); + if (color.length === 4) { + /* interpret values of the form #rgb as #rrggbb */ + color = parseInt(color.slice(1), 16); + color = (color & 0xf00) * 0x1100 + (color & 0xf0) * 0x110 + (color & 0xf) * 0x11; + } else { + color = parseInt(color.slice(1), 16); + } } } if (isFinite(color)) { diff --git a/tests/cases/annotation.js b/tests/cases/annotation.js new file mode 100644 index 0000000000..d040a6e742 --- /dev/null +++ b/tests/cases/annotation.js @@ -0,0 +1,306 @@ +/* Test geo.annotation */ + +describe('geo.annotation', function () { + 'use strict'; + + var $ = require('jquery'); + var geo = require('../test-utils').geo; + var mockVGLRenderer = require('../test-utils').mockVGLRenderer; + var restoreVGLRenderer = require('../test-utils').restoreVGLRenderer; + + beforeEach(function () { + mockVGLRenderer(); + }); + + afterEach(function () { + restoreVGLRenderer(); + }); + + function create_map(opts) { + var node = $('
').css({width: '640px', height: '360px'}); + $('#map').remove(); + $('body').append(node); + opts = $.extend({}, opts); + opts.node = node; + return geo.map(opts); + } + + describe('geo.annotation.annotation', function () { + var map, layer, stateEvent = 0, lastStateEvent; + it('create', function () { + var ann = geo.annotation.annotation('test'); + expect(ann instanceof geo.annotation.annotation); + /* test defaults from various functions */ + expect(ann.type()).toBe('test'); + expect(ann.state()).toBe(geo.annotation.state.done); + expect(ann.id()).toBeGreaterThan(0); + expect(ann.name()).toBe('Test ' + ann.id()); + expect(ann.layer()).toBe(undefined); + expect(ann.features()).toEqual([]); + expect(ann.coordinates()).toBe(undefined); + expect(ann.mouseClick()).toBe(undefined); + expect(ann.mouseMove()).toBe(undefined); + expect(ann._coordinates()).toEqual([]); + expect(ann.geojson()).toBe('not implemented'); + map = create_map(); + layer = map.createLayer('annotation', { + features: ['polygon', 'line', 'point'] + }); + ann = geo.annotation.annotation('test2', { + layer: layer, + name: 'Annotation', + state: geo.annotation.state.create + }); + expect(ann.type()).toBe('test2'); + expect(ann.state()).toBe(geo.annotation.state.create); + expect(ann.id()).toBeGreaterThan(0); + expect(ann.name()).toBe('Annotation'); + expect(ann.layer()).toBe(layer); + expect(ann.coordinates()).toEqual([]); + }); + it('_exit', function () { + var ann = geo.annotation.annotation('test'); + expect(ann._exit()).toBe(undefined); + }); + it('name', function () { + var ann = geo.annotation.annotation('test'); + expect(ann.name()).toBe('Test ' + ann.id()); + expect(ann.name('New Name')).toBe(ann); + expect(ann.name()).toBe('New Name'); + expect(ann.name('')).toBe(ann); + expect(ann.name()).toBe('New Name'); + }); + it('layer', function () { + var ann = geo.annotation.annotation('test'); + expect(ann.layer()).toBe(undefined); + expect(ann.layer(layer)).toBe(ann); + expect(ann.layer()).toBe(layer); + }); + it('state', function () { + var ann = geo.annotation.annotation('test', {layer: layer}); + map.geoOn(geo.event.annotation.state, function (evt) { + stateEvent += 1; + lastStateEvent = evt; + }); + expect(ann.state()).toBe(geo.annotation.state.done); + expect(ann.state(geo.annotation.state.create)).toBe(ann); + expect(stateEvent).toBe(1); + expect(lastStateEvent.annotation).toBe(ann); + expect(ann.state()).toBe(geo.annotation.state.create); + expect(ann.state(geo.annotation.state.create)).toBe(ann); + expect(stateEvent).toBe(1); + expect(ann.state(geo.annotation.state.done)).toBe(ann); + expect(stateEvent).toBe(2); + expect(ann.state()).toBe(geo.annotation.state.done); + }); + it('options', function () { + var ann = geo.annotation.annotation('test', {layer: layer, testopt: 30}); + expect(ann.options().testopt).toBe(30); + expect(ann.options('testopt')).toBe(30); + expect(ann.options('testopt', 40)).toBe(ann); + expect(ann.options().testopt).toBe(40); + expect(ann.options({testopt: 30})).toBe(ann); + expect(ann.options().testopt).toBe(30); + }); + it('coordinates', function () { + var ann = geo.annotation.annotation('test', {layer: layer}); + var coord = [{x: 10, y: 30}, {x: 20, y: 25}]; + ann._coordinates = function () { + return coord; + }; + expect(ann.coordinates().length).toBe(2); + expect(ann.coordinates(null)[0].x).toBeCloseTo(10); + expect(ann.coordinates()[0].x).not.toBeCloseTo(10); + }); + it('modified', function () { + var ann = geo.annotation.annotation('test', {layer: layer}); + var buildTime = layer.getMTime(); + ann.modified(); + expect(layer.getMTime()).toBeGreaterThan(buildTime); + }); + it('draw', function () { + var oldDraw = layer.draw, drawCalled = 0; + layer.draw = function () { + drawCalled += 1; + }; + var ann = geo.annotation.annotation('test', {layer: layer}); + ann.draw(); + expect(drawCalled).toBe(1); + layer.draw = oldDraw; + }); + }); + + describe('geo.annotation.rectangleAnnotation', function () { + var corners = [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}]; + it('create', function () { + var ann = geo.annotation.rectangleAnnotation(); + expect(ann instanceof geo.annotation.rectangleAnnotation); + expect(ann.type()).toBe('rectangle'); + }); + it('features', function () { + var ann = geo.annotation.rectangleAnnotation({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); + }); + it('_coordinates', function () { + var ann = geo.annotation.rectangleAnnotation({corners: corners}); + expect(ann._coordinates()).toEqual(corners); + }); + }); + + describe('geo.annotation.polygonAnnotation', function () { + var vertices = [{x: 30, y: 0}, {x: 50, y: 0}, {x: 40, y: 20}, {x: 30, y: 10}]; + it('create', function () { + var ann = geo.annotation.polygonAnnotation(); + expect(ann instanceof geo.annotation.polygonAnnotation); + expect(ann.type()).toBe('polygon'); + }); + it('features', function () { + var ann = geo.annotation.polygonAnnotation({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.create); + features = ann.features(); + expect(features.length).toBe(3); + expect(features[0]).toBe(undefined); + expect(features[1].polygon.polygon).toEqual(vertices); + expect(features[1].polygon.style.fillOpacity).toBe(0.25); + expect(features[1].polygon.style.fillColor.g).toBe(0.3); + expect(features[1].polygon.style.polygon({polygon: 'a'})).toBe('a'); + expect(features[2].line.line).toEqual(vertices); + expect(features[2].line.style.fillOpacity).toBe(0.25); + expect(features[2].line.style.fillColor.g).toBe(0.3); + ann.options('vertices', [{x: 3, y: 0}, {x: 5, y: 0}]); + features = ann.features(); + expect(features.length).toBe(3); + expect(features[0]).toBe(undefined); + expect(features[1]).toBe(undefined); + expect(features[2].line.line.length).toBe(2); + }); + it('_coordinates', function () { + var ann = geo.annotation.polygonAnnotation({vertices: vertices}); + expect(ann._coordinates()).toEqual(vertices); + }); + it('mouseMove', function () { + var ann = geo.annotation.polygonAnnotation({vertices: vertices}); + expect(ann.mouseMove({mapgcs: {x: 6, y: 4}})).toBe(undefined); + expect(ann.options('vertices')).toEqual(vertices); + ann.state(geo.annotation.state.create); + expect(ann.mouseMove({mapgcs: {x: 6, y: 4}})).toBe(true); + expect(ann.options('vertices')).not.toEqual(vertices); + }); + it('mouseClick', function () { + var map = create_map(); + var layer = map.createLayer('annotation', { + features: ['polygon', 'line'] + }); + var ann = geo.annotation.polygonAnnotation({layer: layer}); + var time = new Date().getTime(); + expect(ann.mouseClick({ + buttonsDown: {left: true}, + time: time, + map: {x: 10, y: 20}, + mapgcs: map.displayToGcs({x: 10, y: 20}, null) + })).toBe(undefined); + ann.state(geo.annotation.state.create); + expect(ann.mouseClick({ + buttonsDown: {middle: true}, + time: time, + map: vertices[0], + mapgcs: map.displayToGcs(vertices[0], null) + })).toBe(undefined); + expect(ann.options('vertices').length).toBe(0); + expect(ann.mouseClick({ + buttonsDown: {right: true}, + time: time, + map: vertices[0], + mapgcs: map.displayToGcs(vertices[0], null) + })).toBe(undefined); + expect(ann.options('vertices').length).toBe(0); + expect(ann.mouseClick({ + buttonsDown: {left: true}, + time: time, + map: vertices[0], + mapgcs: map.displayToGcs(vertices[0], null) + })).toBe(true); + expect(ann.options('vertices').length).toBe(2); + ann.mouseClick({ + buttonsDown: {left: true}, + time: time, + map: vertices[1], + mapgcs: map.displayToGcs(vertices[1], null) + }); + expect(ann.options('vertices').length).toBe(3); + ann.mouseClick({ + buttonsDown: {left: true}, + time: time, + map: vertices[2], + mapgcs: map.displayToGcs(vertices[2], null) + }); + expect(ann.options('vertices').length).toBe(4); + expect(ann.mouseClick({ + buttonsDown: {left: true}, + time: time, + map: {x: vertices[0].x + 1, y: vertices[0].y}, + mapgcs: map.displayToGcs({x: vertices[0].x + 1, y: vertices[0].y}, null) + })).toBe('done'); + expect(ann.options('vertices').length).toBe(3); + expect(ann.state()).toBe(geo.annotation.state.done); + }); + }); + + describe('geo.annotation.pointAnnotation', function () { + var point = {x: 30, y: 25}; + + it('create', function () { + var ann = geo.annotation.pointAnnotation(); + expect(ann instanceof geo.annotation.pointAnnotation); + expect(ann.type()).toBe('point'); + }); + it('features', function () { + var ann = geo.annotation.pointAnnotation({position: point}); + var features = ann.features(); + 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.create); + features = ann.features(); + expect(features.length).toBe(0); + }); + it('_coordinates', function () { + var ann = geo.annotation.pointAnnotation({position: point}); + expect(ann._coordinates()).toEqual([point]); + ann.state(geo.annotation.state.create); + expect(ann._coordinates()).toEqual([]); + }); + it('mouseClick', function () { + var ann = geo.annotation.pointAnnotation(); + expect(ann.mouseClick({ + buttonsDown: {left: true}, + map: {x: 10, y: 20}, + mapgcs: {x: 10, y: 20} + })).toBe(undefined); + expect(ann.options('position')).toBe(undefined); + ann.state(geo.annotation.state.create); + expect(ann.mouseClick({ + buttonsDown: {right: true}, + map: {x: 10, y: 20}, + mapgcs: {x: 10, y: 20} + })).toBe(undefined); + expect(ann.options('position')).toBe(undefined); + expect(ann.mouseClick({ + buttonsDown: {left: true}, + map: {x: 10, y: 20}, + mapgcs: {x: 10, y: 20} + })).toBe('done'); + expect(ann.options('position')).toEqual({x: 10, y: 20}); + expect(ann.state()).toBe(geo.annotation.state.done); + }); + }); +}); diff --git a/tests/cases/annotationLayer.js b/tests/cases/annotationLayer.js new file mode 100644 index 0000000000..2628a68fac --- /dev/null +++ b/tests/cases/annotationLayer.js @@ -0,0 +1,328 @@ +/* Test geo.annotationLayer */ + +describe('geo.annotationLayer', function () { + 'use strict'; + + var $ = require('jquery'); + var geo = require('../test-utils').geo; + var mockVGLRenderer = require('../test-utils').mockVGLRenderer; + var restoreVGLRenderer = require('../test-utils').restoreVGLRenderer; + + beforeEach(function () { + mockVGLRenderer(); + }); + + afterEach(function () { + restoreVGLRenderer(); + }); + + function create_map(opts) { + var node = $('
').css({width: '640px', height: '360px'}); + $('#map').remove(); + $('body').append(node); + opts = $.extend({}, opts); + opts.node = node; + return geo.map(opts); + } + + it('Test initialization.', function () { + var map = create_map(); + var layer = map.createLayer('annotation', { + features: ['polygon', 'line', 'point'] + }); + expect(layer instanceof geo.annotationLayer).toBe(true); + }); + it('Test initialization without interactor.', function () { + var map = create_map({interactor: null}); + var layer = map.createLayer('annotation', { + features: ['polygon', 'line', 'point'] + }); + expect(layer instanceof geo.annotationLayer).toBe(true); + expect(map.interactor()).not.toBeNull(); + }); + describe('Check class accessors', function () { + var map, layer, modeEvent = 0, lastModeEvent; + it('options', function () { + map = create_map(); + layer = map.createLayer('annotation', { + features: ['polygon', 'line', 'point'] + }); + expect(layer.options().dblClickTime).toBe(300); + expect(layer.options('dblClickTime')).toBe(300); + expect(layer.options('dblClickTime', 400)).toBe(layer); + expect(layer.options().dblClickTime).toBe(400); + expect(layer.options({dblClickTime: 300})).toBe(layer); + expect(layer.options().dblClickTime).toBe(300); + }); + it('mode', function () { + map.geoOn(geo.event.annotation.mode, function (evt) { + modeEvent += 1; + lastModeEvent = evt; + }); + expect(layer.mode()).toBe(null); + expect(layer.mode('point')).toBe(layer); + expect(modeEvent).toBe(1); + expect(lastModeEvent.mode).toBe('point'); + expect(lastModeEvent.oldMode).toBe(null); + expect(layer.mode()).toBe('point'); + expect(layer.annotations().length).toBe(1); + var id = layer.annotations()[0].id(); + layer.mode('point'); + expect(modeEvent).toBe(1); + expect(layer.annotations()[0].id()).toBe(id); + expect(layer.mode('polygon')).toBe(layer); + expect(modeEvent).toBe(2); + expect(layer.mode()).toBe('polygon'); + expect(layer.annotations().length).toBe(1); + expect(layer.annotations()[0].id()).not.toBe(id); + expect(map.interactor().hasAction(undefined, undefined, 'annotationLayer')).toBeNull(); + expect(layer.mode('rectangle')).toBe(layer); + expect(layer.mode()).toBe('rectangle'); + expect(map.interactor().hasAction(undefined, undefined, 'annotationLayer')).not.toBeNull(); + expect(layer.mode(null)).toBe(layer); + expect(layer.mode()).toBe(null); + expect(map.interactor().hasAction(undefined, undefined, 'annotationLayer')).toBeNull(); + }); + it('annotations', function () { + var poly = geo.annotation.polygonAnnotation({ + state: geo.annotation.state.create, layer: layer}), + rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}]}); + expect(layer.annotations().length).toBe(0); + layer.addAnnotation(poly); + expect(layer.annotations().length).toBe(1); + expect(layer.annotations()[0]).toBe(poly); + layer.addAnnotation(rect); + expect(layer.annotations().length).toBe(2); + expect(layer.annotations()[1]).toBe(rect); + // this should give us a copy, so we don't alter the internal array + layer.annotations().splice(0, 1); + expect(layer.annotations().length).toBe(2); + layer.removeAllAnnotations(); + expect(layer.annotations().length).toBe(0); + }); + }); + describe('Public utility functions', function () { + var map, layer, + addAnnotationEvent = 0, lastAddAnnotationEvent, + addAnnotationBeforeEvent = 0, + removeAnnotationEvent = 0, lastRemoveAnnotationEvent, + poly, rect; + it('addAnnotation', function () { + map = create_map(); + layer = map.createLayer('annotation', { + features: ['polygon', 'line', 'point'] + }); + poly = geo.annotation.polygonAnnotation({ + state: geo.annotation.state.create, layer: layer}); + rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}]}); + map.geoOn(geo.event.annotation.add_before, function () { + addAnnotationBeforeEvent += 1; + }); + map.geoOn(geo.event.annotation.add, function (evt) { + addAnnotationEvent += 1; + lastAddAnnotationEvent = evt; + }); + expect(layer.annotations().length).toBe(0); + layer.addAnnotation(poly); + expect(layer.annotations().length).toBe(1); + expect(addAnnotationBeforeEvent).toBe(1); + expect(addAnnotationEvent).toBe(1); + expect(lastAddAnnotationEvent.annotation).toBe(poly); + layer.addAnnotation(poly); + expect(layer.annotations().length).toBe(1); + expect(addAnnotationBeforeEvent).toBe(1); + expect(addAnnotationEvent).toBe(1); + layer.addAnnotation(rect); + expect(layer.annotations().length).toBe(2); + expect(addAnnotationBeforeEvent).toBe(2); + expect(addAnnotationEvent).toBe(2); + expect(lastAddAnnotationEvent.annotation).toBe(rect); + }); + it('annotationById', function () { + expect(layer.annotationById()).toBe(undefined); + expect(layer.annotationById('not an id')).toBe(undefined); + expect(layer.annotationById(poly.id())).toBe(poly); + expect(layer.annotationById(rect.id())).toBe(rect); + expect(layer.annotationById('' + poly.id())).toBe(poly); + }); + it('removeAnnotation', function () { + map.geoOn(geo.event.annotation.remove, function (evt) { + removeAnnotationEvent += 1; + lastRemoveAnnotationEvent = evt; + }); + expect(layer.removeAnnotation('not present')).toBe(false); + expect(removeAnnotationEvent).toBe(0); + expect(layer.removeAnnotation(poly)).toBe(true); + expect(removeAnnotationEvent).toBe(1); + expect(lastRemoveAnnotationEvent.annotation).toBe(poly); + expect(layer.removeAnnotation(poly)).toBe(false); + expect(removeAnnotationEvent).toBe(1); + expect(layer.removeAnnotation(rect, false)).toBe(true); + expect(removeAnnotationEvent).toBe(2); + }); + it('removeAllAnnotation', function () { + removeAnnotationEvent = 0; + layer.addAnnotation(poly); + layer.addAnnotation(rect); + expect(layer.removeAllAnnotations()).toBe(2); + expect(removeAnnotationEvent).toBe(2); + expect(layer.annotations().length).toBe(0); + layer.addAnnotation(poly); + layer.addAnnotation(rect); + expect(layer.removeAllAnnotations(true)).toBe(1); + expect(removeAnnotationEvent).toBe(3); + expect(layer.annotations().length).toBe(1); + }); + it('displayDistance', function () { + var c1 = {x: 100, y: 80}, + c2 = map.displayToGcs({x: 105, y: 84}), /* ingcs */ + c3 = map.displayToGcs({x: 107, y: 88}, null); /* gcs */ + expect(layer.displayDistance(c1, 'display', c2)).toBeCloseTo(6.40, 2); + expect(layer.displayDistance(c1, 'display', c2, 'EPSG:4326')).toBeCloseTo(6.40, 2); + expect(layer.displayDistance(c1, 'display', c3, null)).toBeCloseTo(10.63, 2); + expect(layer.displayDistance(c1, 'display', c3, 'EPSG:3857')).toBeCloseTo(10.63, 2); + expect(layer.displayDistance(c2, undefined, c1, 'display')).toBeCloseTo(6.40, 2); + expect(layer.displayDistance(c2, undefined, c3, null)).toBeCloseTo(4.47, 2); + expect(layer.displayDistance(c3, null, c1, 'display')).toBeCloseTo(10.63, 2); + expect(layer.displayDistance(c3, null, c2)).toBeCloseTo(4.47, 2); + }); + }); + describe('Private utility functions', function () { + var map, layer, point, rect, rect2; + it('_update', function () { + /* Most of update is covered as a side effect of other code. This tests + * some edge conditions */ + map = create_map(); + layer = map.createLayer('annotation', { + renderer: 'd3' + }); + point = geo.annotation.pointAnnotation({ + layer: layer, + position: {x: 2, y: 3}}); + rect = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}]}); + rect2 = geo.annotation.rectangleAnnotation({ + layer: layer, + corners: [{x: 3, y: 0}, {x: 4, y: 0}, {x: 4, y: 1}, {x: 3, y: 1}]}); + layer.addAnnotation(point); + layer.addAnnotation(rect); + layer.addAnnotation(rect2); + expect(layer.features.length).toBe(1); + }); + it('_handleMouseClick', function () { + layer.removeAllAnnotations(); + layer.mode('polygon'); + expect(layer.annotations().length).toBe(1); + expect(layer.annotations()[0].options('vertices').length).toBe(0); + var time = new Date().getTime(); + layer._handleMouseClick({ + buttonsDown: {left: true}, + time: time, + map: {x: 10, y: 20}, + mapgcs: map.displayToGcs({x: 10, y: 20}, null) + }); + expect(layer.annotations().length).toBe(1); + expect(layer.annotations()[0].options('vertices').length).toBe(2); + layer._handleMouseClick({ + buttonsDown: {left: true}, + time: time, + map: {x: 30, y: 20}, + mapgcs: map.displayToGcs({x: 30, y: 20}, null) + }); + expect(layer.annotations()[0].options('vertices').length).toBe(3); + layer._handleMouseClick({ + buttonsDown: {left: true}, + time: time + 1000, + map: {x: 30, y: 20}, + mapgcs: map.displayToGcs({x: 30, y: 20}, null) + }); + expect(layer.annotations()[0].options('vertices').length).toBe(3); + layer._handleMouseClick({ + buttonsDown: {left: true}, + time: time + 1000, + map: {x: 20, y: 50}, + mapgcs: map.displayToGcs({x: 20, y: 50}, null) + }); + expect(layer.annotations()[0].options('vertices').length).toBe(4); + layer._handleMouseClick({ + buttonsDown: {left: true}, + time: time + 1000, + map: {x: 20, y: 50}, + mapgcs: map.displayToGcs({x: 20, y: 50}, null) + }); + expect(layer.annotations()[0].options('vertices').length).toBe(3); + expect(layer.annotations()[0].state()).toBe(geo.annotation.state.done); + layer.removeAllAnnotations(); + layer.mode('polygon'); + layer._handleMouseClick({ + buttonsDown: {left: true}, + time: time, + map: {x: 10, y: 20}, + mapgcs: map.displayToGcs({x: 10, y: 20}, null) + }); + layer._handleMouseClick({ + buttonsDown: {right: true}, + time: time, + map: {x: 30, y: 20}, + mapgcs: map.displayToGcs({x: 30, y: 20}, null) + }); + expect(layer.annotations().length).toBe(0); + }); + it('_handleMouseMove', function () { + layer.removeAllAnnotations(); + layer.mode('polygon'); + var time = new Date().getTime(); + layer._handleMouseClick({ + buttonsDown: {left: true}, + time: time, + map: {x: 10, y: 20}, + mapgcs: map.displayToGcs({x: 10, y: 20}, null) + }); + expect(layer.annotations()[0].options('vertices')[0]).toEqual(layer.annotations()[0].options('vertices')[1]); + layer._handleMouseMove({ + buttonsDown: {}, + time: time, + map: {x: 15, y: 22}, + mapgcs: map.displayToGcs({x: 15, y: 22}, null) + }); + expect(layer.annotations()[0].options('vertices')[0]).not.toEqual(layer.annotations()[0].options('vertices')[1]); + }); + it('_processSelection', function () { + layer.removeAllAnnotations(); + layer._processSelection({ + state: {action: geo.geo_action.annotation_rectangle}, + lowerLeft: {x: 10, y: 10}, + lowerRight: {x: 20, y: 10}, + upperLeft: {x: 10, y: 5}, + upperRight: {x: 20, y: 5} + }); + expect(layer.annotations().length).toBe(0); + layer.mode('rectangle'); + layer._processSelection({ + state: {action: geo.geo_action.annotation_rectangle}, + lowerLeft: {x: 10, y: 10}, + lowerRight: {x: 20, y: 10}, + upperLeft: {x: 10, y: 5}, + upperRight: {x: 20, y: 5} + }); + expect(layer.annotations().length).toBe(1); + expect(layer.annotations()[0].type()).toBe('rectangle'); + }); + }); + it('Test destroy layer.', function () { + var map = create_map(); + var layer = map.createLayer('annotation', { + features: ['polygon', 'line', 'point'] + }); + expect(layer instanceof geo.annotationLayer).toBe(true); + layer.mode('polygon'); + expect(layer.annotations().length).toBe(1); + map.deleteLayer(layer); + expect(layer.annotations().length).toBe(0); + }); +}); diff --git a/tests/cases/colors.js b/tests/cases/colors.js index 335ca1d9ce..69bb9f3e2d 100644 --- a/tests/cases/colors.js +++ b/tests/cases/colors.js @@ -29,6 +29,32 @@ describe('geo.util.convertColor', function () { }); }); }); + describe('From short hex string', function () { + it('#000', function () { + var c = geo.util.convertColor('#000'); + expect(c).toEqual({ + r: 0, + g: 0, + b: 0 + }); + }); + it('#fff', function () { + var c = geo.util.convertColor('#fff'); + expect(c).toEqual({ + r: 1, + g: 1, + b: 1 + }); + }); + it('#26b', function () { + var c = geo.util.convertColor('#26b'); + expect(c).toEqual({ + r: 2 / 15, + g: 6 / 15, + b: 11 / 15 + }); + }); + }); describe('From hex value', function () { it('#000000', function () { var c = geo.util.convertColor(0x000000); diff --git a/tests/cases/contourWrap.js b/tests/cases/contourWrap.js index 8d573d8dfb..0c80d310a8 100644 --- a/tests/cases/contourWrap.js +++ b/tests/cases/contourWrap.js @@ -9,8 +9,8 @@ describe('Contour Feature', function () { var restoreVGLRenderer = require('../test-utils').restoreVGLRenderer; beforeEach(function () { - $('
').appendTo('body') - .css({width: '500px', height: '300px'}); + $('
') + .css({width: '500px', height: '300px'}).appendTo('body'); mockVGLRenderer(); map = geo.map({ diff --git a/tests/cases/d3GraphFeature.js b/tests/cases/d3GraphFeature.js index 707e7ec8a9..ee4ff01663 100644 --- a/tests/cases/d3GraphFeature.js +++ b/tests/cases/d3GraphFeature.js @@ -1,65 +1,67 @@ -var geo = require('../test-utils').geo; -var $ = require('jquery'); -var mockAnimationFrame = require('../test-utils').mockAnimationFrame; -var stepAnimationFrame = require('../test-utils').stepAnimationFrame; -var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; +describe('d3 graph feature', function () { + var geo = require('../test-utils').geo; + var $ = require('jquery'); + var mockAnimationFrame = require('../test-utils').mockAnimationFrame; + var stepAnimationFrame = require('../test-utils').stepAnimationFrame; + var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; -beforeEach(function () { - $('
').appendTo('body') - .css({width: '500px', height: '400px'}); -}); + beforeEach(function () { + $('
') + .css({width: '500px', height: '400px'}).appendTo('body'); + }); -afterEach(function () { - $('#map-d3-graph-feature').remove(); -}); + afterEach(function () { + $('#map-d3-graph-feature').remove(); + }); -describe('d3 graph feature', function () { - 'use strict'; + describe('d3 graph feature', function () { + 'use strict'; - var map, layer, feature; + var map, layer, feature; - it('Setup map', function () { - map = geo.map({node: '#map-d3-graph-feature', center: [0, 0], zoom: 3}); - layer = map.createLayer('feature', {'renderer': 'd3'}); - }); + it('Setup map', function () { + map = geo.map({node: '#map-d3-graph-feature', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {'renderer': 'd3'}); + }); - it('Add features to a layer', function () { - mockAnimationFrame(); - var selection, nodes; + it('Add features to a layer', function () { + mockAnimationFrame(); + var selection, nodes; - nodes = [ - {y: 0, x: 0}, - {y: 10, x: 0}, - {y: -10, x: 0}, - {y: 10, x: 10} - ]; + nodes = [ + {y: 0, x: 0}, + {y: 10, x: 0}, + {y: -10, x: 0}, + {y: 10, x: 10} + ]; - nodes[0].children = [nodes[1], nodes[2]]; - nodes[1].children = [nodes[3]]; + nodes[0].children = [nodes[1], nodes[2]]; + nodes[1].children = [nodes[3]]; - feature = layer.createFeature('graph') - .data(nodes) - .draw(); - stepAnimationFrame(); + feature = layer.createFeature('graph') + .data(nodes) + .draw(); + stepAnimationFrame(); - selection = layer.canvas().selectAll('circle'); - expect(selection[0].length).toBe(4); + selection = layer.canvas().selectAll('circle'); + expect(selection[0].length).toBe(4); - selection = layer.canvas().selectAll('path'); - expect(selection[0].length).toBe(3); - }); + selection = layer.canvas().selectAll('path'); + expect(selection[0].length).toBe(3); + }); - it('Remove feature from a layer', function () { - var selection; + it('Remove feature from a layer', function () { + var selection; - layer.deleteFeature(feature).draw(); - stepAnimationFrame(); + layer.deleteFeature(feature).draw(); + stepAnimationFrame(); - selection = layer.canvas().selectAll('circle'); - expect(selection[0].length).toBe(0); + selection = layer.canvas().selectAll('circle'); + expect(selection[0].length).toBe(0); - selection = layer.canvas().selectAll('path'); - expect(selection[0].length).toBe(0); - unmockAnimationFrame(); + selection = layer.canvas().selectAll('path'); + expect(selection[0].length).toBe(0); + unmockAnimationFrame(); + }); }); }); diff --git a/tests/cases/d3PointFeature.js b/tests/cases/d3PointFeature.js index 4fc593e027..d4ca8e5f5b 100644 --- a/tests/cases/d3PointFeature.js +++ b/tests/cases/d3PointFeature.js @@ -1,81 +1,83 @@ -var geo = require('../test-utils').geo; -var $ = require('jquery'); -var mockAnimationFrame = require('../test-utils').mockAnimationFrame; -var stepAnimationFrame = require('../test-utils').stepAnimationFrame; -var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; - -beforeEach(function () { - $('
').appendTo('body') - .css({width: '500px', height: '400px'}); -}); +describe('d3 point feature', function () { + var geo = require('../test-utils').geo; + var $ = require('jquery'); + var mockAnimationFrame = require('../test-utils').mockAnimationFrame; + var stepAnimationFrame = require('../test-utils').stepAnimationFrame; + var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; + + beforeEach(function () { + $('
') + .css({width: '500px', height: '400px'}).appendTo('body'); + }); -afterEach(function () { - $('#map-d3-point-feature').remove(); -}); + afterEach(function () { + $('#map-d3-point-feature').remove(); + }); -describe('d3 point feature', function () { - 'use strict'; + describe('d3 point feature', function () { + 'use strict'; - var map, width = 800, height = 600, layer, feature1, feature2; + var map, width = 800, height = 600, layer, feature1, feature2; - it('Setup map', function () { - map = geo.map({node: '#map-d3-point-feature', center: [0, 0], zoom: 3}); - layer = map.createLayer('feature', {'renderer': 'd3'}); + it('Setup map', function () { + map = geo.map({node: '#map-d3-point-feature', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {'renderer': 'd3'}); - map.resize(0, 0, width, height); - }); + map.resize(0, 0, width, height); + }); - it('Add features to a layer', function () { - mockAnimationFrame(); - var selection; - feature1 = layer.createFeature('point', {selectionAPI: true}) - .data([{y: 0, x: 0}, {y: 10, x: 0}, {y: 0, x: 10}]) - .draw(); - stepAnimationFrame(); + it('Add features to a layer', function () { + mockAnimationFrame(); + var selection; + feature1 = layer.createFeature('point', {selectionAPI: true}) + .data([{y: 0, x: 0}, {y: 10, x: 0}, {y: 0, x: 10}]) + .draw(); + stepAnimationFrame(); - selection = layer.node().find('circle'); - expect(selection.length).toBe(3); + selection = layer.node().find('circle'); + expect(selection.length).toBe(3); - feature2 = layer.createFeature('point') - .data([{y: -10, x: -10}, {y: 10, x: -10}]) - .draw(); - stepAnimationFrame(); + feature2 = layer.createFeature('point') + .data([{y: -10, x: -10}, {y: 10, x: -10}]) + .draw(); + stepAnimationFrame(); - selection = layer.node().find('circle'); - expect(selection.length).toBe(5); + selection = layer.node().find('circle'); + expect(selection.length).toBe(5); - layer.createFeature('point') - .data([{y: -10, x: 10}]) - .draw(); - stepAnimationFrame(); + layer.createFeature('point') + .data([{y: -10, x: 10}]) + .draw(); + stepAnimationFrame(); - selection = layer.node().find('circle'); - expect(selection.length).toBe(6); - }); + selection = layer.node().find('circle'); + expect(selection.length).toBe(6); + }); - it('Validate selection API option', function () { - expect(feature1.selectionAPI()).toBe(true); - expect(feature2.selectionAPI()).toBe(false); - }); + it('Validate selection API option', function () { + expect(feature1.selectionAPI()).toBe(true); + expect(feature2.selectionAPI()).toBe(false); + }); - it('Remove a feature from a layer', function () { - var selection; + it('Remove a feature from a layer', function () { + var selection; - layer.deleteFeature(feature2).draw(); - stepAnimationFrame(); + layer.deleteFeature(feature2).draw(); + stepAnimationFrame(); - selection = layer.node().find('circle'); - expect(selection.length).toBe(4); - }); - it('Remove all features from a layer', function () { - var selection; + selection = layer.node().find('circle'); + expect(selection.length).toBe(4); + }); + it('Remove all features from a layer', function () { + var selection; - layer.clear().draw(); - map.draw(); - stepAnimationFrame(); + layer.clear().draw(); + map.draw(); + stepAnimationFrame(); - selection = layer.node().find('circle'); - expect(selection.length).toBe(0); - unmockAnimationFrame(); + selection = layer.node().find('circle'); + expect(selection.length).toBe(0); + unmockAnimationFrame(); + }); }); }); diff --git a/tests/cases/discreteZoom.js b/tests/cases/discreteZoom.js index 23eedc7d1e..b2b07090a6 100644 --- a/tests/cases/discreteZoom.js +++ b/tests/cases/discreteZoom.js @@ -16,8 +16,8 @@ describe('DiscreteZoom and ParallelProjection', function () { } beforeEach(function () { - $('
').appendTo('body') - .css({width: '800px', height: '800px'}); + $('
') + .css({width: '800px', height: '800px'}).appendTo('body'); }); afterEach(function () { diff --git a/tests/cases/geojsonReader.js b/tests/cases/geojsonReader.js index dd4829da77..c653859336 100644 --- a/tests/cases/geojsonReader.js +++ b/tests/cases/geojsonReader.js @@ -1,372 +1,374 @@ -var geo = require('../test-utils').geo; -var $ = require('jquery'); +describe('geojsonReader', function () { + var geo = require('../test-utils').geo; + var $ = require('jquery'); -beforeEach(function () { - $('
').appendTo('body') - .css({width: '800px', height: '600px'}); -}); + beforeEach(function () { + $('
') + .css({width: '800px', height: '600px'}).appendTo('body'); + }); -afterEach(function () { - $('#map-geojson-reader').remove(); -}); + afterEach(function () { + $('#map-geojson-reader').remove(); + }); -describe('geojsonReader', function () { - 'use strict'; + describe('geojsonReader', function () { + 'use strict'; - var obj, map, layer; + var obj, map, layer; - describe('Feature normalization', function () { - var reader; + describe('Feature normalization', function () { + var reader; - beforeEach(function () { - map = geo.map({node: '#map-geojson-reader', center: [0, 0], zoom: 3}); - layer = map.createLayer('feature', {renderer: 'd3'}); - sinon.stub(layer, 'createFeature'); - reader = geo.createFileReader('jsonReader', {'layer': layer}); - }); - afterEach(function () { - layer.createFeature.restore(); - map.exit(); - }); + beforeEach(function () { + map = geo.map({node: '#map-geojson-reader', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {renderer: 'd3'}); + sinon.stub(layer, 'createFeature'); + reader = geo.createFileReader('jsonReader', {'layer': layer}); + }); + afterEach(function () { + layer.createFeature.restore(); + map.exit(); + }); - describe('bare geometry', function () { - it('Point', function () { - expect(reader._featureArray({ - type: 'Point', - coordinates: [1, 2] - })).toEqual([{ - type: 'Feature', - properties: {}, - geometry: { + describe('bare geometry', function () { + it('Point', function () { + expect(reader._featureArray({ type: 'Point', coordinates: [1, 2] - } - }]); - }); - it('LineString', function () { - expect(reader._featureArray({ - type: 'LineString', - coordinates: [[1, 2], [3, 4]] - })).toEqual([{ - type: 'Feature', - properties: {}, - geometry: { + })).toEqual([{ + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [1, 2] + } + }]); + }); + it('LineString', function () { + expect(reader._featureArray({ type: 'LineString', coordinates: [[1, 2], [3, 4]] - } - }]); - }); - it('Polygon', function () { - expect(reader._featureArray({ - type: 'Polygon', - coordinates: [[[1, 2], [3, 4], [5, 6]]] - })).toEqual([{ - type: 'Feature', - properties: {}, - geometry: { + })).toEqual([{ + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [[1, 2], [3, 4]] + } + }]); + }); + it('Polygon', function () { + expect(reader._featureArray({ type: 'Polygon', coordinates: [[[1, 2], [3, 4], [5, 6]]] - } - }]); + })).toEqual([{ + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[[1, 2], [3, 4], [5, 6]]] + } + }]); + }); + it('MultiPoint', function () { + expect(reader._featureArray({ + type: 'MultiPoint', + coordinates: [[1, 2], [3, 4]] + })).toEqual([{ + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [1, 2] + } + }, { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [3, 4] + } + }]); + }); + it('MultiLineString', function () { + expect(reader._featureArray({ + type: 'MultiLineString', + coordinates: [[[1, 2], [3, 4]]] + })).toEqual([{ + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [[1, 2], [3, 4]] + } + }]); + }); + it('MultiPolygon', function () { + expect(reader._featureArray({ + type: 'MultiPolygon', + coordinates: [[[[1, 2], [3, 4], [5, 6]]]] + })).toEqual([{ + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[[1, 2], [3, 4], [5, 6]]] + } + }]); + }); }); - it('MultiPoint', function () { + + it('GeometryCollection', function () { expect(reader._featureArray({ - type: 'MultiPoint', - coordinates: [[1, 2], [3, 4]] - })).toEqual([{ - type: 'Feature', - properties: {}, - geometry: { - type: 'Point', - coordinates: [1, 2] - } - }, { - type: 'Feature', - properties: {}, - geometry: { - type: 'Point', - coordinates: [3, 4] + type: 'GeometryCollection', + geometries: [ + { + type: 'Point', + coordinates: [0, 0] + }, { + type: 'MultiPoint', + coordinates: [[0, 1], [2, 3]] + } + ] + })).toEqual([ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [0, 0] + } + }, { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [0, 1] + } + }, { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [2, 3] + } } - }]); + ]); }); - it('MultiLineString', function () { + + it('Feature', function () { expect(reader._featureArray({ - type: 'MultiLineString', - coordinates: [[[1, 2], [3, 4]]] - })).toEqual([{ type: 'Feature', - properties: {}, geometry: { - type: 'LineString', - coordinates: [[1, 2], [3, 4]] - } - }]); - }); - it('MultiPolygon', function () { - expect(reader._featureArray({ - type: 'MultiPolygon', - coordinates: [[[[1, 2], [3, 4], [5, 6]]]] + type: 'Point', + coordinates: [1, 2] + }, + properties: {a: 1} })).toEqual([{ type: 'Feature', - properties: {}, + properties: {a: 1}, geometry: { - type: 'Polygon', - coordinates: [[[1, 2], [3, 4], [5, 6]]] + type: 'Point', + coordinates: [1, 2] } }]); }); - }); - it('GeometryCollection', function () { - expect(reader._featureArray({ - type: 'GeometryCollection', - geometries: [ - { - type: 'Point', - coordinates: [0, 0] + it('FeatureCollection', function () { + expect(reader._featureArray({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [1, 2] + }, + properties: {a: 1} }, { - type: 'MultiPoint', - coordinates: [[0, 1], [2, 3]] - } - ] - })).toEqual([ - { + type: 'Feature', + geometry: { + type: 'MultiPoint', + coordinates: [[0, 0], [1, 1]] + }, + properties: {b: 2} + }] + })).toEqual([{ type: 'Feature', - properties: {}, + properties: {a: 1}, geometry: { type: 'Point', - coordinates: [0, 0] + coordinates: [1, 2] } }, { type: 'Feature', - properties: {}, + properties: {b: 2}, geometry: { type: 'Point', - coordinates: [0, 1] + coordinates: [0, 0] } }, { type: 'Feature', - properties: {}, + properties: {b: 2}, geometry: { type: 'Point', - coordinates: [2, 3] + coordinates: [1, 1] } - } - ]); - }); - - it('Feature', function () { - expect(reader._featureArray({ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [1, 2] - }, - properties: {a: 1} - })).toEqual([{ - type: 'Feature', - properties: {a: 1}, - geometry: { - type: 'Point', - coordinates: [1, 2] - } - }]); - }); - - it('FeatureCollection', function () { - expect(reader._featureArray({ - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [1, 2] - }, - properties: {a: 1} - }, { - type: 'Feature', - geometry: { - type: 'MultiPoint', - coordinates: [[0, 0], [1, 1]] - }, - properties: {b: 2} - }] - })).toEqual([{ - type: 'Feature', - properties: {a: 1}, - geometry: { - type: 'Point', - coordinates: [1, 2] - } - }, { - type: 'Feature', - properties: {b: 2}, - geometry: { - type: 'Point', - coordinates: [0, 0] - } - }, { - type: 'Feature', - properties: {b: 2}, - geometry: { - type: 'Point', - coordinates: [1, 1] - } - }]); + }]); - it('empty geometry', function () { - expect(reader._featureArray({ - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [] - } - }, { - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: [] - } - }, { - type: 'Feature', - geometry: { - type: 'Polygon', - coordinates: [] - } - }] - })).toEqual([]); + it('empty geometry', function () { + expect(reader._featureArray({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [] + } + }, { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [] + } + }, { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [] + } + }] + })).toEqual([]); + }); }); - }); - describe('Errors', function () { - it('Invalid geometry', function () { - expect(function () { - reader._feature({ - type: 'Feature', - properties: {}, - geometry: { - type: 'pt', - coordinates: [0, 0] - } - }); - }).toThrow(); - }); + describe('Errors', function () { + it('Invalid geometry', function () { + expect(function () { + reader._feature({ + type: 'Feature', + properties: {}, + geometry: { + type: 'pt', + coordinates: [0, 0] + } + }); + }).toThrow(); + }); - it('Invalid feature', function () { - expect(function () { - reader._feature({ - properties: {}, - geometry: { - type: 'Point', - coordinates: [0, 0] - } - }); - }).toThrow(); - }); - it('Invalid JSON', function () { - expect(function () { - reader._featureArray({ - features: [] - }); - }).toThrow(); + it('Invalid feature', function () { + expect(function () { + reader._feature({ + properties: {}, + geometry: { + type: 'Point', + coordinates: [0, 0] + } + }); + }).toThrow(); + }); + it('Invalid JSON', function () { + expect(function () { + reader._featureArray({ + features: [] + }); + }).toThrow(); + }); }); }); - }); - it('Setup map', function () { - map = geo.map({node: '#map-geojson-reader', center: [0, 0], zoom: 3}); - layer = map.createLayer('feature', {renderer: 'd3'}); + it('Setup map', function () { + map = geo.map({node: '#map-geojson-reader', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {renderer: 'd3'}); - obj = { - 'features': [ - { - 'geometry': { - 'coordinates': [ - 102.0, - 0.5 - ], - 'type': 'Point' - }, - 'properties': { - 'color': [1, 0, 0], - 'size': [10] - }, - 'type': 'Feature' - }, - { - 'geometry': { - 'coordinates': [ - [ + obj = { + 'features': [ + { + 'geometry': { + 'coordinates': [ 102.0, - 0.0, - 0 - ], - [ - 103.0, - 1.0, - 1 - ], - [ - 104.0, - 0.0, - 2 - ], - [ - 105.0, - 1.0, - 3 - ] - ], - 'type': 'LineString' - }, - 'properties': { - 'color': [0, 1, 0], - 'width': [3] - }, - 'type': 'Feature' - }, - { - 'geometry': { - 'coordinates': [ - [ - 10.0, 0.5 ], - [ - 10.0, - -0.5 - ] - ], - 'type': 'MultiPoint' + 'type': 'Point' + }, + 'properties': { + 'color': [1, 0, 0], + 'size': [10] + }, + 'type': 'Feature' }, - 'properties': { - 'fillColor': '#0000ff', - 'radius': 7 + { + 'geometry': { + 'coordinates': [ + [ + 102.0, + 0.0, + 0 + ], + [ + 103.0, + 1.0, + 1 + ], + [ + 104.0, + 0.0, + 2 + ], + [ + 105.0, + 1.0, + 3 + ] + ], + 'type': 'LineString' + }, + 'properties': { + 'color': [0, 1, 0], + 'width': [3] + }, + 'type': 'Feature' }, - 'type': 'Feature' - } - ], - 'type': 'FeatureCollection' - }; - }); - it('read from object', function (done) { - var reader = geo.createFileReader('jsonReader', {'layer': layer}), - data, i; + { + 'geometry': { + 'coordinates': [ + [ + 10.0, + 0.5 + ], + [ + 10.0, + -0.5 + ] + ], + 'type': 'MultiPoint' + }, + 'properties': { + 'fillColor': '#0000ff', + 'radius': 7 + }, + 'type': 'Feature' + } + ], + 'type': 'FeatureCollection' + }; + }); + it('read from object', function (done) { + var reader = geo.createFileReader('jsonReader', {'layer': layer}), + data, i; - expect(reader.canRead(obj)).toBe(true); - reader.read(obj, function (features) { - expect(features.length).toEqual(2); + expect(reader.canRead(obj)).toBe(true); + reader.read(obj, function (features) { + expect(features.length).toEqual(2); - // Validate that we are getting the correct Z values - data = features[1].data()[0]; - for (i = 0; i < data.length; i += 1) { - expect(data[i].z()).toEqual(i); - } + // Validate that we are getting the correct Z values + data = features[1].data()[0]; + for (i = 0; i < data.length; i += 1) { + expect(data[i].z()).toEqual(i); + } - done(); + done(); + }); }); - }); + }); }); diff --git a/tests/cases/heatmap.js b/tests/cases/heatmap.js index 2eb41cd790..3406927e61 100644 --- a/tests/cases/heatmap.js +++ b/tests/cases/heatmap.js @@ -1,300 +1,302 @@ // Test geo.core.heatmap -var geo = require('../test-utils').geo; -var $ = require('jquery'); +describe('canvas heatmap', function () { + var geo = require('../test-utils').geo; + var $ = require('jquery'); -beforeEach(function () { - $('
').appendTo('body') - .css({width: '500px', height: '400px'}); -}); - -afterEach(function () { - $('#map-canvas-heatmap-feature').remove(); -}); - -describe('canvas heatmap feature', function () { - 'use strict'; - - var mockAnimationFrame = require('../test-utils').mockAnimationFrame; - var stepAnimationFrame = require('../test-utils').stepAnimationFrame; - var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; - - var map, width = 800, height = 600, layer, feature1, - testData = [[0.6, 42.8584, -70.9301], - [0.233, 42.2776, -83.7409], - [0.2, 42.2776, -83.7409]]; - var clock; beforeEach(function () { - clock = sinon.useFakeTimers(); + $('
') + .css({width: '500px', height: '400px'}).appendTo('body'); }); + afterEach(function () { - clock.restore(); + $('#map-canvas-heatmap-feature').remove(); }); - it('Setup map', function () { - mockAnimationFrame(); - map = geo.map({node: '#map-canvas-heatmap-feature', center: [0, 0], zoom: 3}); - layer = map.createLayer('feature', {'renderer': 'canvas'}); - map.resize(0, 0, width, height); - }); + describe('canvas heatmap feature', function () { + 'use strict'; - it('Add feature to a layer', function () { - feature1 = layer.createFeature('heatmap') - .data(testData) - .intensity(function (d) { - return d[0]; - }) - .position(function (d) { - return { - x: d[2], - y: d[1] - }; - }) - .style('radius', 5) - .style('blurRadius', 15); + var mockAnimationFrame = require('../test-utils').mockAnimationFrame; + var stepAnimationFrame = require('../test-utils').stepAnimationFrame; + var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(layer.children().length).toBe(1); - // leave animation frames mocked for later tests. - }); + var map, width = 800, height = 600, layer, feature1, + testData = [[0.6, 42.8584, -70.9301], + [0.233, 42.2776, -83.7409], + [0.2, 42.2776, -83.7409]]; + var clock; + beforeEach(function () { + clock = sinon.useFakeTimers(); + }); + afterEach(function () { + clock.restore(); + }); - it('Validate selection API option', function () { - expect(feature1.selectionAPI()).toBe(false); - }); + it('Setup map', function () { + mockAnimationFrame(); + map = geo.map({node: '#map-canvas-heatmap-feature', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {'renderer': 'canvas'}); + map.resize(0, 0, width, height); + }); - it('Validate position', function () { - expect(feature1.position()([0.6, 42.8584, -70.9301])) - .toEqual({x:-70.9301, y:42.8584}); - }); + it('Add feature to a layer', function () { + feature1 = layer.createFeature('heatmap') + .data(testData) + .intensity(function (d) { + return d[0]; + }) + .position(function (d) { + return { + x: d[2], + y: d[1] + }; + }) + .style('radius', 5) + .style('blurRadius', 15); - it('Validate maximum intensity', function () { - expect(feature1.maxIntensity()).toBe(0.6); - }); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(layer.children().length).toBe(1); + // leave animation frames mocked for later tests. + }); - it('Validate minimum intensity', function () { - expect(feature1.minIntensity()).toBe(0.2); - }); + it('Validate selection API option', function () { + expect(feature1.selectionAPI()).toBe(false); + }); - it('Compute gradient', function () { - feature1.style('color', {0: {r: 0, g: 0, b: 0.0, a: 0.0}, - 0.25: {r: 0, g: 0, b: 1, a: 0.5}, - 0.5: {r: 0, g: 1, b: 1, a: 0.6}, - 0.75: {r: 1, g: 1, b: 0, a: 0.7}, - 1: {r: 1, g: 0, b: 0, a: 0.1}}); - feature1._computeGradient(); - expect(layer.node()[0].children[0].getContext('2d') - .getImageData(1, 0, 1, 1).data.length).toBe(4); - }); - it('_animatePan', function () { - map.draw(); - var buildTime = feature1.buildTime().getMTime(); - map.pan({x: 10, y: 0}); - expect(feature1.buildTime().getMTime()).toBe(buildTime); - clock.tick(800); - map.pan({x: 10, y: 0}); - expect(feature1.buildTime().getMTime()).toBe(buildTime); - clock.tick(800); - expect(feature1.buildTime().getMTime()).toBe(buildTime); - clock.tick(800); - expect(feature1.buildTime().getMTime()).not.toBe(buildTime); - buildTime = feature1.buildTime().getMTime(); - map.pan({x: 0, y: 0}); - expect(feature1.buildTime().getMTime()).toBe(buildTime); - clock.tick(2000); - expect(feature1.buildTime().getMTime()).toBe(buildTime); - }); - it('radius, blurRadius, and gaussian', function () { - // animation frames are already mocked - expect(feature1._circle.radius).toBe(5); - expect(feature1._circle.blurRadius).toBe(15); - expect(feature1._circle.gaussian).toBe(true); - expect(feature1._circle.width).toBe(40); - expect(feature1._circle.height).toBe(40); - feature1.style('gaussian', false); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._circle.gaussian).toBe(false); - feature1.style('radius', 10); - expect(feature1._circle.radius).toBe(5); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._circle.radius).toBe(10); - expect(feature1._circle.width).toBe(50); - expect(feature1._circle.height).toBe(50); - feature1.style('blurRadius', 0); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._circle.blurRadius).toBe(0); - expect(feature1._circle.width).toBe(20); - expect(feature1._circle.height).toBe(20); - }); - it('binned', function () { - // animation frames are already mocked - // ensure there is some data that will be off the map when we zoom in - var viewport = map.camera()._viewport; - var r = 80, - data = [[1, 80, 0], [1, 0, 180]], - numpoints = ((viewport.width + r * 2) / (r / 8) * - (viewport.height + r * 2) / (r / 8)), - idx; - feature1.style({radius: r, blurRadius: 0}); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._binned).toBe(false); - feature1.binned(true); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._binned).toBe(r / 8); - feature1.binned(2); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._binned).toBe(2); - feature1.binned(20); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._binned).toBe(20); - for (idx = data.length; idx < numpoints + 1; idx += 1) { - data.push([Math.random(), (Math.random() - 0.5) * 190, ( - Math.random() - 0.5) * 360]); - } - feature1.data(data); - feature1.binned('auto'); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._binned).toBe(r / 8); - data.splice(numpoints); - feature1.data(data); - map.draw(); - stepAnimationFrame(new Date().getTime()); - expect(feature1._binned).toBe(false); - feature1.binned(true); - map.zoom(10); - stepAnimationFrame(new Date().getTime()); - expect(feature1._binned).toBe(r / 8); - }); - it('Remove a feature from a layer', function () { - layer.deleteFeature(feature1).draw(); - expect(layer.children().length).toBe(0); - // stop mocking animation frames - unmockAnimationFrame(); - }); -}); + it('Validate position', function () { + expect(feature1.position()([0.6, 42.8584, -70.9301])) + .toEqual({x:-70.9301, y:42.8584}); + }); -describe('core.heatmapFeature', function () { - var map, layer; - var heatmapFeature = require('../../src/heatmapFeature'); - var data = []; + it('Validate maximum intensity', function () { + expect(feature1.maxIntensity()).toBe(0.6); + }); - it('Setup map', function () { - map = geo.map({node: '#map-canvas-heatmap-feature', center: [0, 0], zoom: 3}); - layer = map.createLayer('feature', {'renderer': 'canvas'}); - for (var i = 0; i < 100; i += 1) { - data.push({a: i % 10, b: i % 9, c: i % 8}); - } - }); + it('Validate minimum intensity', function () { + expect(feature1.minIntensity()).toBe(0.2); + }); - describe('class accessors', function () { - it('maxIntensity', function () { - var heatmap = heatmapFeature({layer: layer}); - expect(heatmap.maxIntensity()).toBe(null); - expect(heatmap.maxIntensity(7)).toBe(heatmap); - expect(heatmap.maxIntensity()).toBe(7); - heatmap = heatmapFeature({layer: layer, maxIntensity: 8}); - expect(heatmap.maxIntensity()).toBe(8); + it('Compute gradient', function () { + feature1.style('color', {0: {r: 0, g: 0, b: 0.0, a: 0.0}, + 0.25: {r: 0, g: 0, b: 1, a: 0.5}, + 0.5: {r: 0, g: 1, b: 1, a: 0.6}, + 0.75: {r: 1, g: 1, b: 0, a: 0.7}, + 1: {r: 1, g: 0, b: 0, a: 0.1}}); + feature1._computeGradient(); + expect(layer.node()[0].children[0].getContext('2d') + .getImageData(1, 0, 1, 1).data.length).toBe(4); }); - it('minIntensity', function () { - var heatmap = heatmapFeature({layer: layer}); - expect(heatmap.minIntensity()).toBe(null); - expect(heatmap.minIntensity(2)).toBe(heatmap); - expect(heatmap.minIntensity()).toBe(2); - heatmap = heatmapFeature({layer: layer, minIntensity: 3}); - expect(heatmap.minIntensity()).toBe(3); + it('_animatePan', function () { + map.draw(); + var buildTime = feature1.buildTime().getMTime(); + map.pan({x: 10, y: 0}); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(800); + map.pan({x: 10, y: 0}); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(800); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(800); + expect(feature1.buildTime().getMTime()).not.toBe(buildTime); + buildTime = feature1.buildTime().getMTime(); + map.pan({x: 0, y: 0}); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(2000); + expect(feature1.buildTime().getMTime()).toBe(buildTime); }); - it('updateDelay', function () { - var heatmap = heatmapFeature({layer: layer}); - expect(heatmap.updateDelay()).toBe(1000); - expect(heatmap.updateDelay(40)).toBe(heatmap); - expect(heatmap.updateDelay()).toBe(40); - heatmap = heatmapFeature({layer: layer, updateDelay: 50}); - expect(heatmap.updateDelay()).toBe(50); + it('radius, blurRadius, and gaussian', function () { + // animation frames are already mocked + expect(feature1._circle.radius).toBe(5); + expect(feature1._circle.blurRadius).toBe(15); + expect(feature1._circle.gaussian).toBe(true); + expect(feature1._circle.width).toBe(40); + expect(feature1._circle.height).toBe(40); + feature1.style('gaussian', false); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._circle.gaussian).toBe(false); + feature1.style('radius', 10); + expect(feature1._circle.radius).toBe(5); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._circle.radius).toBe(10); + expect(feature1._circle.width).toBe(50); + expect(feature1._circle.height).toBe(50); + feature1.style('blurRadius', 0); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._circle.blurRadius).toBe(0); + expect(feature1._circle.width).toBe(20); + expect(feature1._circle.height).toBe(20); }); it('binned', function () { - var heatmap = heatmapFeature({layer: layer}); - expect(heatmap.binned()).toBe('auto'); - expect(heatmap.binned(true)).toBe(heatmap); - expect(heatmap.binned()).toBe(true); - heatmap = heatmapFeature({layer: layer, binned: 5}); - expect(heatmap.binned()).toBe(5); - heatmap.binned('true'); - expect(heatmap.binned()).toBe(true); - heatmap.binned('false'); - expect(heatmap.binned()).toBe(false); - heatmap.binned('auto'); - expect(heatmap.binned()).toBe('auto'); - heatmap.binned(5.3); - expect(heatmap.binned()).toBe(5); - heatmap.binned(-3); - expect(heatmap.binned()).toBe(false); - heatmap.binned('not a number'); - expect(heatmap.binned()).toBe(false); - }); - it('position', function () { - var heatmap = heatmapFeature({layer: layer}); - expect(heatmap.position()('abc')).toBe('abc'); - expect(heatmap.position(function (d) { - return {x: d.a, y: d.b}; - })).toBe(heatmap); - expect(heatmap.position()(data[0])).toEqual({x: 0, y: 0}); - expect(heatmap.position()(data[84])).toEqual({x: 4, y: 3}); - heatmap = heatmapFeature({layer: layer, position: function (d) { - return {x: d.b, y: d.c}; - }}); - expect(heatmap.position()(data[0])).toEqual({x: 0, y: 0}); - expect(heatmap.position()(data[87])).toEqual({x: 6, y: 7}); + // animation frames are already mocked + // ensure there is some data that will be off the map when we zoom in + var viewport = map.camera()._viewport; + var r = 80, + data = [[1, 80, 0], [1, 0, 180]], + numpoints = ((viewport.width + r * 2) / (r / 8) * + (viewport.height + r * 2) / (r / 8)), + idx; + feature1.style({radius: r, blurRadius: 0}); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._binned).toBe(false); + feature1.binned(true); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._binned).toBe(r / 8); + feature1.binned(2); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._binned).toBe(2); + feature1.binned(20); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._binned).toBe(20); + for (idx = data.length; idx < numpoints + 1; idx += 1) { + data.push([Math.random(), (Math.random() - 0.5) * 190, ( + Math.random() - 0.5) * 360]); + } + feature1.data(data); + feature1.binned('auto'); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._binned).toBe(r / 8); + data.splice(numpoints); + feature1.data(data); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._binned).toBe(false); + feature1.binned(true); + map.zoom(10); + stepAnimationFrame(new Date().getTime()); + expect(feature1._binned).toBe(r / 8); }); - it('intensity', function () { - var heatmap = heatmapFeature({layer: layer}); - expect(heatmap.intensity()('abc')).toBe(1); - expect(heatmap.intensity(function (d) { - return d.c; - })).toBe(heatmap); - expect(heatmap.intensity()(data[0])).toEqual(0); - expect(heatmap.intensity()(data[67])).toEqual(3); - heatmap = heatmapFeature({layer: layer, intensity: function (d) { - return d.a; - }}); - expect(heatmap.intensity()(data[0])).toEqual(0); - expect(heatmap.intensity()(data[67])).toEqual(7); + it('Remove a feature from a layer', function () { + layer.deleteFeature(feature1).draw(); + expect(layer.children().length).toBe(0); + // stop mocking animation frames + unmockAnimationFrame(); }); }); - describe('_build', function () { - it('intensity ranges', function () { - var heatmap = heatmapFeature({layer: layer, position: function (d) { - return {x: d.a, y: d.b}; - }, intensity: function (d) { - return d.c; - }}).data(data); - heatmap.gcs('EPSG:3857'); - heatmap._build(); - expect(heatmap.minIntensity()).toBe(0); - expect(heatmap.maxIntensity()).toBe(7); - heatmap.intensity(function () { return 2; }); - heatmap.maxIntensity(null).minIntensity(null); - heatmap._build(); - expect(heatmap.minIntensity()).toBe(1); - expect(heatmap.maxIntensity()).toBe(2); + + describe('core.heatmapFeature', function () { + var map, layer; + var heatmapFeature = require('../../src/heatmapFeature'); + var data = []; + + it('Setup map', function () { + map = geo.map({node: '#map-canvas-heatmap-feature', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {'renderer': 'canvas'}); + for (var i = 0; i < 100; i += 1) { + data.push({a: i % 10, b: i % 9, c: i % 8}); + } + }); + + describe('class accessors', function () { + it('maxIntensity', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.maxIntensity()).toBe(null); + expect(heatmap.maxIntensity(7)).toBe(heatmap); + expect(heatmap.maxIntensity()).toBe(7); + heatmap = heatmapFeature({layer: layer, maxIntensity: 8}); + expect(heatmap.maxIntensity()).toBe(8); + }); + it('minIntensity', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.minIntensity()).toBe(null); + expect(heatmap.minIntensity(2)).toBe(heatmap); + expect(heatmap.minIntensity()).toBe(2); + heatmap = heatmapFeature({layer: layer, minIntensity: 3}); + expect(heatmap.minIntensity()).toBe(3); + }); + it('updateDelay', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.updateDelay()).toBe(1000); + expect(heatmap.updateDelay(40)).toBe(heatmap); + expect(heatmap.updateDelay()).toBe(40); + heatmap = heatmapFeature({layer: layer, updateDelay: 50}); + expect(heatmap.updateDelay()).toBe(50); + }); + it('binned', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.binned()).toBe('auto'); + expect(heatmap.binned(true)).toBe(heatmap); + expect(heatmap.binned()).toBe(true); + heatmap = heatmapFeature({layer: layer, binned: 5}); + expect(heatmap.binned()).toBe(5); + heatmap.binned('true'); + expect(heatmap.binned()).toBe(true); + heatmap.binned('false'); + expect(heatmap.binned()).toBe(false); + heatmap.binned('auto'); + expect(heatmap.binned()).toBe('auto'); + heatmap.binned(5.3); + expect(heatmap.binned()).toBe(5); + heatmap.binned(-3); + expect(heatmap.binned()).toBe(false); + heatmap.binned('not a number'); + expect(heatmap.binned()).toBe(false); + }); + it('position', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.position()('abc')).toBe('abc'); + expect(heatmap.position(function (d) { + return {x: d.a, y: d.b}; + })).toBe(heatmap); + expect(heatmap.position()(data[0])).toEqual({x: 0, y: 0}); + expect(heatmap.position()(data[84])).toEqual({x: 4, y: 3}); + heatmap = heatmapFeature({layer: layer, position: function (d) { + return {x: d.b, y: d.c}; + }}); + expect(heatmap.position()(data[0])).toEqual({x: 0, y: 0}); + expect(heatmap.position()(data[87])).toEqual({x: 6, y: 7}); + }); + it('intensity', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.intensity()('abc')).toBe(1); + expect(heatmap.intensity(function (d) { + return d.c; + })).toBe(heatmap); + expect(heatmap.intensity()(data[0])).toEqual(0); + expect(heatmap.intensity()(data[67])).toEqual(3); + heatmap = heatmapFeature({layer: layer, intensity: function (d) { + return d.a; + }}); + expect(heatmap.intensity()(data[0])).toEqual(0); + expect(heatmap.intensity()(data[67])).toEqual(7); + }); }); - it('gcsPosition', function () { - var heatmap = heatmapFeature({layer: layer, position: function (d) { - return {x: d.a, y: d.b}; - }}).data(data); - heatmap.gcs('EPSG:3857'); - // we have to call build since we didn't attach this to the layer in the - // normal way - heatmap._build(); - var pos = heatmap.gcsPosition(); - expect(pos[0]).toEqual({x: 0, y: 0}); - expect(pos[84]).toEqual({x: 4, y: 3}); + describe('_build', function () { + it('intensity ranges', function () { + var heatmap = heatmapFeature({layer: layer, position: function (d) { + return {x: d.a, y: d.b}; + }, intensity: function (d) { + return d.c; + }}).data(data); + heatmap.gcs('EPSG:3857'); + heatmap._build(); + expect(heatmap.minIntensity()).toBe(0); + expect(heatmap.maxIntensity()).toBe(7); + heatmap.intensity(function () { return 2; }); + heatmap.maxIntensity(null).minIntensity(null); + heatmap._build(); + expect(heatmap.minIntensity()).toBe(1); + expect(heatmap.maxIntensity()).toBe(2); + }); + it('gcsPosition', function () { + var heatmap = heatmapFeature({layer: layer, position: function (d) { + return {x: d.a, y: d.b}; + }}).data(data); + heatmap.gcs('EPSG:3857'); + // we have to call build since we didn't attach this to the layer in the + // normal way + heatmap._build(); + var pos = heatmap.gcsPosition(); + expect(pos[0]).toEqual({x: 0, y: 0}); + expect(pos[84]).toEqual({x: 4, y: 3}); + }); }); }); }); diff --git a/tests/cases/lineFeature.js b/tests/cases/lineFeature.js index 73cf745d38..9fd60d4173 100644 --- a/tests/cases/lineFeature.js +++ b/tests/cases/lineFeature.js @@ -34,6 +34,8 @@ describe('geo.lineFeature', function () { ] }, { coord: [{x: 50, y: 10}, {x: 50, y: 10}] + }, { + coord: [{x: 60, y: 10}] } ]; @@ -214,7 +216,7 @@ describe('geo.lineFeature', function () { }).data(testLines); line.draw(); stepAnimationFrame(); - expect(layer.node().find('path').length).toBe(6); + expect(layer.node().find('path').length).toBe(7); unmockAnimationFrame(); }); }); diff --git a/tests/cases/mapAttribution.js b/tests/cases/mapAttribution.js index 3364a06423..50b9e000d7 100644 --- a/tests/cases/mapAttribution.js +++ b/tests/cases/mapAttribution.js @@ -5,8 +5,8 @@ describe('Test adding and remove attribution via layers', function () { var geo = require('../test-utils').geo; beforeEach(function () { - $('
').appendTo('body') - .css({width: '500px', height: '400px'}); + $('
') + .css({width: '500px', height: '400px'}).appendTo('body'); }); afterEach(function () { diff --git a/tests/cases/mapInteractor.js b/tests/cases/mapInteractor.js index 1d83cc246c..035f5c5d32 100644 --- a/tests/cases/mapInteractor.js +++ b/tests/cases/mapInteractor.js @@ -148,8 +148,10 @@ describe('mapInteractor', function () { beforeEach(function () { // create a new div - $('body').append('
'); - $('body').append('
'); + $('
') + .css({width: '800px', height: '600px'}).appendTo('body'); + $('
') + .css({width: '800px', height: '600px'}).appendTo('body'); }); afterEach(function () { @@ -516,7 +518,7 @@ describe('mapInteractor', function () { expect(map.info.zoomArgs).toBeCloseTo(3.75, 1); expect(map.info.centerCalls).toBe(1); expect(map.info.centerArgs.x).toBeCloseTo(-370); - expect(map.info.centerArgs.y).toBeCloseTo(35); + expect(map.info.centerArgs.y).toBeCloseTo(-265); map.discreteZoom(true); @@ -527,6 +529,9 @@ describe('mapInteractor', function () { interactor.simulateEvent( 'mousedown', {map: {x: 20, y: 20}, button: 'left'} ); + interactor.simulateEvent( + 'mousemove.geojs', {map: {x: 0, y: -30}, button: 'left'} + ); interactor.simulateEvent( 'mouseup.geojs', {map: {x: 0, y: -30}, button: 'left'} ); @@ -580,12 +585,13 @@ describe('mapInteractor', function () { expect(map.info.centerCalls).toBe(0); expect(map.info.pan).toBe(1); expect(map.info.panArgs.x).toBeCloseTo(-370); - expect(map.info.panArgs.y).toBeCloseTo(35); + expect(map.info.panArgs.y).toBeCloseTo(-265); }); it('Test selection event propagation', function () { var map = mockedMap('#mapNode1'), - triggered = 0; + triggered = 0, + clickTriggered = 0; var interactor = geo.mapInteractor({ map: map, @@ -599,13 +605,16 @@ describe('mapInteractor', function () { map.geoOn(geo.event.select, function () { triggered += 1; }); + map.geoOn(geo.event.mouseclick, function () { + clickTriggered += 1; + }); // initialize the selection interactor.simulateEvent( 'mousedown', {map: {x: 20, y: 20}, button: 'left'} ); interactor.simulateEvent( - 'mousemove', {map: {x: 30, y: 20}, button: 'left'} + 'mousemove.geojs', {map: {x: 30, y: 20}, button: 'left'} ); interactor.simulateEvent( 'mouseup.geojs', {map: {x: 40, y: 50}, button: 'left'} @@ -616,6 +625,19 @@ describe('mapInteractor', function () { expect(map.info.centerCalls).toBe(0); expect(map.info.pan).toBe(0); expect(triggered).toBe(1); + + // click should still work + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mousemove.geojs', {map: {x: 20, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mouseup.geojs', {map: {x: 20, y: 20}, button: 'left'} + ); + expect(triggered).toBe(1); + expect(clickTriggered).toBe(1); }); describe('pause state', function () { diff --git a/tests/cases/polygonFeature.js b/tests/cases/polygonFeature.js index 8cc4964a94..570600aed0 100644 --- a/tests/cases/polygonFeature.js +++ b/tests/cases/polygonFeature.js @@ -84,8 +84,8 @@ describe('geo.polygonFeature', function () { polygon.position(function () { return 'b'; }); expect(polygon.position()('a')).toEqual('b'); - polygon = geo.polygonFeature({layer: layer, position: pos}); - polygon._init(); + polygon = geo.polygonFeature({layer: layer}); + polygon._init({position: pos}); expect(polygon.position()).toEqual(pos); }); @@ -100,8 +100,8 @@ describe('geo.polygonFeature', function () { polygon.polygon(function () { return 'b'; }); expect(polygon.polygon()('a')).toEqual('b'); - polygon = geo.polygonFeature({layer: layer, polygon: pos}); - polygon._init(); + polygon = geo.polygonFeature({layer: layer}); + polygon._init({polygon: pos}); expect(polygon.polygon()).toEqual(pos); }); @@ -205,7 +205,7 @@ describe('geo.polygonFeature', function () { return vgl.mockCounts().bufferData >= (glCounts.bufferData || 0) + 1 && buildTime !== polygons.buildTime().getMTime(); }); - it('update the style', function () { + it('update the style B', function () { polygons.style('fillColor', function (d) { return '#ff0000'; }); @@ -217,6 +217,29 @@ describe('geo.polygonFeature', function () { return vgl.mockCounts().bufferData >= (glCounts.bufferData || 0) + 1 && buildTime !== polygons.buildTime().getMTime(); }); + it('update the style C', function () { + polygons.style('fill', function (d, i) { + return i % 2 > 0; + }); + glCounts = $.extend({}, vgl.mockCounts()); + buildTime = polygons.buildTime().getMTime(); + polygons.draw(); + }); + waitForIt('next render gl D', function () { + return vgl.mockCounts().bufferData >= (glCounts.bufferData || 0) + 1 && + buildTime !== polygons.buildTime().getMTime(); + }); + it('poor data', function () { + polygons.data([undefined, testPolygons[1]]); + polygons.style('fill', true); + glCounts = $.extend({}, vgl.mockCounts()); + buildTime = polygons.buildTime().getMTime(); + polygons.draw(); + }); + waitForIt('next render gl E', function () { + return vgl.mockCounts().bufferData >= (glCounts.bufferData || 0) + 1 && + buildTime !== polygons.buildTime().getMTime(); + }); it('_exit', function () { var buildTime = polygons.buildTime().getMTime(); layer.deleteFeature(polygons); diff --git a/tests/cases/quadFeature.js b/tests/cases/quadFeature.js index 912947b8ff..fea8b43437 100644 --- a/tests/cases/quadFeature.js +++ b/tests/cases/quadFeature.js @@ -559,7 +559,6 @@ describe('geo.quadFeature', function () { map.draw(); }); waitForIt('next render canvas C', function () { - console.log([window._canvasLog.counts.drawImage, counts.drawImage + 200, window._canvasLog.counts.clearRect, counts.clearRect + 1]); //DWM:: debug this test to find out why travis fails. return window._canvasLog.counts.drawImage >= counts.drawImage + 200 && window._canvasLog.counts.clearRect >= counts.clearRect + 1; }); diff --git a/tests/cases/widgetApi.js b/tests/cases/widgetApi.js index 88397674b3..2d3228636c 100644 --- a/tests/cases/widgetApi.js +++ b/tests/cases/widgetApi.js @@ -5,8 +5,8 @@ describe('widget api', function () { 'use strict'; beforeEach(function () { - $('
').appendTo('body') - .css({width: '500px', height: '400px'}); + $('
') + .css({width: '500px', height: '400px'}).appendTo('body'); }); afterEach(function () { diff --git a/tests/cases/zoomSlider.js b/tests/cases/zoomSlider.js index 6686961715..24de713991 100644 --- a/tests/cases/zoomSlider.js +++ b/tests/cases/zoomSlider.js @@ -8,8 +8,8 @@ describe('zoom slider', function () { var map; beforeEach(function () { - $('
').appendTo('body') - .css({width: '500px', height: '400px'}); + $('
') + .css({width: '500px', height: '400px'}).appendTo('body'); map = geo.map({ 'node': '#map-zoom-slider', 'center': [0, 0], diff --git a/tests/test-utils.js b/tests/test-utils.js index 15ab2e9933..4a354d0fc4 100644 --- a/tests/test-utils.js +++ b/tests/test-utils.js @@ -242,7 +242,7 @@ module.exports.mockVGLRenderer = function mockVGLRenderer(supported) { * Restore the vgl renderer to a pristine state. */ module.exports.restoreVGLRenderer = function () { - vgl.renderWidow = _renderWindow; + vgl.renderWindow = _renderWindow; geo.gl.vglRenderer.supported = _supported; delete vgl._mocked; delete vgl.mockCounts; diff --git a/webpack.config.js b/webpack.config.js index e25ee50d71..861e74c3ea 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -36,7 +36,8 @@ module.exports = { jquery: 'jquery/dist/jquery', proj4: 'proj4/lib', vgl: 'vgl/vgl.js', - d3: 'd3/d3.js' + d3: 'd3/d3.js', + mousetrap: 'mousetrap/mousetrap.js' } }, externals: {