From bbf7669411681d41b1e1403ff56537cd997b13ad Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 12 Oct 2016 11:36:03 -0400 Subject: [PATCH 01/14] Add a pixelmap feature. This takes an image where the color values are treated as indices into the data data. This is used to generate a new color for each index. The resultant image is rendered as a quad. The feature is available of the d3, canvas, and vgl renderers. --- src/canvas/index.js | 3 +- src/canvas/object.js | 6 + src/canvas/pixelmapFeature.js | 34 +++++ src/d3/index.js | 1 + src/d3/pixelmapFeature.js | 34 +++++ src/feature.js | 3 +- src/gl/index.js | 1 + src/gl/pixelmapFeature.js | 33 ++++ src/index.js | 3 +- src/pixelmapFeature.js | 276 ++++++++++++++++++++++++++++++++++ src/quadFeature.js | 21 ++- src/util/init.js | 27 ++++ 12 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 src/canvas/pixelmapFeature.js create mode 100644 src/d3/pixelmapFeature.js create mode 100644 src/gl/pixelmapFeature.js create mode 100644 src/pixelmapFeature.js diff --git a/src/canvas/index.js b/src/canvas/index.js index ad55e55bb2..661067b6d8 100644 --- a/src/canvas/index.js +++ b/src/canvas/index.js @@ -3,7 +3,8 @@ */ module.exports = { canvasRenderer: require('./canvasRenderer'), - quadFeature: require('./quadFeature'), heatmapFeature: require('./heatmapFeature'), + pixelmapFeature: require('./pixelmapFeature'), + quadFeature: require('./quadFeature'), tileLayer: require('./tileLayer') }; diff --git a/src/canvas/object.js b/src/canvas/object.js index 8fa037e4ab..e8cc9c7ad0 100644 --- a/src/canvas/object.js +++ b/src/canvas/object.js @@ -24,6 +24,12 @@ var canvas_object = function (arg) { var m_this = this, s_draw = this.draw; + /** + * This must be overridden by any feature that needs to render. + */ + this._renderOnCanvas = function () { + }; + //////////////////////////////////////////////////////////////////////////// /** * Redraw the object. diff --git a/src/canvas/pixelmapFeature.js b/src/canvas/pixelmapFeature.js new file mode 100644 index 0000000000..e8333a900f --- /dev/null +++ b/src/canvas/pixelmapFeature.js @@ -0,0 +1,34 @@ +var inherit = require('../inherit'); +var registerFeature = require('../registry').registerFeature; +var pixelmapFeature = require('../pixelmapFeature'); + +////////////////////////////////////////////////////////////////////////////// +/** + * Create a new instance of class pixelmapFeature + * + * @class geo.canvas.pixelmapFeature + * @param {Object} arg Options object + * @extends geo.pixelmapFeature + * @returns {canvas_pixelmapFeature} + */ +////////////////////////////////////////////////////////////////////////////// +var canvas_pixelmapFeature = function (arg) { + 'use strict'; + + if (!(this instanceof canvas_pixelmapFeature)) { + return new canvas_pixelmapFeature(arg); + } + pixelmapFeature.call(this, arg); + + var object = require('./object'); + object.call(this); + + this._init(arg); + return this; +}; + +inherit(canvas_pixelmapFeature, pixelmapFeature); + +// Now register it +registerFeature('canvas', 'pixelmap', canvas_pixelmapFeature); +module.exports = canvas_pixelmapFeature; diff --git a/src/d3/index.js b/src/d3/index.js index 1068e29a4a..3da1fad27f 100644 --- a/src/d3/index.js +++ b/src/d3/index.js @@ -11,6 +11,7 @@ module.exports = { lineFeature: require('./lineFeature'), object: require('./object'), pathFeature: require('./pathFeature'), + pixelmapFeature: require('./pixelmapFeature'), pointFeature: require('./pointFeature'), quadFeature: require('./quadFeature'), renderer: require('./d3Renderer'), diff --git a/src/d3/pixelmapFeature.js b/src/d3/pixelmapFeature.js new file mode 100644 index 0000000000..4165040b95 --- /dev/null +++ b/src/d3/pixelmapFeature.js @@ -0,0 +1,34 @@ +var inherit = require('../inherit'); +var registerFeature = require('../registry').registerFeature; +var pixelmapFeature = require('../pixelmapFeature'); + +////////////////////////////////////////////////////////////////////////////// +/** + * Create a new instance of class pixelmapFeature + * + * @class geo.d3.pixelmapFeature + * @param {Object} arg Options object + * @extends geo.pixelmapFeature + * @returns {d3_pixelmapFeature} + */ +////////////////////////////////////////////////////////////////////////////// +var d3_pixelmapFeature = function (arg) { + 'use strict'; + + if (!(this instanceof d3_pixelmapFeature)) { + return new d3_pixelmapFeature(arg); + } + pixelmapFeature.call(this, arg); + + var object = require('./object'); + object.call(this); + + this._init(arg); + return this; +}; + +inherit(d3_pixelmapFeature, pixelmapFeature); + +// Now register it +registerFeature('d3', 'pixelmap', d3_pixelmapFeature); +module.exports = d3_pixelmapFeature; diff --git a/src/feature.js b/src/feature.js index cae1b1077b..637f0d6fb5 100644 --- a/src/feature.js +++ b/src/feature.js @@ -212,11 +212,12 @@ var feature = function (arg) { * Private mouseclick handler */ //////////////////////////////////////////////////////////////////////////// - this._handleMouseclick = function () { + this._handleMouseclick = function (evt) { var mouse = m_this.layer().map().interactor().mouse(), data = m_this.data(), over = m_this.pointSearch(mouse.geo); + mouse.buttonsDown = evt.buttonsDown; feature.eventID += 1; over.index.forEach(function (i, idx) { m_this.geoTrigger(geo_event.feature.mouseclick, { diff --git a/src/gl/index.js b/src/gl/index.js index 1fa5da165d..9e781a0ab2 100644 --- a/src/gl/index.js +++ b/src/gl/index.js @@ -7,6 +7,7 @@ module.exports = { ellipsoid: require('./ellipsoid'), geomFeature: require('./geomFeature'), lineFeature: require('./lineFeature'), + pixelmapFeature: require('./pixelmapFeature'), pointFeature: require('./pointFeature'), polygonFeature: require('./polygonFeature'), quadFeature: require('./quadFeature'), diff --git a/src/gl/pixelmapFeature.js b/src/gl/pixelmapFeature.js new file mode 100644 index 0000000000..fe26c400ec --- /dev/null +++ b/src/gl/pixelmapFeature.js @@ -0,0 +1,33 @@ +var inherit = require('../inherit'); +var registerFeature = require('../registry').registerFeature; +var pixelmapFeature = require('../pixelmapFeature'); + +////////////////////////////////////////////////////////////////////////////// +/** + * Create a new instance of class pixelmapFeature + * + * @class geo.gl.pixelmapFeature + * @param {Object} arg Options object + * @extends geo.pixelmapFeature + * @returns {gl_pixelmapFeature} + */ +////////////////////////////////////////////////////////////////////////////// +var gl_pixelmapFeature = function (arg) { + 'use strict'; + + if (!(this instanceof gl_pixelmapFeature)) { + return new gl_pixelmapFeature(arg); + } + pixelmapFeature.call(this, arg); + var object = require('./object'); + object.call(this); + + this._init(arg); + return this; +}; + +inherit(gl_pixelmapFeature, pixelmapFeature); + +// Now register it +registerFeature('vgl', 'pixelmap', gl_pixelmapFeature); +module.exports = gl_pixelmapFeature; diff --git a/src/index.js b/src/index.js index dd7359547f..8538f883fb 100644 --- a/src/index.js +++ b/src/index.js @@ -46,6 +46,7 @@ module.exports = $.extend({ geo_action: require('./action'), geomFeature: require('./geomFeature'), graphFeature: require('./graphFeature'), + heatmapFeature: require('./heatmapFeature'), imageTile: require('./imageTile'), jsonReader: require('./jsonReader'), layer: require('./layer'), @@ -58,7 +59,7 @@ module.exports = $.extend({ pointFeature: require('./pointFeature'), polygonFeature: require('./polygonFeature'), quadFeature: require('./quadFeature'), - heatmapFeature: require('./heatmapFeature'), + pixelmapFeature: require('./pixelmapFeature'), renderer: require('./renderer'), sceneObject: require('./sceneObject'), tile: require('./tile'), diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js new file mode 100644 index 0000000000..fc69ccacfe --- /dev/null +++ b/src/pixelmapFeature.js @@ -0,0 +1,276 @@ +var $ = require('jquery'); +var inherit = require('./inherit'); +var feature = require('./feature'); + +////////////////////////////////////////////////////////////////////////////// +/** + * Create a new instance of class imagemapFeature + * + * @class geo.pixelmapFeature + * @param {Object} arg Options object + * @extends geo.feature + * @param {Object|Function} [url] URL of a pixel map. The rgb data is + * interpretted as an index of the form 0xbbggrr. The alpha channel is + * ignored. + * @param {Object|Function} [mapColor] The color that should be used for each + * data element. Data elements correspond to the indices in the pixel map. + * If an index is larger than the number of data elements, it will be + * transparent. If there is more data than there are indices, it is ignored. + * @param {Object|Function} [position] Position of the image. Default is + * (data). The position is an Object which specifies the corners of the + * quad: ll, lr, ur, ul. At least two opposite corners must be specified. + * The corners do not have to physically correspond to the order specified, + * but rather correspond to that part of the image map. If a corner is + * unspecified, it will use the x coordinate from one adjacent corner, the y + * coordinate from the other adjacent corner, and the average z value of + * those two corners. For instance, if ul is unspecified, it is + * {x: ll.x, y: ur.y}. Note that each quad is rendered as a pair of + * triangles: (ll, lr, ul) and (ur, ul, lr). Nothing special is done for + * quads that are not convex or quads that have substantially different + * transformations for those two triangles. + * @returns {geo.pixelmapFeature} + */ +////////////////////////////////////////////////////////////////////////////// + +////////////////////////////////////////////////////////////////////////////// +var pixelmapFeature = function (arg) { + 'use strict'; + if (!(this instanceof pixelmapFeature)) { + return new pixelmapFeature(arg); + } + arg = arg || {}; + feature.call(this, arg); + + //////////////////////////////////////////////////////////////////////////// + /** + * @private + */ + //////////////////////////////////////////////////////////////////////////// + var m_this = this, + m_quadFeature, + m_mappedColors, + m_pixelmapWidth, + m_pixelmapHeight, + m_pixelmapIndices, + m_srcImage, + s_update = this._update, + s_init = this._init, + s_exit = this._exit; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set position accessor + * + * @returns {geo.pixelmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.position = function (val) { + if (val === undefined) { + return m_this.style('position'); + } else if (val !== m_this.style('position')) { + m_this.style('position', val); + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set url accessor + * + * @returns {geo.pixelmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.url = function (val) { + if (val === undefined) { + return m_this.style('url'); + } else if (val !== m_this.style('url')) { + m_srcImage = m_pixelmapWidth = m_pixelmapHeight = undefined; + m_this.style('url', val); + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set mapColor accessor + * + * @returns {geo.pixelmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.mapColor = function (val) { + if (val === undefined) { + return m_this.style('mapColor'); + } else if (val !== m_this.style('mapColor')) { + m_this.style('mapColor', val); + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + /** + * If the specified coordinates are in the rendered quad, use the basis + * information from the quad to determine the pixelmap index value so that it + * can be included in the found results. + */ + this.pointSearch = function (coordinate) { + if (m_quadFeature) { + var result = m_quadFeature.pointSearch(coordinate); + if (result.basis.length === 1) { + var x = result.basis[0].x, y = result.basis[0].y; + x = Math.floor(x * m_pixelmapWidth); + y = Math.floor(y * m_pixelmapHeight); + if (x >= 0 && x < m_pixelmapWidth && y >= 0 && y < m_pixelmapHeight) { + result = { + index: [m_pixelmapIndices[y * m_pixelmapWidth + x]] + }; + result.found = [m_this.data()[result.index[0]]]; + return result; + } + } + } + return {index: [], found: []}; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Build + */ + //////////////////////////////////////////////////////////////////////////// + this._build = function () { + var data = m_this.data() || []; + + m_mappedColors = new Array(data.length); + if (!m_srcImage) { + m_srcImage = new Image(); + m_srcImage.crossOrigin = m_this.style.get('crossDomain')() || 'anonymous'; + m_srcImage.onload = this._computePixelmap; + m_srcImage.src = m_this.style.get('url')(); + } else if (m_pixelmapWidth) { + this._computePixelmap(); + } + m_this.buildTime().modified(); + return m_this; + }; + + /** + * Given the loaded pixelmap image, create a canvas the size of the image. + * Compute a color for each distinct index and recolor the canvas based on + * thise colors, then draw the resultant image as a quad. + */ + this._computePixelmap = function () { + var data = m_this.data() || [], + mapColorFunc = m_this.style.get('mapColor'); + + m_pixelmapWidth = m_srcImage.naturalWidth; + m_pixelmapHeight = m_srcImage.naturalHeight; + + var canvas = document.createElement('canvas'); + canvas.width = m_pixelmapWidth; + canvas.height = m_pixelmapHeight; + var context = canvas.getContext('2d'); + + context.drawImage(m_srcImage, 0, 0); + var imageData = context.getImageData(0, 0, canvas.width, canvas.height), + pixelData = imageData.data, + i, idx, color; + m_pixelmapIndices = new Array(pixelData.length / 4); + + for (i = 0; i < pixelData.length; i += 4) { + idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); + m_pixelmapIndices[i / 4] = idx; + if (m_mappedColors[idx] === undefined) { + m_mappedColors[idx] = mapColorFunc(data[idx], idx) || {}; + } + color = m_mappedColors[idx] || {}; + pixelData[i] = (color.r || 0) * 255; + pixelData[i + 1] = (color.g || 0) * 255; + pixelData[i + 2] = (color.b || 0) * 255; + pixelData[i + 3] = color.a === undefined ? 255 : (color.a * 255); + } + context.putImageData(imageData, 0, 0); + + var destImage = new Image(); + destImage.src = canvas.toDataURL(); + if (!m_quadFeature) { + m_quadFeature = m_this.layer().createFeature( + 'quad', {selectionAPI: false, gcs: m_this.gcs()}); + } + m_quadFeature.style({image: destImage, position: m_this.style.get('position')}); + m_quadFeature.data([{}]); + m_quadFeature.draw(); + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Update + */ + //////////////////////////////////////////////////////////////////////////// + this._update = function () { + s_update.call(m_this); + if (m_this.buildTime().getMTime() <= m_this.dataTime().getMTime() || + m_this.updateTime().getMTime() < m_this.getMTime()) { + m_this._build(); + } + + m_this.updateTime().modified(); + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Destroy + * @memberof geo.polygonFeature + */ + //////////////////////////////////////////////////////////////////////////// + this._exit = function () { + if (m_quadFeature && m_this.layer()) { + m_this.layer().deleteFeature(m_quadFeature); + m_quadFeature = null; + } + s_exit(); + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Initialize + */ + //////////////////////////////////////////////////////////////////////////// + this._init = function (arg) { + s_init.call(m_this, arg); + + var style = $.extend( + {}, + { + mapColor: function (d, idx) { + return { + r: (idx & 0xFF) / 255, + g: ((idx >> 8) & 0xFF) / 255, + b: ((idx >> 16) & 0xFF) / 255, + a: 1 + }; + } + }, + arg.style === undefined ? {} : arg.style + ); + if (arg.position !== undefined) { + style.position = arg.position; + } + if (arg.url !== undefined) { + style.url = arg.url; + } + if (arg.mapColor !== undefined) { + style.mapColor = arg.mapColor; + } + m_this.style(style); + m_this.dataTime().modified(); + }; + + return this; +}; + +inherit(pixelmapFeature, feature); +module.exports = pixelmapFeature; diff --git a/src/quadFeature.js b/src/quadFeature.js index b50c4600ba..bf0b41e3fa 100644 --- a/src/quadFeature.js +++ b/src/quadFeature.js @@ -138,10 +138,12 @@ var quadFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this.pointSearch = function (coordinate) { - var found = [], indices = [], data = m_this.data(), i, + var found = [], indices = [], basis = [], poly1 = [{}, {}, {}, {}], poly2 = [{}, {}, {}, {}], + order1 = [0, 1, 2, 0], order2 = [1, 2, 3, 1], + data = m_this.data(), map = m_this.layer().map(), - order1 = [0, 1, 2, 0], order2 = [1, 2, 3, 1]; + i, coordbasis; coordinate = transform.transformCoordinates( map.ingcs(), map.gcs(), coordinate); if (!m_quads) { @@ -161,12 +163,25 @@ var quadFeature = function (arg) { util.pointInPolygon(coordinate, poly2)) { indices.push(quad.idx); found.push(data[quad.idx]); + coordbasis = util.pointTo2DTriangleBasis( + coordinate, poly1[0], poly1[1], poly1[2]); + if (!coordbasis || coordbasis.x + coordbasis.y > 1) { + coordbasis = util.pointTo2DTriangleBasis( + coordinate, poly2[2], poly2[1], poly2[0]); + if (coordbasis) { + coordbasis.x = 1 - coordbasis.x; + } + } else { + coordbasis.y = 1 - coordbasis.y; + } + basis.push(coordbasis); } }); }); return { index: indices, - found: found + found: found, + basis: basis }; }; diff --git a/src/util/init.js b/src/util/init.js index 2cfd1b9591..60edc0213b 100644 --- a/src/util/init.js +++ b/src/util/init.js @@ -76,6 +76,33 @@ return inside; }, + /** + * Return a point in the basis of the triangle. If the point is located on + * a vertex of the triangle, it will be at vert0: (0, 0), vert1: (1, 0), + * vert2: (0, 1). If it is within the triangle, its coordinates will be + * 0 <= x <= 1, 0 <= y <= 1, x + y <= 1. + * + * @param {object} point: the point to convert. + * @param {object} vert0: vertex 0 of the triangle + * @param {object} vert1: vertex 1 (x direction) of the triangle + * @param {object} vert2: vertex 2 (y direction) of the triangle + * @returns {object} basisPoint: the point in the triangle basis, or + * undefined if the triangle is degenerate. + */ + pointTo2DTriangleBasis: function (point, vert0, vert1, vert2) { + var a = vert1.x - vert0.x, + b = vert2.x - vert0.x, + c = vert1.y - vert0.y, + d = vert2.y - vert0.y, + x = point.x - vert0.x, + y = point.y - vert0.y, + det = a * d - b * c; + if (!det) { + return; + } + return {x: (x * d - y * b) / det, y: (x * -c + y * a) / det}; + }, + /** * Returns true if the argument is a function. */ From ea5618954466646559734884e7d33f4f75048f90 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 13 Oct 2016 13:49:11 -0400 Subject: [PATCH 02/14] Fix issues with setting feature visibility. Only gl features honored visibility, and then only if they did not consist of secondary features. Visibility now works on all renderers and a concept of dependentFeatures has been added to group visibility for certain features. Some features still probably don't support visibility properly (such as the graphFeature), but that should be left for a different fix. --- src/canvas/canvasRenderer.js | 4 +++- src/d3/d3Renderer.js | 5 +++++ src/d3/lineFeature.js | 1 + src/d3/pathFeature.js | 1 + src/d3/pointFeature.js | 1 + src/d3/quadFeature.js | 1 + src/d3/vectorFeature.js | 1 + src/feature.js | 22 ++++++++++++++++++++-- src/pixelmapFeature.js | 9 +++++++-- src/polygonFeature.js | 10 ++++++++-- 10 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/canvas/canvasRenderer.js b/src/canvas/canvasRenderer.js index 2d0e8090ba..36600d011f 100644 --- a/src/canvas/canvasRenderer.js +++ b/src/canvas/canvasRenderer.js @@ -105,7 +105,9 @@ var canvasRenderer = function (arg) { var features = layer.features(); for (var i = 0; i < features.length; i += 1) { - features[i]._renderOnCanvas(m_this.context2d, map); + if (features[i].visible()) { + features[i]._renderOnCanvas(m_this.context2d, map); + } } }); } diff --git a/src/d3/d3Renderer.js b/src/d3/d3Renderer.js index ad70802fe0..e24b90d8c6 100644 --- a/src/d3/d3Renderer.js +++ b/src/d3/d3Renderer.js @@ -478,6 +478,7 @@ var d3Renderer = function (arg) { data: arg.data, index: arg.dataIndex, style: arg.style, + visible: arg.visible, attributes: arg.attributes, classes: arg.classes, append: arg.append, @@ -538,6 +539,7 @@ var d3Renderer = function (arg) { var data = m_features[id].data, index = m_features[id].index, style = m_features[id].style, + visible = m_features[id].visible, attributes = m_features[id].attributes, classes = m_features[id].classes, append = m_features[id].append, @@ -549,6 +551,9 @@ var d3Renderer = function (arg) { setAttrs(rendersel, attributes); rendersel.attr('class', classes.concat([id]).join(' ')); setStyles(rendersel, style); + if (visible) { + rendersel.style('visibility', visible() ? 'visible' : 'hidden'); + } if (entries.size() && m_features[id].sortByZ) { selection.sort(function (a, b) { return (a.zIndex || 0) - (b.zIndex || 0); diff --git a/src/d3/lineFeature.js b/src/d3/lineFeature.js index 0c101c3670..2e38f88d49 100644 --- a/src/d3/lineFeature.js +++ b/src/d3/lineFeature.js @@ -99,6 +99,7 @@ var d3_lineFeature = function (arg) { }, id: m_this._d3id() + idx, classes: ['d3LineFeature', 'd3SubLine-' + idx], + visible: m_this.visible, style: style }; diff --git a/src/d3/pathFeature.js b/src/d3/pathFeature.js index 26a49e5f0e..6360f5c623 100644 --- a/src/d3/pathFeature.js +++ b/src/d3/pathFeature.js @@ -94,6 +94,7 @@ var d3_pathFeature = function (arg) { 'fill': function () { return false; }, 'fillColor': function () { return { r: 0, g: 0, b: 0 }; } }, s_style); + m_style.visible = m_this.visible; m_this.renderer()._drawFeatures(m_style); diff --git a/src/d3/pointFeature.js b/src/d3/pointFeature.js index eb98fc43f8..9d9fc7253a 100644 --- a/src/d3/pointFeature.js +++ b/src/d3/pointFeature.js @@ -81,6 +81,7 @@ var d3_pointFeature = function (arg) { }; m_style.style = s_style; m_style.classes = ['d3PointFeature']; + m_style.visible = m_this.visible; // pass to renderer to draw m_this.renderer()._drawFeatures(m_style); diff --git a/src/d3/quadFeature.js b/src/d3/quadFeature.js index 34610d1152..236004e85d 100644 --- a/src/d3/quadFeature.js +++ b/src/d3/quadFeature.js @@ -182,6 +182,7 @@ var d3_quadFeature = function (arg) { }, onlyRenderNew: !this.style('previewColor') && !this.style('previewImage'), sortByZ: true, + visible: m_this.visible, classes: ['d3QuadFeature'] }; renderer._drawFeatures(feature); diff --git a/src/d3/vectorFeature.js b/src/d3/vectorFeature.js index 31e9492313..976748ee3b 100644 --- a/src/d3/vectorFeature.js +++ b/src/d3/vectorFeature.js @@ -242,6 +242,7 @@ var d3_vectorFeature = function (arg) { endStyle: s_style.endStyle }; m_style.classes = ['d3VectorFeature']; + m_style.visible = m_this.visible; // Add markers to the defition list updateMarkers(data, s_style.strokeColor, s_style.strokeOpacity, s_style.originStyle, s_style.endStyle); diff --git a/src/feature.js b/src/feature.js index 637f0d6fb5..9ecd1a70e0 100644 --- a/src/feature.js +++ b/src/feature.js @@ -41,6 +41,7 @@ var feature = function (arg) { m_dataTime = timestamp(), m_buildTime = timestamp(), m_updateTime = timestamp(), + m_dependentFeatures = [], m_selectedFeatures = []; //////////////////////////////////////////////////////////////////////////// @@ -397,7 +398,8 @@ var feature = function (arg) { this.visible = function (val) { if (val === undefined) { return m_visible; - } else { + } + if (m_visible !== val) { m_visible = val; m_this.modified(); @@ -407,9 +409,25 @@ var feature = function (arg) { } else { m_this._unbindMouseHandlers(); } + for (var i = 0; i < m_dependentFeatures.length; i += 1) { + m_dependentFeatures[i].visible(val); + } + } + return m_this; + }; - return m_this; + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set a list of dependent features. Dependent features have their + * visibility changed at the same time as the feature. + */ + //////////////////////////////////////////////////////////////////////////// + this.dependentFeatures = function (arg) { + if (arg === undefined) { + return m_dependentFeatures.slice(); } + m_dependentFeatures = arg.slice(); + return m_this; }; //////////////////////////////////////////////////////////////////////////// diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index fc69ccacfe..a328dea1b7 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -197,8 +197,12 @@ var pixelmapFeature = function (arg) { var destImage = new Image(); destImage.src = canvas.toDataURL(); if (!m_quadFeature) { - m_quadFeature = m_this.layer().createFeature( - 'quad', {selectionAPI: false, gcs: m_this.gcs()}); + m_quadFeature = m_this.layer().createFeature('quad', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible() + }); + m_this.dependentFeatures([m_quadFeature]); } m_quadFeature.style({image: destImage, position: m_this.style.get('position')}); m_quadFeature.data([{}]); @@ -230,6 +234,7 @@ var pixelmapFeature = function (arg) { if (m_quadFeature && m_this.layer()) { m_this.layer().deleteFeature(m_quadFeature); m_quadFeature = null; + m_this.dependentFeatures([]); } s_exit(); }; diff --git a/src/polygonFeature.js b/src/polygonFeature.js index d9f6ba53dd..afc28aafda 100644 --- a/src/polygonFeature.js +++ b/src/polygonFeature.js @@ -258,6 +258,7 @@ var polygonFeature = function (arg) { if (m_lineFeature && m_this.layer()) { m_this.layer().deleteFeature(m_lineFeature); m_lineFeature = null; + m_this.dependentFeatures([]); } return; } @@ -265,8 +266,12 @@ var polygonFeature = function (arg) { return; } if (!m_lineFeature) { - m_lineFeature = m_this.layer().createFeature( - 'line', {selectionAPI: false, gcs: this.gcs()}); + m_lineFeature = m_this.layer().createFeature('line', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible() + }); + m_this.dependentFeatures([m_lineFeature]); } var polyStyle = m_this.style(); m_lineFeature.style({ @@ -343,6 +348,7 @@ var polygonFeature = function (arg) { if (m_lineFeature && m_this.layer()) { m_this.layer().deleteFeature(m_lineFeature); m_lineFeature = null; + m_this.dependentFeatures([]); } s_exit(); }; From 9f5008e50f14b5008edc27c7c2348f935f7e3f1b Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 13 Oct 2016 08:02:31 -0400 Subject: [PATCH 03/14] Add and remove features on a feature layer. The features() function replaced features but without properly ensure dropped features were removed or new features were added. --- src/featureLayer.js | 86 ++++++++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/src/featureLayer.js b/src/featureLayer.js index 318f0bd560..5d4b799e87 100644 --- a/src/featureLayer.js +++ b/src/featureLayer.js @@ -36,6 +36,8 @@ var featureLayer = function (arg) { /** * Create feature give a name * + * @param {string} featureName the name of the feature to create + * @param {object} arg properties for the new feature * @returns {geo.Feature} Will return a new feature */ //////////////////////////////////////////////////////////////////////////// @@ -43,40 +45,64 @@ var featureLayer = function (arg) { var newFeature = registry.createFeature( featureName, m_this, m_this.renderer(), arg); + this.addFeature(newFeature); + return newFeature; + }; - m_this.addChild(newFeature); - m_features.push(newFeature); - m_this.features(m_features); + //////////////////////////////////////////////////////////////////////////// + /** + * Add a feature to the layer if it is not already present. + * + * @param {object} feature the feature to add. + */ + //////////////////////////////////////////////////////////////////////////// + this.addFeature = function (feature) { + /* try to remove the feature first so that we don't have two copies */ + this.removeFeature(feature); + m_this.addChild(feature); + m_features.push(feature); + m_this.dataTime().modified(); m_this.modified(); - return newFeature; + return m_this; }; //////////////////////////////////////////////////////////////////////////// /** - * Delete feature + * Remove feature without destroying it * + * @param {object} feature the feature to remove. */ //////////////////////////////////////////////////////////////////////////// - this.deleteFeature = function (feature) { - var i; + this.removeFeature = function (feature) { + var pos; - for (i = 0; i < m_features.length; i += 1) { - if (m_features[i] === feature) { - m_features[i]._exit(); - m_this.dataTime().modified(); - m_this.modified(); - } + pos = m_features.indexOf(feature); + if (pos >= 0) { + m_features.splice(pos, 1); + m_this.removeChild(feature); + m_this.dataTime().modified(); + m_this.modified(); } - // Loop through a second to time actually remove - // the given feature from the array because the - // `_exit` call above may mutate it. - for (i = 0; i < m_features.length; i += 1) { - if (m_features[i] === feature) { - m_features.splice(i, 1); + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Delete feature + * + * @param {object} feature the feature to delete. + */ + //////////////////////////////////////////////////////////////////////////// + this.deleteFeature = function (feature) { + + // call _exit first, as destroying the feature affect other features + if (feature) { + if (m_features.indexOf(feature) >= 0) { + feature._exit(); } + this.removeFeature(feature); } - m_this.removeChild(feature); return m_this; }; @@ -90,11 +116,21 @@ var featureLayer = function (arg) { //////////////////////////////////////////////////////////////////////////// this.features = function (val) { if (val === undefined) { - return m_features; + return m_features.slice(); } else { - m_features = val.slice(0); - m_this.dataTime().modified(); - m_this.modified(); + // delete existing features that aren't in the new array. Since features + // can affect other features during their exit process, make sure each + // feature still exists as we work through the list. + var existing = m_features.slice(); + var i; + for (i = 0; i < existing.length; i += 1) { + if (val.indexOf(existing) < 0 && m_features.indexOf(existing) >= 0) { + this.deleteFeature(existing); + } + } + for (i = 0; i < val.length; i += 1) { + this.addFeature(val[i]); + } return m_this; } }; @@ -225,10 +261,10 @@ var featureLayer = function (arg) { m_features[i]._exit(); m_this.removeChild(m_features[i]); } + m_features = []; m_this.dataTime().modified(); m_this.modified(); - m_features = []; return m_this; }; From 8886861da73dcc1d351c17836fa0de8416934035 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 13 Oct 2016 14:33:21 -0400 Subject: [PATCH 04/14] Add pixelmap example. --- examples/pixelmap/example.json | 9 ++++ examples/pixelmap/index.jade | 1 + examples/pixelmap/main.css | 0 examples/pixelmap/main.js | 89 ++++++++++++++++++++++++++++++++ examples/pixelmap/pixelmap.json | 1 + examples/pixelmap/pixelmap.png | Bin 0 -> 7308 bytes examples/pixelmap/thumb.jpg | Bin 0 -> 73634 bytes 7 files changed, 100 insertions(+) create mode 100644 examples/pixelmap/example.json create mode 100644 examples/pixelmap/index.jade create mode 100644 examples/pixelmap/main.css create mode 100644 examples/pixelmap/main.js create mode 100644 examples/pixelmap/pixelmap.json create mode 100755 examples/pixelmap/pixelmap.png create mode 100755 examples/pixelmap/thumb.jpg diff --git a/examples/pixelmap/example.json b/examples/pixelmap/example.json new file mode 100644 index 0000000000..c44abf81ff --- /dev/null +++ b/examples/pixelmap/example.json @@ -0,0 +1,9 @@ +{ + "path": "pixelmap", + "title": "Pixelmap feature", + "exampleCss": ["main.css"], + "exampleJs": ["main.js"], + "about": { + "text": "This example shows how to use a pixelmap feature. The pixelmap colors areas based on an index derived from an image and some data per index." + } +} diff --git a/examples/pixelmap/index.jade b/examples/pixelmap/index.jade new file mode 100644 index 0000000000..a2432ab791 --- /dev/null +++ b/examples/pixelmap/index.jade @@ -0,0 +1 @@ +extends ../common/templates/index.jade diff --git a/examples/pixelmap/main.css b/examples/pixelmap/main.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/pixelmap/main.js b/examples/pixelmap/main.js new file mode 100644 index 0000000000..c8caab585b --- /dev/null +++ b/examples/pixelmap/main.js @@ -0,0 +1,89 @@ +/* globals utils */ + +var debug = {}; + +// Run after the DOM loads +$(function () { + 'use strict'; + + // Get the query parameters and set controls appropriately. The query takes: + // map: the png file used for the pixel map. + // json: the json file for the starting state. + var query = utils.getQuery(); + var pixelmapUrl = query.map || 'pixelmap.png'; + var pixelmapJSON = query.json === undefined ? 'pixelmap.json' : query.json; + var map, mapParams, osm, osmParams, layer, pixelmap, pixelmapParams; + // Center the map on the United States + mapParams = { + node: '#map', + center: {x: -98.58333, y: 39.83333}, + zoom: 4, + discreteZoom: false + }; + map = geo.map(mapParams); + // Add a tile layer to the map + osm = map.createLayer('osm', osmParams); + // Create a feature layer that supports the pixelmap feature. This can be + // overridden to use a specific renderer + layer = map.createLayer('feature', { + renderer: query.renderer ? (query.renderer === 'html' ? null : query.renderer) : undefined, + features: query.renderer ? undefined : ['pixelmap'], + opacity: 0.65 + }); + // Our default pixelmap covers a known geographic region + pixelmapParams = { + selectionAPI: true, + url: pixelmapUrl, + position: {ul: {x: -180, y: 71.471178}, lr: {x: -60, y: 13.759032}}, + mapColor: function (d, idx) { + // Always set index 0 to transparent. Other indicies are set based on + // the data value + var color = {r: 0, g: 0, b: 0, a: 0}; + if (idx && d && d.value) { + color = d.value === 'R' ? 'red' : 'blue'; + } + return color; + } + }; + pixelmap = layer.createFeature('pixelmap', pixelmapParams); + layer.draw(); + // When the user left clicks, cycle through three states. A right click + // clears the state. + pixelmap.geoOn(geo.event.feature.mouseclick, function (evt) { + var data = pixelmap.data(); + if (!data) { + return; + } + if (data[evt.index] === undefined) { + data[evt.index] = {}; + } + var val = data[evt.index].value; + if (evt.mouse.buttonsDown.left) { + var cycle = {'D': 'R', 'R': '', '': 'D'}; + val = cycle[cycle[val] !== undefined ? val : '']; + } else if (evt.mouse.buttonsDown.right) { + val = ''; + } + if (val !== data[evt.index].value) { + data[evt.index].value = val; + pixelmap.data(data).draw(); + } + }); + // Load the JSON file + if (pixelmapJSON) { + $.ajax({url: pixelmapJSON}).done(function (resp) { + pixelmap.data(resp); + pixelmap.draw(); + }); + } + + // Expose some internal to make it easier to play with the example from the + // browser console. + debug.map = map; + debug.mapParams = mapParams; + debug.osm = osm; + debug.osmParams = osmParams; + debug.layer = layer; + debug.pixelmap = pixelmap; + debug.pixelmapParams = pixelmapParams; +}); diff --git a/examples/pixelmap/pixelmap.json b/examples/pixelmap/pixelmap.json new file mode 100644 index 0000000000..435312c07e --- /dev/null +++ b/examples/pixelmap/pixelmap.json @@ -0,0 +1 @@ +[{"name": "background"}, {"name": "Arizona", "value": "D"}, {"name": "Arkansas", "value": "R"}, {"name": "California", "value": "D"}, {"name": "Colorado", "value": "D"}, {"name": "Connecticut", "value": "D"}, {"name": "District of Columbia", "value": "D"}, {"name": "Georgia", "value": "R"}, {"name": "Hawaii", "value": "D"}, {"name": "Illinois", "value": "D"}, {"name": "Indiana", "value": "R"}, {"name": "Louisiana", "value": "R"}, {"name": "Minnesota", "value": "D"}, {"name": "Mississippi", "value": "R"}, {"name": "Montana", "value": "R"}, {"name": "New Mexico", "value": "D"}, {"name": "North Dakota", "value": "R"}, {"name": "Oklahoma", "value": "R"}, {"name": "Pennsylvania", "value": "D"}, {"name": "Tennessee", "value": "R"}, {"name": "Virginia", "value": "D"}, {"name": "Puerto Rico", "value": null}, {"name": "Delaware", "value": "D"}, {"name": "West Virginia", "value": "R"}, {"name": "Wisconsin", "value": "D"}, {"name": "Wyoming", "value": "R"}, {"name": "Alabama", "value": "R"}, {"name": "Alaska", "value": "R"}, {"name": "Florida", "value": "D"}, {"name": "Idaho", "value": "R"}, {"name": "Kansas", "value": "R"}, {"name": "Maryland", "value": "D"}, {"name": "New Jersey", "value": "D"}, {"name": "North Carolina", "value": "D"}, {"name": "South Carolina", "value": "R"}, {"name": "Washington", "value": "D"}, {"name": "Vermont", "value": "D"}, {"name": "Utah", "value": "R"}, {"name": "Iowa", "value": "D"}, {"name": "Kentucky", "value": "R"}, {"name": "Maine", "value": "D"}, {"name": "Massachusetts", "value": "D"}, {"name": "Michigan", "value": "D"}, {"name": "Missouri", "value": "R"}, {"name": "Nebraska", "value": "R"}, {"name": "Nevada", "value": "D"}, {"name": "New Hampshire", "value": "D"}, {"name": "New York", "value": "D"}, {"name": "Ohio", "value": "D"}, {"name": "Oregon", "value": "D"}, {"name": "Rhode Island", "value": "D"}, {"name": "South Dakota", "value": "R"}, {"name": "Texas", "value": "R"}] \ No newline at end of file diff --git a/examples/pixelmap/pixelmap.png b/examples/pixelmap/pixelmap.png new file mode 100755 index 0000000000000000000000000000000000000000..e9e33dd743931a756b6a69acf5fdedbdca2a7bc7 GIT binary patch literal 7308 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 literal 0 HcmV?d00001 diff --git a/examples/pixelmap/thumb.jpg b/examples/pixelmap/thumb.jpg new file mode 100755 index 0000000000000000000000000000000000000000..2ecc799e8ca6040bc2a80716c25c007c60334d87 GIT binary patch literal 73634 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 b%v);jcVMfXsu`Go03`RSWj*9gv$_A-Z0Q(G literal 0 HcmV?d00001 From c9a83d05c8d764a69ae5488792ff67d1c6da601a Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 13 Oct 2016 15:14:52 -0400 Subject: [PATCH 05/14] Added a maxIndex function. This returns the maximum value from the pixelmap index (not the number of distinct indices). ((maxIndex() + 1) is the useful length of the data array. Also, don't reload the index values from the pixelmap if it isn't necessary. When only the data updates, this saves drawing the pixelmap to a canvas. --- src/pixelmapFeature.js | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index a328dea1b7..0616c5cfac 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -87,6 +87,7 @@ var pixelmapFeature = function (arg) { return m_this.style('url'); } else if (val !== m_this.style('url')) { m_srcImage = m_pixelmapWidth = m_pixelmapHeight = undefined; + m_pixelmapIndices = undefined; m_this.style('url', val); m_this.dataTime().modified(); m_this.modified(); @@ -94,6 +95,24 @@ var pixelmapFeature = function (arg) { return m_this; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Get the maximum index value from the pixelmap. This is a value present in + * the pixelmap. This is a somewhat expensive call. + * + * @returns {geo.pixelmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.maxIndex = function () { + if (m_pixelmapIndices) { + var max = -1; + for (var i = 0; i < m_pixelmapIndices.length; i += 1) { + max = Math.max(max, m_pixelmapIndices[i]); + } + return max; + } + }; + //////////////////////////////////////////////////////////////////////////// /** * Get/Set mapColor accessor @@ -174,15 +193,22 @@ var pixelmapFeature = function (arg) { canvas.height = m_pixelmapHeight; var context = canvas.getContext('2d'); - context.drawImage(m_srcImage, 0, 0); + if (!m_pixelmapIndices) { + context.drawImage(m_srcImage, 0, 0); + } var imageData = context.getImageData(0, 0, canvas.width, canvas.height), pixelData = imageData.data, i, idx, color; - m_pixelmapIndices = new Array(pixelData.length / 4); + if (!m_pixelmapIndices) { + m_pixelmapIndices = new Array(pixelData.length / 4); + for (i = 0; i < pixelData.length; i += 4) { + idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); + m_pixelmapIndices[i / 4] = idx; + } + } for (i = 0; i < pixelData.length; i += 4) { - idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); - m_pixelmapIndices[i / 4] = idx; + idx = m_pixelmapIndices[i / 4]; if (m_mappedColors[idx] === undefined) { m_mappedColors[idx] = mapColorFunc(data[idx], idx) || {}; } From ef1f546b49f03496c06977ad619f875733377aa6 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 14 Oct 2016 12:02:47 -0400 Subject: [PATCH 06/14] Improve the speed of pixelmap's update. Significantly increase the speed by caching more and updating less. Specifically, the working canvas is maintained between updates, and only changed portions are updated. canvas.toBlob is used in preference to canvas.toDataURL if it is available. maxIndex is cached. Added a fallback for boxSearch to the feature class to suppress some console warnings. --- src/feature.js | 10 +++ src/pixelmapFeature.js | 184 ++++++++++++++++++++++++++++------------- 2 files changed, 135 insertions(+), 59 deletions(-) diff --git a/src/feature.js b/src/feature.js index 9ecd1a70e0..296ee9aea4 100644 --- a/src/feature.js +++ b/src/feature.js @@ -120,6 +120,16 @@ var feature = function (arg) { }; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Returns an array of line indices that are contained in the given box. + */ + //////////////////////////////////////////////////////////////////////////// + this.boxSearch = function (lowerLeft, upperRight, opts) { + // base class method does nothing + return []; + }; + //////////////////////////////////////////////////////////////////////////// /** * Private mousemove handler diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index 0616c5cfac..0c8be672e9 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -48,11 +48,8 @@ var pixelmapFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// var m_this = this, m_quadFeature, - m_mappedColors, - m_pixelmapWidth, - m_pixelmapHeight, - m_pixelmapIndices, m_srcImage, + m_info, s_update = this._update, s_init = this._init, s_exit = this._exit; @@ -86,8 +83,7 @@ var pixelmapFeature = function (arg) { if (val === undefined) { return m_this.style('url'); } else if (val !== m_this.style('url')) { - m_srcImage = m_pixelmapWidth = m_pixelmapHeight = undefined; - m_pixelmapIndices = undefined; + m_srcImage = m_info = undefined; m_this.style('url', val); m_this.dataTime().modified(); m_this.modified(); @@ -98,18 +94,23 @@ var pixelmapFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// /** * Get the maximum index value from the pixelmap. This is a value present in - * the pixelmap. This is a somewhat expensive call. + * the pixelmap. * * @returns {geo.pixelmap} */ //////////////////////////////////////////////////////////////////////////// this.maxIndex = function () { - if (m_pixelmapIndices) { - var max = -1; - for (var i = 0; i < m_pixelmapIndices.length; i += 1) { - max = Math.max(max, m_pixelmapIndices[i]); + if (m_info) { + /* This isn't just m_info.mappedColors.length - 1, since there + * may be more data than actual indices. */ + if (m_info.maxIndex === undefined) { + var max = 0; + for (var i = 0; i < m_info.indices.length; i += 1) { + max = Math.max(max, m_info.indices[i]); + } + m_info.maxIndex = max; } - return max; + return m_info.maxIndex; } }; @@ -137,17 +138,19 @@ var pixelmapFeature = function (arg) { * can be included in the found results. */ this.pointSearch = function (coordinate) { - if (m_quadFeature) { + if (m_quadFeature && m_info) { var result = m_quadFeature.pointSearch(coordinate); if (result.basis.length === 1) { - var x = result.basis[0].x, y = result.basis[0].y; - x = Math.floor(x * m_pixelmapWidth); - y = Math.floor(y * m_pixelmapHeight); - if (x >= 0 && x < m_pixelmapWidth && y >= 0 && y < m_pixelmapHeight) { + var x = result.basis[0].x, y = result.basis[0].y, idx; + x = Math.floor(x * m_info.width); + y = Math.floor(y * m_info.height); + if (x >= 0 && x < m_info.width && + y >= 0 && y < m_info.height) { + idx = m_info.indices[y * m_info.width + x]; result = { - index: [m_pixelmapIndices[y * m_pixelmapWidth + x]] + index: [idx], + found: [m_this.data()[idx]] }; - result.found = [m_this.data()[result.index[0]]]; return result; } } @@ -161,15 +164,12 @@ var pixelmapFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this._build = function () { - var data = m_this.data() || []; - - m_mappedColors = new Array(data.length); if (!m_srcImage) { m_srcImage = new Image(); m_srcImage.crossOrigin = m_this.style.get('crossDomain')() || 'anonymous'; m_srcImage.onload = this._computePixelmap; m_srcImage.src = m_this.style.get('url')(); - } else if (m_pixelmapWidth) { + } else if (m_info) { this._computePixelmap(); } m_this.buildTime().modified(); @@ -183,56 +183,122 @@ var pixelmapFeature = function (arg) { */ this._computePixelmap = function () { var data = m_this.data() || [], - mapColorFunc = m_this.style.get('mapColor'); + mapColorFunc = m_this.style.get('mapColor'), + i, idx, color, pixelData, indices, mappedColors, + updateIdx, update = false, oldColors, destImage; - m_pixelmapWidth = m_srcImage.naturalWidth; - m_pixelmapHeight = m_srcImage.naturalHeight; + if (!m_info) { + /* If we haven't compute information for this pixelmap image, do so. + * This is invalidated by a change in the image. */ + m_info = { + width: m_srcImage.naturalWidth, + height: m_srcImage.naturalHeight, + canvas: document.createElement('canvas'), + updateIdx: new Array(data.length) + }; - var canvas = document.createElement('canvas'); - canvas.width = m_pixelmapWidth; - canvas.height = m_pixelmapHeight; - var context = canvas.getContext('2d'); + m_info.canvas.width = m_info.width; + m_info.canvas.height = m_info.height; + m_info.context = m_info.canvas.getContext('2d'); - if (!m_pixelmapIndices) { - context.drawImage(m_srcImage, 0, 0); - } - var imageData = context.getImageData(0, 0, canvas.width, canvas.height), - pixelData = imageData.data, - i, idx, color; - if (!m_pixelmapIndices) { - m_pixelmapIndices = new Array(pixelData.length / 4); + m_info.context.drawImage(m_srcImage, 0, 0); + m_info.imageData = m_info.context.getImageData( + 0, 0, m_info.canvas.width, m_info.canvas.height); + pixelData = m_info.imageData.data; + m_info.indices = new Array(pixelData.length / 4); for (i = 0; i < pixelData.length; i += 4) { idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); - m_pixelmapIndices[i / 4] = idx; + m_info.indices[i / 4] = idx; } + } else { + /* Otherwise, just get a local reference to our data */ + pixelData = m_info.imageData.data; } + updateIdx = m_info.updateIdx; + oldColors = m_info.mappedColors; + /* We need to determine which colors have been computed and which have + * changed. If memory churn is a factor, this could be refactored to + * update the array instead. */ + mappedColors = m_info.mappedColors = new Array(data.length); + indices = m_info.indices; for (i = 0; i < pixelData.length; i += 4) { - idx = m_pixelmapIndices[i / 4]; - if (m_mappedColors[idx] === undefined) { - m_mappedColors[idx] = mapColorFunc(data[idx], idx) || {}; + idx = indices[i / 4]; + if (mappedColors[idx] === undefined) { + color = mapColorFunc(data[idx], idx) || {}; + color = [ + (color.r || 0) * 255, + (color.g || 0) * 255, + (color.b || 0) * 255, + color.a === undefined ? 255 : (color.a * 255) + ]; + updateIdx[idx] = ( + !oldColors || !oldColors[idx] || + oldColors[idx][0] !== color[0] || + oldColors[idx][1] !== color[1] || + oldColors[idx][2] !== color[2] || + oldColors[idx][3] !== color[3]); + mappedColors[idx] = color; + update = update || updateIdx[idx]; } - color = m_mappedColors[idx] || {}; - pixelData[i] = (color.r || 0) * 255; - pixelData[i + 1] = (color.g || 0) * 255; - pixelData[i + 2] = (color.b || 0) * 255; - pixelData[i + 3] = color.a === undefined ? 255 : (color.a * 255); + if (update && updateIdx[idx]) { + color = mappedColors[idx]; + pixelData[i] = color[0]; + pixelData[i + 1] = color[1]; + pixelData[i + 2] = color[2]; + pixelData[i + 3] = color[3]; + } + } + /* If nothing was updated, we are done */ + if (!update) { + return; } - context.putImageData(imageData, 0, 0); + m_info.context.putImageData(m_info.imageData, 0, 0); - var destImage = new Image(); - destImage.src = canvas.toDataURL(); - if (!m_quadFeature) { - m_quadFeature = m_this.layer().createFeature('quad', { - selectionAPI: false, - gcs: m_this.gcs(), - visible: m_this.visible() + if (m_info.destImage) { + m_info.destImage._ignore = true; + } + /* We have a local reference to the destination image so that we can cancel + * processing an old image if it is no longer wanted. When using + * canvas.toBlob, image loading is asynchronous (whereas canvas.toDataURL + * is synchronous). This has speed benefits, but means that two updates in + * a short time could be called where the older update is not desired. */ + destImage = m_info.destImage = new Image(); + var prev_onload = destImage.onload, + url; + destImage.onload = function () { + if (url) { + URL.revokeObjectURL(url); + } + if (destImage._ignore) { + return; + } + if (prev_onload) { + return prev_onload.apply(this, arguments); + } + if (!m_quadFeature) { + m_quadFeature = m_this.layer().createFeature('quad', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible() + }); + m_quadFeature.data([{}]); + m_this.dependentFeatures([m_quadFeature]); + } + m_quadFeature.style({image: destImage, + position: m_this.style.get('position')}) + .data([{}]) + .draw(); + }; + /* Not all browsers support toBlob, so use toDataURL as a fallback */ + if (m_info.canvas.toBlob) { + m_info.canvas.toBlob(function (blob) { + url = URL.createObjectURL(blob); + destImage.src = url; }); - m_this.dependentFeatures([m_quadFeature]); + } else { + destImage.src = m_info.canvas.toDataURL(); } - m_quadFeature.style({image: destImage, position: m_this.style.get('position')}); - m_quadFeature.data([{}]); - m_quadFeature.draw(); }; //////////////////////////////////////////////////////////////////////////// From 7eda6534e61277c8d624232ed9fff8bdb9a2165e Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 14 Oct 2016 16:08:02 -0400 Subject: [PATCH 07/14] More speed improvements for updates. Use canvas properties for faster drawing of the final image. Compute only the extent of the changed area and only update that extent. This computes colors in one pass and updated pixels in a second pass rather than computing them in the same loop. The first render is probably very slightly slower. --- src/canvas/quadFeature.js | 1 + src/d3/index.js | 1 - src/d3/pixelmapFeature.js | 34 ------- src/d3/quadFeature.js | 1 + src/gl/index.js | 1 - src/gl/pixelmapFeature.js | 33 ------- src/gl/quadFeature.js | 1 + src/pixelmapFeature.js | 189 ++++++++++++++++++-------------------- src/quadFeature.js | 9 +- 9 files changed, 100 insertions(+), 170 deletions(-) delete mode 100644 src/d3/pixelmapFeature.js delete mode 100644 src/gl/pixelmapFeature.js diff --git a/src/canvas/quadFeature.js b/src/canvas/quadFeature.js index 5c56677135..08845fe1b6 100644 --- a/src/canvas/quadFeature.js +++ b/src/canvas/quadFeature.js @@ -153,6 +153,7 @@ capabilities[quadFeature.capabilities.color] = false; capabilities[quadFeature.capabilities.image] = true; capabilities[quadFeature.capabilities.imageCrop] = true; capabilities[quadFeature.capabilities.imageFull] = false; +capabilities[quadFeature.capabilities.canvas] = true; registerFeature('canvas', 'quad', canvas_quadFeature, capabilities); module.exports = canvas_quadFeature; diff --git a/src/d3/index.js b/src/d3/index.js index 3da1fad27f..1068e29a4a 100644 --- a/src/d3/index.js +++ b/src/d3/index.js @@ -11,7 +11,6 @@ module.exports = { lineFeature: require('./lineFeature'), object: require('./object'), pathFeature: require('./pathFeature'), - pixelmapFeature: require('./pixelmapFeature'), pointFeature: require('./pointFeature'), quadFeature: require('./quadFeature'), renderer: require('./d3Renderer'), diff --git a/src/d3/pixelmapFeature.js b/src/d3/pixelmapFeature.js deleted file mode 100644 index 4165040b95..0000000000 --- a/src/d3/pixelmapFeature.js +++ /dev/null @@ -1,34 +0,0 @@ -var inherit = require('../inherit'); -var registerFeature = require('../registry').registerFeature; -var pixelmapFeature = require('../pixelmapFeature'); - -////////////////////////////////////////////////////////////////////////////// -/** - * Create a new instance of class pixelmapFeature - * - * @class geo.d3.pixelmapFeature - * @param {Object} arg Options object - * @extends geo.pixelmapFeature - * @returns {d3_pixelmapFeature} - */ -////////////////////////////////////////////////////////////////////////////// -var d3_pixelmapFeature = function (arg) { - 'use strict'; - - if (!(this instanceof d3_pixelmapFeature)) { - return new d3_pixelmapFeature(arg); - } - pixelmapFeature.call(this, arg); - - var object = require('./object'); - object.call(this); - - this._init(arg); - return this; -}; - -inherit(d3_pixelmapFeature, pixelmapFeature); - -// Now register it -registerFeature('d3', 'pixelmap', d3_pixelmapFeature); -module.exports = d3_pixelmapFeature; diff --git a/src/d3/quadFeature.js b/src/d3/quadFeature.js index 236004e85d..1b82e6352e 100644 --- a/src/d3/quadFeature.js +++ b/src/d3/quadFeature.js @@ -234,6 +234,7 @@ capabilities[quadFeature.capabilities.color] = true; capabilities[quadFeature.capabilities.image] = true; capabilities[quadFeature.capabilities.imageCrop] = false; capabilities[quadFeature.capabilities.imageFull] = false; +capabilities[quadFeature.capabilities.canvas] = false; registerFeature('d3', 'quad', d3_quadFeature, capabilities); module.exports = d3_quadFeature; diff --git a/src/gl/index.js b/src/gl/index.js index 9e781a0ab2..1fa5da165d 100644 --- a/src/gl/index.js +++ b/src/gl/index.js @@ -7,7 +7,6 @@ module.exports = { ellipsoid: require('./ellipsoid'), geomFeature: require('./geomFeature'), lineFeature: require('./lineFeature'), - pixelmapFeature: require('./pixelmapFeature'), pointFeature: require('./pointFeature'), polygonFeature: require('./polygonFeature'), quadFeature: require('./quadFeature'), diff --git a/src/gl/pixelmapFeature.js b/src/gl/pixelmapFeature.js deleted file mode 100644 index fe26c400ec..0000000000 --- a/src/gl/pixelmapFeature.js +++ /dev/null @@ -1,33 +0,0 @@ -var inherit = require('../inherit'); -var registerFeature = require('../registry').registerFeature; -var pixelmapFeature = require('../pixelmapFeature'); - -////////////////////////////////////////////////////////////////////////////// -/** - * Create a new instance of class pixelmapFeature - * - * @class geo.gl.pixelmapFeature - * @param {Object} arg Options object - * @extends geo.pixelmapFeature - * @returns {gl_pixelmapFeature} - */ -////////////////////////////////////////////////////////////////////////////// -var gl_pixelmapFeature = function (arg) { - 'use strict'; - - if (!(this instanceof gl_pixelmapFeature)) { - return new gl_pixelmapFeature(arg); - } - pixelmapFeature.call(this, arg); - var object = require('./object'); - object.call(this); - - this._init(arg); - return this; -}; - -inherit(gl_pixelmapFeature, pixelmapFeature); - -// Now register it -registerFeature('vgl', 'pixelmap', gl_pixelmapFeature); -module.exports = gl_pixelmapFeature; diff --git a/src/gl/quadFeature.js b/src/gl/quadFeature.js index 5d1279a1f7..1660e37567 100644 --- a/src/gl/quadFeature.js +++ b/src/gl/quadFeature.js @@ -422,6 +422,7 @@ capabilities[quadFeature.capabilities.color] = true; capabilities[quadFeature.capabilities.image] = true; capabilities[quadFeature.capabilities.imageCrop] = true; capabilities[quadFeature.capabilities.imageFull] = true; +capabilities[quadFeature.capabilities.canvas] = false; registerFeature('vgl', 'quad', gl_quadFeature, capabilities); module.exports = gl_quadFeature; diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index 0c8be672e9..e9286ab734 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -104,11 +104,12 @@ var pixelmapFeature = function (arg) { /* This isn't just m_info.mappedColors.length - 1, since there * may be more data than actual indices. */ if (m_info.maxIndex === undefined) { - var max = 0; - for (var i = 0; i < m_info.indices.length; i += 1) { - max = Math.max(max, m_info.indices[i]); + m_info.maxIndex = 0; + for (var idx in m_info.mappedColors) { + if (m_info.mappedColors.hasOwnProperty(idx)) { + m_info.maxIndex = Math.max(m_info.maxIndex, idx); + } } - m_info.maxIndex = max; } return m_info.maxIndex; } @@ -176,6 +177,43 @@ var pixelmapFeature = function (arg) { return m_this; }; + /** + * Compute information for this pixelmap image. It is wasterful to call this + * if the pixelmap has already been prepared (it is invalidated by a change + * in the image). + */ + this._preparePixelmap = function () { + var i, idx, pixelData; + + m_info = { + width: m_srcImage.naturalWidth, + height: m_srcImage.naturalHeight, + canvas: document.createElement('canvas'), + updateIdx: {} + }; + + m_info.canvas.width = m_info.width; + m_info.canvas.height = m_info.height; + m_info.context = m_info.canvas.getContext('2d'); + + m_info.context.drawImage(m_srcImage, 0, 0); + m_info.imageData = m_info.context.getImageData( + 0, 0, m_info.canvas.width, m_info.canvas.height); + pixelData = m_info.imageData.data; + m_info.indices = new Array(pixelData.length / 4); + m_info.area = pixelData.length / 4; + + m_info.mappedColors = {}; + for (i = 0; i < pixelData.length; i += 4) { + idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); + m_info.indices[i / 4] = idx; + if (!m_info.mappedColors[idx]) { + m_info.mappedColors[idx] = {first: i / 4}; + } + m_info.mappedColors[idx].last = i / 4; + } + }; + /** * Given the loaded pixelmap image, create a canvas the size of the image. * Compute a color for each distinct index and recolor the canvas based on @@ -184,47 +222,16 @@ var pixelmapFeature = function (arg) { this._computePixelmap = function () { var data = m_this.data() || [], mapColorFunc = m_this.style.get('mapColor'), - i, idx, color, pixelData, indices, mappedColors, - updateIdx, update = false, oldColors, destImage; + i, idx, lastidx, color, pixelData, indices, mappedColors, + updateFirst, updateLast = -1, update; if (!m_info) { - /* If we haven't compute information for this pixelmap image, do so. - * This is invalidated by a change in the image. */ - m_info = { - width: m_srcImage.naturalWidth, - height: m_srcImage.naturalHeight, - canvas: document.createElement('canvas'), - updateIdx: new Array(data.length) - }; - - m_info.canvas.width = m_info.width; - m_info.canvas.height = m_info.height; - m_info.context = m_info.canvas.getContext('2d'); - - m_info.context.drawImage(m_srcImage, 0, 0); - m_info.imageData = m_info.context.getImageData( - 0, 0, m_info.canvas.width, m_info.canvas.height); - pixelData = m_info.imageData.data; - m_info.indices = new Array(pixelData.length / 4); - - for (i = 0; i < pixelData.length; i += 4) { - idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); - m_info.indices[i / 4] = idx; - } - } else { - /* Otherwise, just get a local reference to our data */ - pixelData = m_info.imageData.data; + m_this._preparePixelmap(); } - updateIdx = m_info.updateIdx; - oldColors = m_info.mappedColors; - /* We need to determine which colors have been computed and which have - * changed. If memory churn is a factor, this could be refactored to - * update the array instead. */ - mappedColors = m_info.mappedColors = new Array(data.length); - indices = m_info.indices; - for (i = 0; i < pixelData.length; i += 4) { - idx = indices[i / 4]; - if (mappedColors[idx] === undefined) { + mappedColors = m_info.mappedColors; + updateFirst = m_info.area; + for (idx in mappedColors) { + if (mappedColors.hasOwnProperty(idx)) { color = mapColorFunc(data[idx], idx) || {}; color = [ (color.r || 0) * 255, @@ -232,72 +239,58 @@ var pixelmapFeature = function (arg) { (color.b || 0) * 255, color.a === undefined ? 255 : (color.a * 255) ]; - updateIdx[idx] = ( - !oldColors || !oldColors[idx] || - oldColors[idx][0] !== color[0] || - oldColors[idx][1] !== color[1] || - oldColors[idx][2] !== color[2] || - oldColors[idx][3] !== color[3]); - mappedColors[idx] = color; - update = update || updateIdx[idx]; - } - if (update && updateIdx[idx]) { - color = mappedColors[idx]; - pixelData[i] = color[0]; - pixelData[i + 1] = color[1]; - pixelData[i + 2] = color[2]; - pixelData[i + 3] = color[3]; + mappedColors[idx].update = ( + !mappedColors[idx].color || + mappedColors[idx].color[0] !== color[0] || + mappedColors[idx].color[1] !== color[1] || + mappedColors[idx].color[2] !== color[2] || + mappedColors[idx].color[3] !== color[3]); + if (mappedColors[idx].update) { + mappedColors[idx].color = color; + updateFirst = Math.min(mappedColors[idx].first, updateFirst); + updateLast = Math.max(mappedColors[idx].last, updateLast); + } } } /* If nothing was updated, we are done */ - if (!update) { + if (updateFirst >= updateLast) { return; } - m_info.context.putImageData(m_info.imageData, 0, 0); - - if (m_info.destImage) { - m_info.destImage._ignore = true; - } - /* We have a local reference to the destination image so that we can cancel - * processing an old image if it is no longer wanted. When using - * canvas.toBlob, image loading is asynchronous (whereas canvas.toDataURL - * is synchronous). This has speed benefits, but means that two updates in - * a short time could be called where the older update is not desired. */ - destImage = m_info.destImage = new Image(); - var prev_onload = destImage.onload, - url; - destImage.onload = function () { - if (url) { - URL.revokeObjectURL(url); - } - if (destImage._ignore) { - return; - } - if (prev_onload) { - return prev_onload.apply(this, arguments); + /* Update only the extent that has changed */ + pixelData = m_info.imageData.data; + indices = m_info.indices; + for (i = updateFirst; i <= updateLast; i += 1) { + idx = indices[i]; + if (idx !== lastidx) { + lastidx = idx; + color = mappedColors[idx].color; + update = mappedColors[idx].update; } - if (!m_quadFeature) { - m_quadFeature = m_this.layer().createFeature('quad', { - selectionAPI: false, - gcs: m_this.gcs(), - visible: m_this.visible() - }); - m_quadFeature.data([{}]); - m_this.dependentFeatures([m_quadFeature]); + if (update) { + pixelData[i * 4] = color[0]; + pixelData[i * 4 + 1] = color[1]; + pixelData[i * 4 + 2] = color[2]; + pixelData[i * 4 + 3] = color[3]; } - m_quadFeature.style({image: destImage, + } + /* Place the updated area into the canvas */ + m_info.context.putImageData( + m_info.imageData, 0, 0, 0, Math.floor(updateFirst / m_info.width), + m_info.width, Math.ceil((updateLast + 1) / m_info.width)); + + /* If we haven't made a quad feature, make one now. The quad feature needs + * to have the canvas capability. */ + if (!m_quadFeature) { + m_quadFeature = m_this.layer().createFeature('quad', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible() + }); + m_this.dependentFeatures([m_quadFeature]); + m_quadFeature.style({image: m_info.canvas, position: m_this.style.get('position')}) .data([{}]) .draw(); - }; - /* Not all browsers support toBlob, so use toDataURL as a fallback */ - if (m_info.canvas.toBlob) { - m_info.canvas.toBlob(function (blob) { - url = URL.createObjectURL(blob); - destImage.src = url; - }); - } else { - destImage.src = m_info.canvas.toDataURL(); } }; diff --git a/src/quadFeature.js b/src/quadFeature.js index bf0b41e3fa..4c83387265 100644 --- a/src/quadFeature.js +++ b/src/quadFeature.js @@ -346,7 +346,7 @@ var quadFeature = function (arg) { } else { image = m_this._objectListGet(m_images, img); if (image === undefined) { - if (img instanceof Image) { + if (img instanceof Image || img instanceof HTMLCanvasElement) { image = img; } else { image = new Image(); @@ -365,7 +365,8 @@ var quadFeature = function (arg) { if (d.crop) { quad.crop = d.crop; } - if (image.complete && image.naturalWidth && image.naturalHeight) { + if ((image.complete && image.naturalWidth && image.naturalHeight) || + image instanceof HTMLCanvasElement) { quad.image = image; } else { previewColor = undefined; @@ -495,7 +496,9 @@ quadFeature.capabilities = { /* support for cropping quad images */ imageCrop: 'quad.imageCrop', /* support for arbitrary quad images */ - imageFull: 'quad.imageFull' + imageFull: 'quad.imageFull', + /* support for canvas as images */ + canvas: 'quad.canvas' }; inherit(quadFeature, feature); From 3d0d00aff3f516a087ebc5471d26f55805ddf630 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 14 Oct 2016 16:35:39 -0400 Subject: [PATCH 08/14] Add a event.pixelmap.prepared event. --- src/event.js | 12 ++++++++++++ src/pixelmapFeature.js | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/event.js b/src/event.js index d9f323c092..c349a10180 100644 --- a/src/event.js +++ b/src/event.js @@ -367,6 +367,18 @@ geo_event.feature = { brush: 'geo_feature_brush' }; +//////////////////////////////////////////////////////////////////////////// +/** + * These events are triggered by the pixelmap feature. + * @namespace geo.event.pixelmap + */ +//////////////////////////////////////////////////////////////////////////// +geo_event.pixelmap = { + /* The image associated with the pixel map url has been prepared and rendered + * once. */ + prepared: 'geo_pixelmap_prepared' +}; + //////////////////////////////////////////////////////////////////////////// /** * These events are triggered by the camera when it's internal state is diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index e9286ab734..4909b4c838 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -1,6 +1,7 @@ var $ = require('jquery'); var inherit = require('./inherit'); var feature = require('./feature'); +var geo_event = require('./event'); ////////////////////////////////////////////////////////////////////////////// /** @@ -223,10 +224,11 @@ var pixelmapFeature = function (arg) { var data = m_this.data() || [], mapColorFunc = m_this.style.get('mapColor'), i, idx, lastidx, color, pixelData, indices, mappedColors, - updateFirst, updateLast = -1, update; + updateFirst, updateLast = -1, update, prepared; if (!m_info) { m_this._preparePixelmap(); + prepared = true; } mappedColors = m_info.mappedColors; updateFirst = m_info.area; @@ -292,6 +294,12 @@ var pixelmapFeature = function (arg) { .data([{}]) .draw(); } + /* If we prepared the pixelmap and rendered it, send a prepared event */ + if (prepared) { + m_this.geoTrigger(geo_event.pixelmap.prepared, { + pixelmap: m_this + }); + } }; //////////////////////////////////////////////////////////////////////////// From 8060563410f3daf9b238892847a4764ba49215c0 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 17 Oct 2016 14:39:07 -0400 Subject: [PATCH 09/14] When constructing a mapInteractor with options, make sure the whole set is replaced. --- src/mapInteractor.js | 8 ++++++++ src/tileLayer.js | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/mapInteractor.js b/src/mapInteractor.js index ae4803ac52..c4f641b520 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -155,6 +155,14 @@ var mapInteractor = function (args) { }, m_options ); + /* We don't want to merge the original arrays array with a array passed in + * the args, so override that as necessary for actions. */ + if (args && args.actions) { + m_options.actions = $.extend(true, [], args.actions); + } + if (args && args.momentum && args.momentum.actions) { + m_options.momentum.actions = $.extend(true, [], args.momentum.actions); + } // options supported: // { diff --git a/src/tileLayer.js b/src/tileLayer.js index 4fad9f6483..c61c6f587a 100644 --- a/src/tileLayer.js +++ b/src/tileLayer.js @@ -122,7 +122,7 @@ module.exports = (function () { * This function takes a zoom level argument and returns, in units of * pixels, the coordinates of the point (0, 0) at the given zoom level * relative to the bottom left corner of the domain. - * @param {function} [options.tileMaxBounds=null] + * @param {function} [options.tilesMaxBounds=null] * This function takes a zoom level argument and returns, in units of * pixels, the top, left, right, and bottom maximum value for which tiles * should be drawn at the given zoom level relative to the bottom left From d98ca30e1124f2109abb5195338fb0d064cea4db Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 25 Oct 2016 08:39:34 -0400 Subject: [PATCH 10/14] Several adjustments to align with other PRs. Some changes were made to the quad feature and other details in PRs that haven't been merged in but affect the new pixelmap feature. This adjusts for them (mainly using the extra parameter on found points). --- src/feature.js | 10 ++++++++-- src/pixelmapFeature.js | 8 ++++---- src/quadFeature.js | 8 +++++--- src/util/init.js | 5 ++--- tests/cases/feature.js | 18 ++++++++++++++++++ tests/cases/polygonFeature.js | 19 +++++++++++++++++++ tests/cases/quadFeature.js | 17 +++++++++++++++-- 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/feature.js b/src/feature.js index d41c94bf23..42bdcf106c 100644 --- a/src/feature.js +++ b/src/feature.js @@ -139,12 +139,13 @@ var feature = function (arg) { var mouse = m_this.layer().map().interactor().mouse(), data = m_this.data(), over = m_this.pointSearch(mouse.geo), - newFeatures = [], oldFeatures = [], lastTop = -1, top = -1; + newFeatures = [], oldFeatures = [], lastTop = -1, top = -1, extra; // exit if we have no old or new found entries if (!m_selectedFeatures.length && !over.index.length) { return; } + extra = over.extra || {}; // Get the index of the element that was previously on top if (m_selectedFeatures.length) { lastTop = m_selectedFeatures[m_selectedFeatures.length - 1]; @@ -164,6 +165,7 @@ var feature = function (arg) { m_this.geoTrigger(geo_event.feature.mouseover, { data: data[i], index: i, + extra: extra[i], mouse: mouse, eventID: feature.eventID, top: idx === newFeatures.length - 1 @@ -188,6 +190,7 @@ var feature = function (arg) { m_this.geoTrigger(geo_event.feature.mousemove, { data: data[i], index: i, + extra: extra[i], mouse: mouse, eventID: feature.eventID, top: idx === over.index.length - 1 @@ -216,6 +219,7 @@ var feature = function (arg) { m_this.geoTrigger(geo_event.feature.mouseon, { data: data[top], index: top, + extra: extra[top], mouse: mouse }, true); } @@ -230,7 +234,8 @@ var feature = function (arg) { this._handleMouseclick = function (evt) { var mouse = m_this.layer().map().interactor().mouse(), data = m_this.data(), - over = m_this.pointSearch(mouse.geo); + over = m_this.pointSearch(mouse.geo), + extra = over.extra || {}; mouse.buttonsDown = evt.buttonsDown; feature.eventID += 1; @@ -238,6 +243,7 @@ var feature = function (arg) { m_this.geoTrigger(geo_event.feature.mouseclick, { data: data[i], index: i, + extra: extra[i], mouse: mouse, eventID: feature.eventID, top: idx === over.index.length - 1 diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index 4909b4c838..a48fe49625 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -142,10 +142,10 @@ var pixelmapFeature = function (arg) { this.pointSearch = function (coordinate) { if (m_quadFeature && m_info) { var result = m_quadFeature.pointSearch(coordinate); - if (result.basis.length === 1) { - var x = result.basis[0].x, y = result.basis[0].y, idx; - x = Math.floor(x * m_info.width); - y = Math.floor(y * m_info.height); + if (result.index.length === 1 && result.extra && result.extra[result.index[0]].basis) { + var basis = result.extra[result.index[0]].basis, x, y, idx; + x = Math.floor(basis.x * m_info.width); + y = Math.floor(basis.y * m_info.height); if (x >= 0 && x < m_info.width && y >= 0 && y < m_info.height) { idx = m_info.indices[y * m_info.width + x]; diff --git a/src/quadFeature.js b/src/quadFeature.js index 125066500b..a5c4430e44 100644 --- a/src/quadFeature.js +++ b/src/quadFeature.js @@ -138,7 +138,7 @@ var quadFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this.pointSearch = function (coordinate) { - var found = [], indices = [], basis = [], + var found = [], indices = [], extra = {}, poly1 = [{}, {}, {}, {}], poly2 = [{}, {}, {}, {}], order1 = [0, 1, 2, 0], order2 = [1, 2, 3, 1], data = m_this.data(), @@ -174,14 +174,16 @@ var quadFeature = function (arg) { } else { coordbasis.y = 1 - coordbasis.y; } - basis.push(coordbasis); + if (coordbasis) { + extra[quad.idx] = {basis: coordbasis}; + } } }); }); return { index: indices, found: found, - basis: basis + extra: extra }; }; diff --git a/src/util/init.js b/src/util/init.js index 4f23151b23..c5f601aeaf 100644 --- a/src/util/init.js +++ b/src/util/init.js @@ -97,10 +97,9 @@ x = point.x - vert0.x, y = point.y - vert0.y, det = a * d - b * c; - if (!det) { - return; + if (det) { + return {x: (x * d - y * b) / det, y: (x * -c + y * a) / det}; } - return {x: (x * d - y * b) / det, y: (x * -c + y * a) / det}; }, /** diff --git a/tests/cases/feature.js b/tests/cases/feature.js index 36c4a0dfb9..178df220c7 100644 --- a/tests/cases/feature.js +++ b/tests/cases/feature.js @@ -186,6 +186,18 @@ describe('geo.feature', function () { expect(feat.visible(false)).toBe(feat); expect(feat.visible()).toBe(false); expect(feat.getMTime()).toBeGreaterThan(modTime); + + expect(feat.visible(true)).toBe(feat); + var depFeat = geo.feature({layer: layer, renderer: layer.renderer()}); + feat.dependentFeatures([depFeat]); + modTime = depFeat.getMTime(); + expect(feat.visible(false)).toBe(feat); + expect(feat.visible()).toBe(false); + expect(depFeat.visible()).toBe(false); + expect(depFeat.getMTime()).toBeGreaterThan(modTime); + feat.dependentFeatures([]); + expect(feat.visible(true)).toBe(feat); + expect(depFeat.visible()).toBe(false); }); }); describe('Check class accessors', function () { @@ -221,6 +233,12 @@ describe('geo.feature', function () { expect(feat.gcs('EPSG:3857')).toBe(feat); expect(feat.gcs()).toBe('EPSG:3857'); }); + it('dependentFeatures', function () { + expect(feat.dependentFeatures()).toEqual([]); + var depFeat = geo.feature({layer: layer, renderer: layer.renderer()}); + expect(feat.dependentFeatures([depFeat])).toBe(feat); + expect(feat.dependentFeatures()).toEqual([depFeat]); + }); it('bin', function () { expect(feat.bin()).toBe(0); expect(feat.bin(5)).toBe(feat); diff --git a/tests/cases/polygonFeature.js b/tests/cases/polygonFeature.js index 570600aed0..e7eca2662b 100644 --- a/tests/cases/polygonFeature.js +++ b/tests/cases/polygonFeature.js @@ -118,6 +118,25 @@ describe('geo.polygonFeature', function () { polygon._init({style: {data: pos}}); expect(polygon.data()).toEqual(pos); }); + + it('style', function () { + mockVGLRenderer(); + map = create_map(); + // we have to use a valid renderer so that the stroke can be enabled. + layer = map.createLayer('feature', {renderer: 'vgl'}); + polygon = geo.polygonFeature({layer: layer}); + polygon._init(); + expect(polygon.style().stroke).toBe(false); + expect(polygon.dependentFeatures()).toEqual([]); + polygon.style('stroke', true); + expect(polygon.style().stroke).toBe(true); + expect(polygon.dependentFeatures().length).toEqual(1); + polygon.style({stroke: false}); + expect(polygon.style().stroke).toBe(false); + expect(polygon.dependentFeatures()).toEqual([]); + map.deleteLayer(layer); + restoreVGLRenderer(); + }); }); describe('Public utility methods', function () { diff --git a/tests/cases/quadFeature.js b/tests/cases/quadFeature.js index fea8b43437..af19415092 100644 --- a/tests/cases/quadFeature.js +++ b/tests/cases/quadFeature.js @@ -196,7 +196,9 @@ describe('geo.quadFeature', function () { quad = geo.quadFeature({layer: layer}); quad._init(); data = [{ - ll: [-60, 10], ur: [-40, 30], image: preloadImage + ll: [-40, 30], ur: [-60, 10], image: preloadImage + }, { + ll: [-90, 10], ur: [-100, 10], image: preloadImage }, { ll: [-80, 10], lr: [-50, 10], ur: [-70, 30], image: preloadImage }]; @@ -205,12 +207,23 @@ describe('geo.quadFeature', function () { expect(pt.index).toEqual([0]); expect(pt.found.length).toBe(1); expect(pt.found[0].ll).toEqual(data[0].ll); + expect(pt.extra[0].basis.x).toBeCloseTo(0.25); + expect(pt.extra[0].basis.y).toBeCloseTo(0.047477); pt = quad.pointSearch({x: -55, y: 11}); - expect(pt.index).toEqual([0, 1]); + expect(pt.index).toEqual([0, 2]); expect(pt.found.length).toBe(2); + expect(pt.extra[0].basis.x).toBeCloseTo(0.75); + expect(pt.extra[0].basis.y).toBeCloseTo(0.047477); + expect(pt.extra[2].basis.x).toBeCloseTo(0.833333); + expect(pt.extra[2].basis.y).toBeCloseTo(0.952523); pt = quad.pointSearch({x: -35, y: 11}); expect(pt.index).toEqual([]); expect(pt.found.length).toBe(0); + /* not in a degenerate quad */ + pt = quad.pointSearch({x: -95, y: 10}); + expect(pt.index).toEqual([]); + expect(pt.found.length).toBe(0); + expect(pt.extra[1]).toBe(undefined); }); }); }); From 75cdc50c792276aacc8c6ec99dd7ada05016760a Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 25 Oct 2016 14:05:21 -0400 Subject: [PATCH 11/14] Allow colors to include an alpha value. Also, handle more css color specifications. Currently, nothing takes advantage of a color which has an alpha value (though the pixelmap soon will). It would be nice to be able to use that in addition to opacity styles. Before, we handled colors of the form rgb object, #rrggbb, #rgb, and css color names. This adds parsing of #rrggbbaa, #rgba, rgb(), rgba(), hsl(), hsla(), and 'transparent', conforming that parsing to the css working group's current draft standard (there are other color formats in that draft that are not implemented). It also adds one additional css color name, as per that draft. --- src/util/init.js | 144 ++++++++++++++++++++++++++++++++++---- tests/cases/colors.js | 159 +++++++++++++++++++----------------------- 2 files changed, 203 insertions(+), 100 deletions(-) diff --git a/src/util/init.js b/src/util/init.js index c5f601aeaf..14815f9ff9 100644 --- a/src/util/init.js +++ b/src/util/init.js @@ -135,24 +135,135 @@ return s; }, + /* This is a list of regex and processing functions for color conversions + * to rgb objects. Each entry contains: + * name: a name of the color conversion. + * regex: a regex that, if it matches the color string, will cause the + * process function to be invoked. + * process: a function that takes (color, match) with the original color + * string and the results of matching the regex. It outputs an rgb + * color object or the original color string if there is still a + * parsing failure. + * In general, these conversions are somewhat more forgiving than the css + * specification (see https://drafts.csswg.org/css-color/) in that + * percentages may be mixed with numbers, and that floating point values + * are accepted for all numbers. Commas are optional. As per the latest + * draft standard, rgb and rgba are aliases of each other, as are hsl and + * hsla. + */ + cssColorConversions: [{ + name: 'rgb', + regex: new RegExp( + '^\\s*rgba?' + + '\\(\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*' + + '(,?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*)?' + + '\\)\\s*$'), + process: function (color, match) { + color = { + r: Math.min(1, Math.max(0, +match[1] / (match[2] ? 100 : 255))), + g: Math.min(1, Math.max(0, +match[3] / (match[4] ? 100 : 255))), + b: Math.min(1, Math.max(0, +match[5] / (match[6] ? 100 : 255))) + }; + if (match[7]) { + color.a = Math.min(1, Math.max(0, +match[8] / (match[9] ? 100 : 1))); + } + return color; + } + }, { + name: 'hsl', + regex: new RegExp( + '^\\s*hsla?' + + '\\(\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(deg)?\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*%\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*%\\s*' + + '(,?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*)?' + + '\\)\\s*$'), + process: function (color, match) { + /* Conversion from https://www.w3.org/TR/2011/REC-css3-color-20110607 + */ + var hue_to_rgb = function (m1, m2, h) { + h = h - Math.floor(h); + if (h * 6 < 1) { + return m1 + (m2 - m1) * h * 6; + } + if (h * 6 < 3) { + return m2; + } + if (h * 6 < 4) { + return m1 + (m2 - m1) * (2 / 3 - h) * 6; + } + return m1; + }; + + var h = +match[1] / 360, + s = Math.min(1, Math.max(0, +match[3] / 100)), + l = Math.min(1, Math.max(0, +match[4] / 100)), + m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s, + m1 = l * 2 - m2; + color = { + r: hue_to_rgb(m1, m2, h + 1 / 3), + g: hue_to_rgb(m1, m2, h), + b: hue_to_rgb(m1, m2, h - 1 / 3) + }; + if (match[5]) { + color.a = Math.min(1, Math.max(0, +match[6] / (match[7] ? 100 : 1))); + } + return color; + } + }], + /** - * Convert a color from hex value or css name to rgb objects + * Convert a color to a standard rgb object. Allowed inputs: + * - rgb object with optional 'a' (alpha) value. + * - css color name + * - #rrggbb, #rrggbbaa, #rgb, #rgba hexadecimal colors + * - rgb(), rgba(), hsl(), and hsla() css colors + * - transparent + * The output object always contains r, g, b on a scale of [0-1]. If an + * alpha value is specified, the output will also contain an 'a' value on a + * scale of [0-1]. Objects already in rgb format are not checked to make + * sure that all parameters are in the range of [0-1], but string inputs + * are so validated. + * + * @param {object|string} color: one of the various input formats. + * @returns {object} an rgb color object, possibly with an 'a' value. If + * the input cannot be converted to a valid color, the input value is + * returned. */ convertColor: function (color) { if (color.r !== undefined && color.g !== undefined && color.b !== undefined) { return color; } + var opacity; if (typeof color === 'string') { if (geo.util.cssColors.hasOwnProperty(color)) { color = geo.util.cssColors[color]; } else if (color.charAt(0) === '#') { - if (color.length === 4) { - /* interpret values of the form #rgb as #rrggbb */ - color = parseInt(color.slice(1), 16); + if (color.length === 4 || color.length === 5) { + /* interpret values of the form #rgb as #rrggbb and #rgba as + * #rrggbbaa */ + if (color.length === 5) { + opacity = parseInt(color.slice(4), 16) / 0xf; + } + color = parseInt(color.slice(1, 4), 16); color = (color & 0xf00) * 0x1100 + (color & 0xf0) * 0x110 + (color & 0xf) * 0x11; - } else { - color = parseInt(color.slice(1), 16); + } else if (color.length === 7 || color.length === 9) { + if (color.length === 9) { + opacity = parseInt(color.slice(7), 16) / 0xff; + } + color = parseInt(color.slice(1, 7), 16); + } + } else if (color === 'transparent') { + opacity = color = 0; + } else if (color.indexOf('(') >= 0) { + for (var idx = 0; idx < geo.util.cssColorConversions.length; idx += 1) { + var match = geo.util.cssColorConversions[idx].regex.exec(color); + if (match) { + return geo.util.cssColorConversions[idx].process(color, match); + } } } } @@ -163,20 +274,26 @@ b: ((color & 0xff)) / 255 }; } + if (opacity !== undefined) { + color.a = opacity; + } return color; }, /** - * Convert a color to a six digit hex value prefixed with #. + * Convert a color to a six or eight digit hex value prefixed with #. */ - convertColorToHex: function (color) { - var value = geo.util.convertColor(color); - if (!value.r && !value.g && !value.b) { + convertColorToHex: function (color, allowAlpha) { + var rgb = geo.util.convertColor(color), value; + if (!rgb.r && !rgb.g && !rgb.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); + value = '#' + ((1 << 24) + (Math.round(rgb.r * 255) << 16) + + (Math.round(rgb.g * 255) << 8) + + Math.round(rgb.b * 255)).toString(16).slice(1); + } + if (rgb.a !== undefined && allowAlpha) { + value += (256 + Math.round(rgb.a * 255)).toString(16).slice(1); } return value; }, @@ -817,6 +934,7 @@ plum: 0xdda0dd, powderblue: 0xb0e0e6, purple: 0x800080, + rebeccapurple: 0x663399, red: 0xff0000, rosybrown: 0xbc8f8f, royalblue: 0x4169e1, diff --git a/tests/cases/colors.js b/tests/cases/colors.js index bf233f43d5..46146213ca 100644 --- a/tests/cases/colors.js +++ b/tests/cases/colors.js @@ -1,60 +1,65 @@ +/* global $ */ + describe('geo.util.convertColor', function () { 'use strict'; var geo = require('../test-utils').geo; - describe('From hex string', function () { - it('#000000', function () { - var c = geo.util.convertColor('#000000'); - expect(c).toEqual({ - r: 0, - g: 0, - b: 0 - }); - }); - it('#ffffff', function () { - var c = geo.util.convertColor('#ffffff'); - expect(c).toEqual({ - r: 1, - g: 1, - b: 1 - }); - }); - it('#1256ab', function () { - var c = geo.util.convertColor('#1256ab'); - expect(c).toEqual({ - r: 18 / 255, - g: 86 / 255, - b: 171 / 255 - }); - }); - }); - 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 + var tests = { + // #rrggbb + '#000000': {r: 0, g: 0, b: 0}, + '#ffffff': {r: 1, g: 1, b: 1}, + '#1256ab': {r: 18 / 255, g: 86 / 255, b: 171 / 255}, + // #rrggbbaa + '#00000000': {r: 0, g: 0, b: 0, a: 0}, + '#ffffffff': {r: 1, g: 1, b: 1, a: 1}, + '#1256ab43': {r: 18 / 255, g: 86 / 255, b: 171 / 255, a: 67 / 255}, + // #rgb + '#000': {r: 0, g: 0, b: 0}, + '#fff': {r: 1, g: 1, b: 1}, + '#26b': {r: 2 / 15, g: 6 / 15, b: 11 / 15}, + // # rgba + '#0001': {r: 0, g: 0, b: 0, a: 1 / 15}, + '#fff2': {r: 1, g: 1, b: 1, a: 2 / 15}, + '#26b3': {r: 2 / 15, g: 6 / 15, b: 11 / 15, a: 3 / 15}, + // css color names + 'red': {r: 1, g: 0, b: 0}, + 'green': {r: 0, g: 128 / 255, b: 0}, + 'blue': {r: 0, g: 0, b: 1}, + 'steelblue': {r: 70 / 255, g: 130 / 255, b: 180 / 255}, + // rgb() and rgba() + 'rgb(18, 86, 171)': {r: 18 / 255, g: 86 / 255, b: 171 / 255}, + 'rgb(18 86 171)': {r: 18 / 255, g: 86 / 255, b: 171 / 255}, + 'rgba(18 86 171)': {r: 18 / 255, g: 86 / 255, b: 171 / 255}, + 'rgb( 18 ,86,171 )': {r: 18 / 255, g: 86 / 255, b: 171 / 255}, + 'rgb(10% 35% 63.2%)': {r: 0.1, g: 0.35, b: 0.632}, + 'rgb(18 120% 300)': {r: 18 / 255, g: 1, b: 1}, + 'rgba(18 86 171 0.3)': {r: 18 / 255, g: 86 / 255, b: 171 / 255, a: 0.3}, + 'rgb(18 86 171 0.3)': {r: 18 / 255, g: 86 / 255, b: 171 / 255, a: 0.3}, + 'rgba(10% 35% 63.2% 40%)': {r: 0.1, g: 0.35, b: 0.632, a: 0.4}, + // hsl() and hsla() + 'hsl(120, 100%, 25%)': {r: 0, g: 0.5, b: 0}, + 'hsla(120, 100%, 25%)': {r: 0, g: 0.5, b: 0}, + 'hsl(120, 100%, 25%, 0.3)': {r: 0, g: 0.5, b: 0, a: 0.3}, + 'hsla(120, 100%, 25%, 30%)': {r: 0, g: 0.5, b: 0, a: 0.3}, + 'hsl(120deg 100% 25%)': {r: 0, g: 0.5, b: 0}, + 'hsl(207 44% 49%)': {r: 0.2744, g: 0.51156, b: 0.7056}, + 'hsl(207 100% 50%)': {r: 0, g: 0.55, b: 1}, + // transparent + 'transparent': {r: 0, g: 0, b: 0, a: 0}, + // unknown strings + 'none': 'none' + }; + + describe('From strings', function () { + $.each(tests, function (key, value) { + it(key, function () { + var c = geo.util.convertColor(key); + expect(c).toEqual(value); }); }); }); + describe('From hex value', function () { it('0x000000', function () { var c = geo.util.convertColor(0x000000); @@ -81,45 +86,7 @@ describe('geo.util.convertColor', function () { }); }); }); - describe('From css name', function () { - it('red', function () { - var c = geo.util.convertColor('red'); - expect(c).toEqual({ - r: 1, - g: 0, - b: 0 - }); - }); - it('green', function () { - var c = geo.util.convertColor('green'); - expect(c).toEqual({ - r: 0, - g: 128 / 255, - b: 0 - }); - }); - it('blue', function () { - var c = geo.util.convertColor('blue'); - expect(c).toEqual({ - r: 0, - g: 0, - b: 1 - }); - }); - it('steelblue', function () { - var c = geo.util.convertColor('steelblue'); - expect(c).toEqual({ - r: 70 / 255, - g: 130 / 255, - b: 180 / 255 - }); - }); - }); describe('Pass through unknown colors', function () { - it('none', function () { - var c = geo.util.convertColor('none'); - expect(c).toEqual('none'); - }); it('object', function () { var c = geo.util.convertColor({ r: 0, @@ -216,4 +183,22 @@ describe('geo.util.convertColorToHex', function () { expect(c).toEqual('#000000'); }); }); + describe('With alpha', function () { + it('no flag', function () { + var c = geo.util.convertColorToHex({r: 0, g: 1, b: 1, a: 0.3}); + expect(c).toEqual('#00ffff'); + }); + it('true flag and alpha', function () { + var c = geo.util.convertColorToHex({r: 0, g: 1, b: 1, a: 0.3}, true); + expect(c).toEqual('#00ffff4d'); + }); + it('true flag and no alpha', function () { + var c = geo.util.convertColorToHex({r: 0, g: 1, b: 1}, true); + expect(c).toEqual('#00ffff'); + }); + it('false flag and alpha', function () { + var c = geo.util.convertColorToHex({r: 0, g: 1, b: 1, a: 0.3}, false); + expect(c).toEqual('#00ffff'); + }); + }); }); From b5365136936f303e369358b98b2bb37bf0529f7b Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 25 Oct 2016 14:05:47 -0400 Subject: [PATCH 12/14] Add tests. Refactored how image elements are used for the pixelmap so that the pixelmap becomes a deferred object. Fixed a petty issue with setting an image's crossOrigin property even when it was a data url. --- src/imageTile.js | 4 +- src/pixelmapFeature.js | 103 ++++++++-- tests/cases/pixelmapFeature.js | 343 +++++++++++++++++++++++++++++++++ 3 files changed, 435 insertions(+), 15 deletions(-) create mode 100644 tests/cases/pixelmapFeature.js diff --git a/src/imageTile.js b/src/imageTile.js index 799414f3f8..644051b078 100644 --- a/src/imageTile.js +++ b/src/imageTile.js @@ -70,8 +70,8 @@ module.exports = (function () { if (!this._image) { this._image = new Image(this.size.x, this.size.y); // Only set the crossOrigin parameter if this is going across origins. - if (this._url.indexOf(':') >= 0 && this._url.indexOf('/') >= 0 && - this._url.indexOf(':') < this._url.indexOf('/')) { + if (this._url.indexOf(':') >= 0 && + this._url.indexOf('/') === this._url.indexOf(':') + 1) { this._image.crossOrigin = this._cors; } defer = new $.Deferred(); diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index a48fe49625..9d342c009f 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -10,9 +10,9 @@ var geo_event = require('./event'); * @class geo.pixelmapFeature * @param {Object} arg Options object * @extends geo.feature - * @param {Object|Function} [url] URL of a pixel map. The rgb data is - * interpretted as an index of the form 0xbbggrr. The alpha channel is - * ignored. + * @param {Object|Function|HTMLImageElement} [url] URL of a pixel map or an + * HTML Image element. The rgb data is interpretted as an index of the form + * 0xbbggrr. The alpha channel is ignored. * @param {Object|Function} [mapColor] The color that should be used for each * data element. Data elements correspond to the indices in the pixel map. * If an index is larger than the number of data elements, it will be @@ -166,15 +166,61 @@ var pixelmapFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this._build = function () { + /* Set the build time at the start of the call. A build can result in + * drawing a quad, which can trigger a full layer update, which in tern + * checks if this feature is built. Setting the build time avoid calling + * this a second time. */ + m_this.buildTime().modified(); if (!m_srcImage) { - m_srcImage = new Image(); - m_srcImage.crossOrigin = m_this.style.get('crossDomain')() || 'anonymous'; - m_srcImage.onload = this._computePixelmap; - m_srcImage.src = m_this.style.get('url')(); + var src = this.style.get('url')(); + if (src instanceof Image && src.complete && src.naturalWidth && src.naturalHeight) { + /* we have an already laoded image, so we can just use it. */ + m_srcImage = src; + this._computePixelmap(); + } else if (src) { + var defer = new $.Deferred(), prev_onload, prev_onerror; + if (src instanceof Image) { + /* we have an unloaded image. Hook to the load and error callbacks + * so that when it is loaded we can use it. */ + m_srcImage = src; + prev_onload = src.onload; + prev_onerror = src.onerror; + } else { + /* we were given a url, so construct a new image */ + m_srcImage = new Image(); + // Only set the crossOrigin parameter if this is going across origins. + if (src.indexOf(':') >= 0 && + src.indexOf('/') === src.indexOf(':') + 1) { + m_srcImage.crossOrigin = this.style.get('crossDomain')() || 'anonymous'; + } + } + m_srcImage.onload = function () { + if (prev_onload) { + prev_onload.apply(this, arguments); + } + /* Only use this image if our pixelmap hasn't changed since we + * attached our handler */ + if (m_this.style.get('url')() === src) { + m_info = undefined; + m_this._computePixelmap(); + } + defer.resolve(); + }; + m_srcImage.onerror = function () { + if (prev_onerror) { + prev_onerror.apply(this, arguments); + } + defer.reject(); + }; + defer.promise(this); + this.layer().addPromise(this); + if (!(src instanceof Image)) { + m_srcImage.src = src; + } + } } else if (m_info) { this._computePixelmap(); } - m_this.buildTime().modified(); return m_this; }; @@ -186,6 +232,10 @@ var pixelmapFeature = function (arg) { this._preparePixelmap = function () { var i, idx, pixelData; + if (!m_srcImage || !m_srcImage.complete || !m_srcImage.naturalWidth || + !m_srcImage.naturalHeight) { + return; + } m_info = { width: m_srcImage.naturalWidth, height: m_srcImage.naturalHeight, @@ -213,6 +263,7 @@ var pixelmapFeature = function (arg) { } m_info.mappedColors[idx].last = i / 4; } + return m_info; }; /** @@ -227,14 +278,16 @@ var pixelmapFeature = function (arg) { updateFirst, updateLast = -1, update, prepared; if (!m_info) { - m_this._preparePixelmap(); + if (!m_this._preparePixelmap()) { + return; + } prepared = true; } mappedColors = m_info.mappedColors; updateFirst = m_info.area; for (idx in mappedColors) { if (mappedColors.hasOwnProperty(idx)) { - color = mapColorFunc(data[idx], idx) || {}; + color = mapColorFunc(data[idx], +idx) || {}; color = [ (color.r || 0) * 255, (color.g || 0) * 255, @@ -315,15 +368,16 @@ var pixelmapFeature = function (arg) { } m_this.updateTime().modified(); + return m_this; }; //////////////////////////////////////////////////////////////////////////// /** * Destroy - * @memberof geo.polygonFeature + * @memberof geo.pixelmapFeature */ //////////////////////////////////////////////////////////////////////////// - this._exit = function () { + this._exit = function (abc) { if (m_quadFeature && m_this.layer()) { m_this.layer().deleteFeature(m_quadFeature); m_quadFeature = null; @@ -338,6 +392,7 @@ var pixelmapFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this._init = function (arg) { + arg = arg || {}; s_init.call(m_this, arg); var style = $.extend( @@ -350,7 +405,8 @@ var pixelmapFeature = function (arg) { b: ((idx >> 16) & 0xFF) / 255, a: 1 }; - } + }, + position: function (d) { return d; } }, arg.style === undefined ? {} : arg.style ); @@ -370,5 +426,26 @@ var pixelmapFeature = function (arg) { return this; }; +/** + * Create a pixelmapFeature from an object. + * + * @see {@link geo.feature.create} + * @param {geo.layer} layer The layer to add the feature to + * @param {geo.pixelmapFeature.spec} spec The object specification + * @returns {geo.pixelmapFeature|null} + */ +pixelmapFeature.create = function (layer, spec) { + 'use strict'; + + spec = spec || {}; + spec.type = 'pixelmap'; + return feature.create(layer, spec); +}; + +pixelmapFeature.capabilities = { + /* core feature name -- support in any manner */ + feature: 'pixelmap' +}; + inherit(pixelmapFeature, feature); module.exports = pixelmapFeature; diff --git a/tests/cases/pixelmapFeature.js b/tests/cases/pixelmapFeature.js new file mode 100644 index 0000000000..d3bfc6ebf4 --- /dev/null +++ b/tests/cases/pixelmapFeature.js @@ -0,0 +1,343 @@ +// Test geo.pixelmapFeature and geo.canvas.pixelmapFeature + +/* globals Image */ + +var geo = require('../test-utils').geo; +var $ = require('jquery'); +var waitForIt = require('../test-utils').waitForIt; +var logCanvas2D = require('../test-utils').logCanvas2D; + +describe('geo.pixelmapFeature', function () { + 'use strict'; + + var position = {ul: {x: -140, y: 70}, lr: {x: -60, y: 10}}; + + var testImageSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAG' + + 'CAIAAABxZ0isAAAAKklEQVQI13XLoREAMAwDMTkg+2/ckrKkRMC+Tw' + + 'iGWdfnXvgU1eAMC+t3AZSjBh9ho6CUAAAAAElFTkSuQmCC'; + var testImage = new Image(); + + function load_test_image(done) { + if (!testImage.src) { + testImage.onload = function () { + done(); + }; + testImage.src = testImageSrc; + } else { + done(); + } + } + + 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('create', function () { + var map, layer, pixelmap; + it('create function', function () { + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = geo.pixelmapFeature.create(layer); + expect(pixelmap instanceof geo.pixelmapFeature).toBe(true); + }); + it('direct create', function () { + var pixelmap = geo.pixelmapFeature({layer: layer}); + expect(pixelmap instanceof geo.pixelmapFeature).toBe(true); + }); + }); + + describe('Private utility methods', function () { + it('load test image', load_test_image); + describe('_init', function () { + var map, layer, pixelmap; + it('defaults', function () { + map = create_map(); + layer = map.createLayer('feature', {renderer: null}); + pixelmap = geo.pixelmapFeature({layer: layer}); + pixelmap._init(); + expect(pixelmap.mapColor()(0, 0)).toEqual({r: 0, g: 0, b: 0, a: 1}); + }); + it('arg gets added to style', function () { + pixelmap = geo.pixelmapFeature({layer: layer}); + /* init is not automatically called on the geo.pixelmapFeature (it is + * on geo.canvas.pixelmapFeature). */ + pixelmap._init({ + mapColor: 'red', + position: position, + url: testImageSrc, + style: {position: {ul: {x: 1}}} + }); + expect(pixelmap.style('position').ul.x).toBe(-140); + expect(pixelmap.style('url').slice(0, 4)).toBe('data'); + expect(pixelmap.style('mapColor')).toBe('red'); + }); + }); + it('_exit', function () { + var map, layer, pixelmap; + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = geo.pixelmapFeature({layer: layer}); + pixelmap._init({ + position: position, + url: testImage + }); + pixelmap._build(); + expect(pixelmap.dependentFeatures().length).toBe(1); + pixelmap._exit(); + expect(pixelmap.dependentFeatures().length).toBe(0); + }); + it('_preparePixelmap', function () { + var map, layer, pixelmap; + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = geo.pixelmapFeature({layer: layer}); + pixelmap._init({ + position: position, + url: testImage + }); + expect(pixelmap.maxIndex()).toBe(undefined); + expect(pixelmap._preparePixelmap()).toBe(undefined); + /* We have to call _build to be able to set the source image and + * successfully call _preparePixelmap. */ + pixelmap._build(); + var info = pixelmap._preparePixelmap(); + expect(pixelmap.maxIndex()).toBe(6); + expect(info.width).toBe(8); + expect(info.height).toBe(6); + expect(info.area).toBe(info.width * info.height); + expect(info.indices.length).toBe(info.area); + expect(info.mappedColors[0]).toEqual({first: 16, last: 43}); + expect(info.mappedColors[6]).toEqual({first: 32, last: 41}); + }); + it('_computePixelmap', function () { + var map, layer, pixelmap, prepared = 0; + + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = geo.pixelmapFeature({layer: layer}); + pixelmap._init({ + position: position, + url: testImage + }); + expect(pixelmap.maxIndex()).toBe(undefined); + expect(pixelmap.dependentFeatures().length).toBe(0); + + pixelmap.geoOn(geo.event.pixelmap.prepared, function () { + prepared += 1; + }); + + /* We shouldn't be able to compute it without building */ + pixelmap._computePixelmap(); + expect(prepared).toBe(0); + /* We have to call _build to be able to set the source image and + * successfully call _computePixelmap. */ + pixelmap._build(); + expect(pixelmap.maxIndex()).toBe(6); + expect(pixelmap.dependentFeatures().length).toBe(1); + expect(prepared).toBe(1); + // we shouldn't get another prepared event when we call it again*/ + pixelmap._computePixelmap(); + expect(prepared).toBe(1); + }); + describe('_build', function () { + var map, layer, pixelmap, buildTime; + + it('loading image', function (done) { + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = layer.createFeature('pixelmap', { + position: position, + url: testImageSrc + }); + buildTime = pixelmap.buildTime().getMTime(); + pixelmap._build().then(function () { + expect(pixelmap.buildTime().getMTime()).toBeGreaterThan(buildTime); + expect(pixelmap.maxIndex()).toBe(6); + done(); + }); + }); + it('built', function () { + buildTime = pixelmap.buildTime().getMTime(); + expect(pixelmap._build()).toBe(pixelmap); + expect(pixelmap.buildTime().getMTime()).toBeGreaterThan(buildTime); + }); + it('unloaded image', function (done) { + var img = new Image(), loaded; + img.onload = function () { + loaded = true; + }; + pixelmap.url(img); + pixelmap._build().then(function () { + expect(loaded).toBe(true); + done(); + }); + img.src = testImageSrc; + }); + it('bad unloaded image', function (done) { + var img = new Image(), errored; + img.onerror = function () { + errored = true; + }; + pixelmap.url(img); + pixelmap._build().fail(function () { + expect(errored).toBe(true); + done(); + }); + img.src = 'data:image/png;base64,notanimage'; + }); + it('bad url', function (done) { + pixelmap.url('noprotocol://127.0.0.1/notanimage'); + pixelmap._build().fail(function () { + done(); + }); + }); + }); + describe('_update', function () { + var map, layer, pixelmap, buildTime, updateTime; + + it('loading image', function (done) { + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = layer.createFeature('pixelmap', { + position: position, + url: testImageSrc + }); + buildTime = pixelmap.buildTime().getMTime(); + updateTime = pixelmap.updateTime().getMTime(); + pixelmap._update().then(function () { + expect(pixelmap.buildTime().getMTime()).toBeGreaterThan(buildTime); + expect(pixelmap.updateTime().getMTime()).toBeGreaterThan(updateTime); + expect(pixelmap.maxIndex()).toBe(6); + done(); + }); + }); + it('updated', function (done) { + buildTime = pixelmap.buildTime().getMTime(); + updateTime = pixelmap.updateTime().getMTime(); + pixelmap._update().then(function () { + expect(pixelmap.buildTime().getMTime()).toBe(buildTime); + expect(pixelmap.updateTime().getMTime()).toBeGreaterThan(updateTime); + done(); + }); + }); + }); + }); + + describe('Check class accessors', function () { + var map, layer, pixelmap; + + it('position', function () { + var pos = {lr: {x: 0, y: 0}, ul: {x: 10, y: 5}}; + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = layer.createFeature('pixelmap', { + url: testImageSrc + }); + expect(pixelmap.position()('a')).toBe('a'); + pixelmap.position(pos); + expect(pixelmap.position()).toEqual(pos); + pixelmap.position(function () { return 'b'; }); + expect(pixelmap.position()('a')).toEqual('b'); + pixelmap.position(position); + expect(pixelmap.position()).toEqual(position); + }); + it('url', function () { + expect(pixelmap.url()).toEqual(testImageSrc); + expect(pixelmap.url(testImage)).toBe(pixelmap); + expect(pixelmap.url()).toEqual(testImage); + }); + it('maxIndex', function () { + expect(pixelmap.maxIndex()).toBe(undefined); + pixelmap._update(); + expect(pixelmap.maxIndex()).toBe(6); + }); + it('mapColor', function () { + var colorFunc = function (d, i) { + return i & 1 ? 'red' : 'blue'; + }; + expect(pixelmap.mapColor()(0, 2)).toEqual({r: 2 / 255, g: 0, b: 0, a: 1}); + expect(pixelmap.mapColor(colorFunc)).toBe(pixelmap); + expect(pixelmap.mapColor()(0, 2)).toEqual('blue'); + expect(pixelmap.mapColor()(0, 3)).toEqual('red'); + }); + }); + + describe('Public utility methods', function () { + describe('pointSearch', function () { + it('basic usage', function () { + var map, layer, pixelmap, pt; + + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = layer.createFeature('pixelmap', { + position: position, + url: testImage + }); + pixelmap.data(['a', 'b', 'c', 'd', 'e', 'f']); + // var position = {ul: {x: -140, y: 70}, lr: {x: -60, y: 10}}; + pt = pixelmap.pointSearch({x: -135, y: 65}); + expect(pt).toEqual({index: [], found: []}); + pixelmap._update(); + pt = pixelmap.pointSearch({x: -135, y: 65}); + expect(pt).toEqual({index: [1], found: ['b']}); + pt = pixelmap.pointSearch({x: -145, y: 65}); + expect(pt).toEqual({index: [], found: []}); + pt = pixelmap.pointSearch({x: -65, y: 15}); + expect(pt).toEqual({index: [2], found: ['c']}); + }); + }); + }); + + /* This is a basic integration test of geo.canvas.pixelmapFeature. */ + describe('geo.canvas.pixelmapFeature', function () { + var map, layer, pixelmap, buildTime, counts; + it('basic usage', function () { + map = create_map(); + layer = map.createLayer('feature', {renderer: 'canvas'}); + pixelmap = layer.createFeature('pixelmap', { + position: position, + url: testImage + }); + /* Trigger rerendering */ + pixelmap.data(['a', 'b', 'c', 'd', 'e', 'f']); + buildTime = pixelmap.buildTime().getMTime(); + logCanvas2D(); + counts = $.extend({}, window._canvasLog.counts); + map.draw(); + expect(buildTime).not.toEqual(pixelmap.buildTime().getMTime()); + }); + waitForIt('next render canvas A', function () { + return window._canvasLog.counts.clearRect >= (counts.clearRect || 0) + 1 && + window._canvasLog.counts.getImageData >= (counts.getImageData || 0) + 1 && + window._canvasLog.counts.drawImage >= (counts.drawImage || 0) + 1; + }); + it('Minimal update', function () { + pixelmap.modified(); + counts = $.extend({}, window._canvasLog.counts); + pixelmap.draw(); + }); + waitForIt('next render canvas B', function () { + return window._canvasLog.counts.clearRect >= (counts.clearRect || 0) + 1 && + window._canvasLog.counts.getImageData === counts.getImageData && + window._canvasLog.counts.drawImage >= (counts.drawImage || 0) + 1; + }); + it('Heavier update', function () { + var colorFunc = function (d, i) { + return i & 1 ? 'red' : 'blue'; + }; + pixelmap.mapColor(colorFunc); + counts = $.extend({}, window._canvasLog.counts); + pixelmap.draw(); + }); + waitForIt('next render canvas C', function () { + return window._canvasLog.counts.clearRect >= (counts.clearRect || 0) + 1 && + window._canvasLog.counts.getImageData === counts.getImageData && + window._canvasLog.counts.drawImage >= (counts.drawImage || 0) + 1; + }); + }); +}); From 6e73a80af9262357e21019d186349fb49b7a789b Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 28 Oct 2016 09:26:28 -0400 Subject: [PATCH 13/14] Add isReadyImage function. Add a utility function to determine if an object is an HTML image element that is fully loaded. --- src/pixelmapFeature.js | 8 ++++---- src/quadFeature.js | 7 ++----- src/util/init.js | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index 9d342c009f..c657fa33a9 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -2,6 +2,7 @@ var $ = require('jquery'); var inherit = require('./inherit'); var feature = require('./feature'); var geo_event = require('./event'); +var util = require('./util'); ////////////////////////////////////////////////////////////////////////////// /** @@ -173,8 +174,8 @@ var pixelmapFeature = function (arg) { m_this.buildTime().modified(); if (!m_srcImage) { var src = this.style.get('url')(); - if (src instanceof Image && src.complete && src.naturalWidth && src.naturalHeight) { - /* we have an already laoded image, so we can just use it. */ + if (util.isReadyImage(src)) { + /* we have an already loaded image, so we can just use it. */ m_srcImage = src; this._computePixelmap(); } else if (src) { @@ -232,8 +233,7 @@ var pixelmapFeature = function (arg) { this._preparePixelmap = function () { var i, idx, pixelData; - if (!m_srcImage || !m_srcImage.complete || !m_srcImage.naturalWidth || - !m_srcImage.naturalHeight) { + if (!util.isReadyImage(m_srcImage)) { return; } m_info = { diff --git a/src/quadFeature.js b/src/quadFeature.js index 4ed155b076..d350970c9b 100644 --- a/src/quadFeature.js +++ b/src/quadFeature.js @@ -376,15 +376,12 @@ var quadFeature = function (arg) { if (d.crop) { quad.crop = d.crop; } - if ((image.complete && image.naturalWidth && image.naturalHeight) || - image instanceof HTMLCanvasElement) { + if (util.isReadyImage(image) || image instanceof HTMLCanvasElement) { quad.image = image; } else { previewColor = undefined; previewImage = previewImageFunc.call(m_this, d, i); - if (previewImage && previewImage instanceof Image && - previewImage.complete && previewImage.naturalWidth && - previewImage.naturalHeight) { + if (previewImage && util.isReadyImage(previewImage)) { quad.image = previewImage; } else { previewColor = previewColorFunc.call(m_this, d, i); diff --git a/src/util/init.js b/src/util/init.js index 14815f9ff9..0a976804a4 100644 --- a/src/util/init.js +++ b/src/util/init.js @@ -102,6 +102,25 @@ } }, + /** + * Returns true if the argument is an HTML Image element that is fully + * loaded. + * + * @param {object} img: an object that might be an HTML Image element. + * @param {boolean} [allowFailedImage]: if true, an image element that has + * a source and has failed to load is also considered 'ready' in the + * sense that it isn't expected to change to a better state. + * @returns {boolean} true if this is an image that is ready. + */ + isReadyImage: function (img, allowFailedImage) { + if (img instanceof Image && img.complete && img.src) { + if ((img.naturalWidth && img.naturalHeight) || allowFailedImage) { + return true; + } + } + return false; + }, + /** * Returns true if the argument is a function. */ From 8f65be1527e360678329b8dbf61845a32df38c86 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 28 Oct 2016 12:02:14 -0400 Subject: [PATCH 14/14] Rename mapColor to color. --- examples/pixelmap/main.js | 2 +- src/pixelmapFeature.js | 28 ++++++++++++++-------------- tests/cases/pixelmapFeature.js | 18 +++++++++--------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/examples/pixelmap/main.js b/examples/pixelmap/main.js index c8caab585b..3610cb0c54 100644 --- a/examples/pixelmap/main.js +++ b/examples/pixelmap/main.js @@ -35,7 +35,7 @@ $(function () { selectionAPI: true, url: pixelmapUrl, position: {ul: {x: -180, y: 71.471178}, lr: {x: -60, y: 13.759032}}, - mapColor: function (d, idx) { + color: function (d, idx) { // Always set index 0 to transparent. Other indicies are set based on // the data value var color = {r: 0, g: 0, b: 0, a: 0}; diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index c657fa33a9..43c5edb237 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -14,10 +14,10 @@ var util = require('./util'); * @param {Object|Function|HTMLImageElement} [url] URL of a pixel map or an * HTML Image element. The rgb data is interpretted as an index of the form * 0xbbggrr. The alpha channel is ignored. - * @param {Object|Function} [mapColor] The color that should be used for each - * data element. Data elements correspond to the indices in the pixel map. - * If an index is larger than the number of data elements, it will be - * transparent. If there is more data than there are indices, it is ignored. + * @param {Object|Function} [color] The color that should be used for each data + * element. Data elements correspond to the indices in the pixel map. If an + * index is larger than the number of data elements, it will be transparent. + * If there is more data than there are indices, it is ignored. * @param {Object|Function} [position] Position of the image. Default is * (data). The position is an Object which specifies the corners of the * quad: ll, lr, ur, ul. At least two opposite corners must be specified. @@ -119,16 +119,16 @@ var pixelmapFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// /** - * Get/Set mapColor accessor + * Get/Set color accessor * * @returns {geo.pixelmap} */ //////////////////////////////////////////////////////////////////////////// - this.mapColor = function (val) { + this.color = function (val) { if (val === undefined) { - return m_this.style('mapColor'); - } else if (val !== m_this.style('mapColor')) { - m_this.style('mapColor', val); + return m_this.style('color'); + } else if (val !== m_this.style('color')) { + m_this.style('color', val); m_this.dataTime().modified(); m_this.modified(); } @@ -273,7 +273,7 @@ var pixelmapFeature = function (arg) { */ this._computePixelmap = function () { var data = m_this.data() || [], - mapColorFunc = m_this.style.get('mapColor'), + colorFunc = m_this.style.get('color'), i, idx, lastidx, color, pixelData, indices, mappedColors, updateFirst, updateLast = -1, update, prepared; @@ -287,7 +287,7 @@ var pixelmapFeature = function (arg) { updateFirst = m_info.area; for (idx in mappedColors) { if (mappedColors.hasOwnProperty(idx)) { - color = mapColorFunc(data[idx], +idx) || {}; + color = colorFunc(data[idx], +idx) || {}; color = [ (color.r || 0) * 255, (color.g || 0) * 255, @@ -398,7 +398,7 @@ var pixelmapFeature = function (arg) { var style = $.extend( {}, { - mapColor: function (d, idx) { + color: function (d, idx) { return { r: (idx & 0xFF) / 255, g: ((idx >> 8) & 0xFF) / 255, @@ -416,8 +416,8 @@ var pixelmapFeature = function (arg) { if (arg.url !== undefined) { style.url = arg.url; } - if (arg.mapColor !== undefined) { - style.mapColor = arg.mapColor; + if (arg.color !== undefined) { + style.color = arg.color; } m_this.style(style); m_this.dataTime().modified(); diff --git a/tests/cases/pixelmapFeature.js b/tests/cases/pixelmapFeature.js index d3bfc6ebf4..14136bf95c 100644 --- a/tests/cases/pixelmapFeature.js +++ b/tests/cases/pixelmapFeature.js @@ -60,21 +60,21 @@ describe('geo.pixelmapFeature', function () { layer = map.createLayer('feature', {renderer: null}); pixelmap = geo.pixelmapFeature({layer: layer}); pixelmap._init(); - expect(pixelmap.mapColor()(0, 0)).toEqual({r: 0, g: 0, b: 0, a: 1}); + expect(pixelmap.color()(0, 0)).toEqual({r: 0, g: 0, b: 0, a: 1}); }); it('arg gets added to style', function () { pixelmap = geo.pixelmapFeature({layer: layer}); /* init is not automatically called on the geo.pixelmapFeature (it is * on geo.canvas.pixelmapFeature). */ pixelmap._init({ - mapColor: 'red', + color: 'red', position: position, url: testImageSrc, style: {position: {ul: {x: 1}}} }); expect(pixelmap.style('position').ul.x).toBe(-140); expect(pixelmap.style('url').slice(0, 4)).toBe('data'); - expect(pixelmap.style('mapColor')).toBe('red'); + expect(pixelmap.style('color')).toBe('red'); }); }); it('_exit', function () { @@ -256,14 +256,14 @@ describe('geo.pixelmapFeature', function () { pixelmap._update(); expect(pixelmap.maxIndex()).toBe(6); }); - it('mapColor', function () { + it('color', function () { var colorFunc = function (d, i) { return i & 1 ? 'red' : 'blue'; }; - expect(pixelmap.mapColor()(0, 2)).toEqual({r: 2 / 255, g: 0, b: 0, a: 1}); - expect(pixelmap.mapColor(colorFunc)).toBe(pixelmap); - expect(pixelmap.mapColor()(0, 2)).toEqual('blue'); - expect(pixelmap.mapColor()(0, 3)).toEqual('red'); + expect(pixelmap.color()(0, 2)).toEqual({r: 2 / 255, g: 0, b: 0, a: 1}); + expect(pixelmap.color(colorFunc)).toBe(pixelmap); + expect(pixelmap.color()(0, 2)).toEqual('blue'); + expect(pixelmap.color()(0, 3)).toEqual('red'); }); }); @@ -330,7 +330,7 @@ describe('geo.pixelmapFeature', function () { var colorFunc = function (d, i) { return i & 1 ? 'red' : 'blue'; }; - pixelmap.mapColor(colorFunc); + pixelmap.color(colorFunc); counts = $.extend({}, window._canvasLog.counts); pixelmap.draw(); });