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..3610cb0c54 --- /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}}, + 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}; + 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 0000000000..e9e33dd743 Binary files /dev/null and b/examples/pixelmap/pixelmap.png differ diff --git a/examples/pixelmap/thumb.jpg b/examples/pixelmap/thumb.jpg new file mode 100755 index 0000000000..2ecc799e8c Binary files /dev/null and b/examples/pixelmap/thumb.jpg differ 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/event.js b/src/event.js index 8e5daf8477..2bc38bd036 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/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/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..43c5edb237 --- /dev/null +++ b/src/pixelmapFeature.js @@ -0,0 +1,451 @@ +var $ = require('jquery'); +var inherit = require('./inherit'); +var feature = require('./feature'); +var geo_event = require('./event'); +var util = require('./util'); + +////////////////////////////////////////////////////////////////////////////// +/** + * Create a new instance of class imagemapFeature + * + * @class geo.pixelmapFeature + * @param {Object} arg Options object + * @extends geo.feature + * @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} [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. + * 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_srcImage, + m_info, + 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_info = undefined; + m_this.style('url', val); + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get the maximum index value from the pixelmap. This is a value present in + * the pixelmap. + * + * @returns {geo.pixelmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.maxIndex = function () { + 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) { + 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); + } + } + } + return m_info.maxIndex; + } + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set color accessor + * + * @returns {geo.pixelmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.color = function (val) { + if (val === undefined) { + return m_this.style('color'); + } else if (val !== m_this.style('color')) { + m_this.style('color', 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 && m_info) { + var result = m_quadFeature.pointSearch(coordinate); + 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]; + result = { + index: [idx], + found: [m_this.data()[idx]] + }; + return result; + } + } + } + return {index: [], found: []}; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Build + */ + //////////////////////////////////////////////////////////////////////////// + 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) { + var src = this.style.get('url')(); + if (util.isReadyImage(src)) { + /* we have an already loaded 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(); + } + 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; + + if (!util.isReadyImage(m_srcImage)) { + return; + } + 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; + } + return m_info; + }; + + /** + * 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() || [], + colorFunc = m_this.style.get('color'), + i, idx, lastidx, color, pixelData, indices, mappedColors, + updateFirst, updateLast = -1, update, prepared; + + if (!m_info) { + if (!m_this._preparePixelmap()) { + return; + } + prepared = true; + } + mappedColors = m_info.mappedColors; + updateFirst = m_info.area; + for (idx in mappedColors) { + if (mappedColors.hasOwnProperty(idx)) { + color = colorFunc(data[idx], +idx) || {}; + color = [ + (color.r || 0) * 255, + (color.g || 0) * 255, + (color.b || 0) * 255, + color.a === undefined ? 255 : (color.a * 255) + ]; + 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 (updateFirst >= updateLast) { + return; + } + /* 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 (update) { + pixelData[i * 4] = color[0]; + pixelData[i * 4 + 1] = color[1]; + pixelData[i * 4 + 2] = color[2]; + pixelData[i * 4 + 3] = color[3]; + } + } + /* 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(); + } + /* If we prepared the pixelmap and rendered it, send a prepared event */ + if (prepared) { + m_this.geoTrigger(geo_event.pixelmap.prepared, { + pixelmap: m_this + }); + } + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * 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(); + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Destroy + * @memberof geo.pixelmapFeature + */ + //////////////////////////////////////////////////////////////////////////// + this._exit = function (abc) { + if (m_quadFeature && m_this.layer()) { + m_this.layer().deleteFeature(m_quadFeature); + m_quadFeature = null; + m_this.dependentFeatures([]); + } + s_exit(); + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Initialize + */ + //////////////////////////////////////////////////////////////////////////// + this._init = function (arg) { + arg = arg || {}; + s_init.call(m_this, arg); + + var style = $.extend( + {}, + { + color: function (d, idx) { + return { + r: (idx & 0xFF) / 255, + g: ((idx >> 8) & 0xFF) / 255, + b: ((idx >> 16) & 0xFF) / 255, + a: 1 + }; + }, + position: function (d) { return d; } + }, + arg.style === undefined ? {} : arg.style + ); + if (arg.position !== undefined) { + style.position = arg.position; + } + if (arg.url !== undefined) { + style.url = arg.url; + } + if (arg.color !== undefined) { + style.color = arg.color; + } + m_this.style(style); + m_this.dataTime().modified(); + }; + + 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/src/quadFeature.js b/src/quadFeature.js index d64592550e..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); @@ -510,7 +507,7 @@ quadFeature.capabilities = { imageFixedScale: 'quad.imageFixedScale', /* support for arbitrary quad images */ imageFull: 'quad.imageFull', - /* support for canvas as content in image quads*/ + /* support for canvas elements as content in image quads */ canvas: 'quad.canvas' }; 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. */ diff --git a/tests/cases/pixelmapFeature.js b/tests/cases/pixelmapFeature.js new file mode 100644 index 0000000000..14136bf95c --- /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.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({ + 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('color')).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('color', function () { + var colorFunc = function (d, i) { + return i & 1 ? 'red' : 'blue'; + }; + 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'); + }); + }); + + 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.color(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; + }); + }); +});