From 3468ebddff142cd6ca265888037abcd599cf616d Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 11 Nov 2025 15:08:48 -0500 Subject: [PATCH] feat: Support tiled pixelmaps with the canvas renderer Previously, we supported pixelmap features on canvas and webgl, but only tiled pixelmap layers on webgl. --- src/canvas/pixelmapFeature.js | 127 ++++++++++++++++++---------- src/canvas/quadFeature.js | 3 + src/pixelmapFeature.js | 49 ++++++++++- src/webgl/pixelmapFeature.js | 29 +------ tests/headed-cases/pixelmapLayer.js | 120 ++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 71 deletions(-) create mode 100644 tests/headed-cases/pixelmapLayer.js diff --git a/src/canvas/pixelmapFeature.js b/src/canvas/pixelmapFeature.js index 146350a466..e0bf58d8c2 100644 --- a/src/canvas/pixelmapFeature.js +++ b/src/canvas/pixelmapFeature.js @@ -44,6 +44,7 @@ var canvas_pixelmapFeature = function (arg) { object.call(this); var m_quadFeature, + m_quadFeatureInit, s_exit = this._exit, m_this = this; @@ -59,21 +60,25 @@ var canvas_pixelmapFeature = function (arg) { * feature indices that are located at the specified point. */ this.pointSearch = function (geo, gcs) { - if (m_quadFeature && m_this.m_info) { + if (m_quadFeature) { var result = m_quadFeature.pointSearch(geo, gcs); - 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_this.m_info.width); - y = Math.floor(basis.y * m_this.m_info.height); - if (x >= 0 && x < m_this.m_info.width && + if (m_this.m_info) { + 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_this.m_info.width); + y = Math.floor(basis.y * m_this.m_info.height); + if (x >= 0 && x < m_this.m_info.width && y >= 0 && y < m_this.m_info.height) { - idx = m_this.m_info.indices[y * m_this.m_info.width + x]; - result = { - index: [idx], - found: [m_this.data()[idx]] - }; - return result; + idx = m_this.m_info.indices[y * m_this.m_info.width + x]; + result = { + index: [idx], + found: [m_this.data()[idx]] + }; + return result; + } } + } else { + return this._pointSearchProcess(result); } } return {index: [], found: []}; @@ -84,41 +89,47 @@ var canvas_pixelmapFeature = function (arg) { * if the pixelmap has already been prepared (it is invalidated by a change * in the image). * + * @param {object} [quad] A quad to use as the base instead of the class + * instance. * @returns {geo.pixelmapFeature.info?} */ - this._preparePixelmap = function () { + this._preparePixelmap = function (quad) { + const base = quad || m_this; + if (quad && quad.m_info) { + return quad.m_info; + } var i, idx, pixelData; - if (!util.isReadyImage(m_this.m_srcImage)) { + if (!util.isReadyImage(base.m_srcImage)) { return undefined; } - m_this.m_info = { - width: m_this.m_srcImage.naturalWidth, - height: m_this.m_srcImage.naturalHeight, + base.m_info = { + width: base.m_srcImage.naturalWidth, + height: base.m_srcImage.naturalHeight, canvas: document.createElement('canvas') }; - m_this.m_info.canvas.width = m_this.m_info.width; - m_this.m_info.canvas.height = m_this.m_info.height; - m_this.m_info.context = m_this.m_info.canvas.getContext('2d'); + base.m_info.canvas.width = base.m_info.width; + base.m_info.canvas.height = base.m_info.height; + base.m_info.context = base.m_info.canvas.getContext('2d'); - m_this.m_info.context.drawImage(m_this.m_srcImage, 0, 0); - m_this.m_info.imageData = m_this.m_info.context.getImageData( - 0, 0, m_this.m_info.canvas.width, m_this.m_info.canvas.height); - pixelData = m_this.m_info.imageData.data; - m_this.m_info.indices = new Array(pixelData.length / 4); - m_this.m_info.area = pixelData.length / 4; + base.m_info.context.drawImage(base.m_srcImage, 0, 0); + base.m_info.imageData = base.m_info.context.getImageData( + 0, 0, base.m_info.canvas.width, base.m_info.canvas.height); + pixelData = base.m_info.imageData.data; + base.m_info.indices = new Array(pixelData.length / 4); + base.m_info.area = pixelData.length / 4; - m_this.m_info.mappedColors = {}; + base.m_info.mappedColors = {}; for (i = 0; i < pixelData.length; i += 4) { idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); - m_this.m_info.indices[i / 4] = idx; - if (!m_this.m_info.mappedColors[idx]) { - m_this.m_info.mappedColors[idx] = {first: i / 4}; + base.m_info.indices[i / 4] = idx; + if (!base.m_info.mappedColors[idx]) { + base.m_info.mappedColors[idx] = {first: i / 4}; } - m_this.m_info.mappedColors[idx].last = i / 4; + base.m_info.mappedColors[idx].last = i / 4; } - return m_this.m_info; + return base.m_info; }; /** @@ -127,23 +138,43 @@ var canvas_pixelmapFeature = function (arg) { * these colors, then draw the resultant image as a quad. * * @fires geo.event.pixelmap.prepared + * @param {object} [quad] A quad to use as the base instead of the class + * instance. */ - this._computePixelmap = function () { + this._computePixelmap = function (quad) { + const base = quad || m_this; 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_this.m_info) { + if (!m_quadFeatureInit && m_quadFeature && !quad) { + m_quadFeature._hookRenderImageQuads = (quads) => { + quads.forEach((quad) => { + if (!quad.m_srcImage) { + quad.m_srcImage = quad.image; + m_this._computePixelmap(quad); + quad.image = quad.m_info.context.canvas; + quad._build = m_this.buildTime().timestamp(); + } else if (m_this.buildTime().timestamp() > quad._build) { + m_this._computePixelmap(quad); + quad.image = quad.m_info.context.canvas; + quad._build = m_this.buildTime().timestamp(); + } + }); + }; + m_quadFeatureInit = true; + } + if (!base.m_info) { m_this.indexModified(undefined, 'clear'); - if (!m_this._preparePixelmap()) { + if (!m_this._preparePixelmap(quad)) { return; } prepared = true; } m_this.indexModified(undefined, 'clear'); - mappedColors = m_this.m_info.mappedColors; - updateFirst = m_this.m_info.area; + mappedColors = base.m_info.mappedColors; + updateFirst = base.m_info.area; for (idx in mappedColors) { if (mappedColors.hasOwnProperty(idx)) { color = colorFunc(data[idx], +idx) || {}; @@ -171,8 +202,8 @@ var canvas_pixelmapFeature = function (arg) { return; } /* Update only the extent that has changed */ - pixelData = m_this.m_info.imageData.data; - indices = m_this.m_info.indices; + pixelData = base.m_info.imageData.data; + indices = base.m_info.indices; for (i = updateFirst; i <= updateLast; i += 1) { idx = indices[i]; if (idx !== lastidx) { @@ -188,10 +219,13 @@ var canvas_pixelmapFeature = function (arg) { } } /* Place the updated area into the canvas */ - m_this.m_info.context.putImageData( - m_this.m_info.imageData, 0, 0, 0, Math.floor(updateFirst / m_this.m_info.width), - m_this.m_info.width, Math.ceil((updateLast + 1) / m_this.m_info.width)); + base.m_info.context.putImageData( + base.m_info.imageData, 0, 0, 0, Math.floor(updateFirst / base.m_info.width), + base.m_info.width, Math.ceil((updateLast + 1) / base.m_info.width)); + if (quad) { + return; + } /* If we haven't made a quad feature, make one now. The quad feature needs * to have the canvas capability. */ if (!m_quadFeature) { @@ -206,6 +240,7 @@ var canvas_pixelmapFeature = function (arg) { position: m_this.style.get('position')}) .data([{}]) .draw(); + m_quadFeatureInit = true; } /* If we prepared the pixelmap and rendered it, send a prepared event */ if (prepared) { @@ -230,6 +265,9 @@ var canvas_pixelmapFeature = function (arg) { return m_this; }; + if (arg.quadFeature) { + m_quadFeature = arg.quadFeature; + } this._init(arg); return this; }; @@ -237,5 +275,8 @@ var canvas_pixelmapFeature = function (arg) { inherit(canvas_pixelmapFeature, pixelmapFeature); // Now register it -registerFeature('canvas', 'pixelmap', canvas_pixelmapFeature); +var capabilities = {}; +capabilities[pixelmapFeature.capabilities.lookup] = true; + +registerFeature('canvas', 'pixelmap', canvas_pixelmapFeature, capabilities); module.exports = canvas_pixelmapFeature; diff --git a/src/canvas/quadFeature.js b/src/canvas/quadFeature.js index 3168501aa9..8876fb9f7a 100644 --- a/src/canvas/quadFeature.js +++ b/src/canvas/quadFeature.js @@ -146,6 +146,9 @@ var canvas_quadFeature = function (arg) { } context2d.imageSmoothingEnabled = !nearestPixel; } + if (m_this._hookRenderImageQuads) { + m_this._hookRenderImageQuads(m_quads.imgQuads); + } $.each([m_quads.imgQuads, m_quads.vidQuads], function (listidx, quadlist) { if (!quadlist) { return; diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index 9de29d56f0..5483a4f0e7 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -261,11 +261,58 @@ var pixelmapFeature = function (arg) { } } else if (m_this.m_info) { m_this._computePixelmap(); - } + } else { + m_this._computePixelmap(); + } // else we need to regenerate the images for canvas m_this.buildTime().modified(); return m_this; }; + /** + * Given the results of the quad search, determine which pixel index is + * found. + * + * @param {object} result An object with `index`: a list of quad indices, + * `found`: a list of quads that contain the specified coordinate, and + * `extra`: an object with keys that are quad indices and values that are + * objects with `basis.x` and `basis.y`, values from 0 - 1 relative to + * interior of the quad. + * @returns {geo.feature.searchResult} An object with a list of features and + * feature indices that are located at the specified point. + */ + this._pointSearchProcess = function (result) { + // use the last index by preference, since for tile layers, this is the + // topmosttile + let idxIdx = result.index.length - 1; + for (; idxIdx >= 0; idxIdx -= 1) { + if (result.extra[result.index[idxIdx]]._quad && + result.extra[result.index[idxIdx]]._quad.image) { + let img = result.extra[result.index[idxIdx]]._quad.image; + if (result.extra[result.index[idxIdx]]._quad.m_srcImage) { + img = result.extra[result.index[idxIdx]]._quad.m_srcImage; + } + const basis = result.extra[result.index[idxIdx]].basis; + const x = Math.floor(basis.x * img.width); + const y = Math.floor(basis.y * img.height); + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d'); + context.drawImage(img, x, y, 1, 1, 0, 0, 1, 1); + const pixel = context.getImageData(0, 0, 1, 1).data; + const idx = pixel[0] + pixel[1] * 256 + pixel[2] * 256 * 256; + if (idx === 16777215) { + continue; + } + result = { + index: [idx], + found: [m_this.data()[idx]] + }; + return result; + } + } + return {index: [], found: []}; + }; + /** * Initialize. * diff --git a/src/webgl/pixelmapFeature.js b/src/webgl/pixelmapFeature.js index e1a8a398a4..75ed300940 100644 --- a/src/webgl/pixelmapFeature.js +++ b/src/webgl/pixelmapFeature.js @@ -46,33 +46,8 @@ var webgl_pixelmapFeature = function (arg) { */ this.pointSearch = function (geo, gcs) { if (m_quadFeature && m_this.m_info) { - let result = m_quadFeature.pointSearch(geo, gcs); - // use the last index by preference, since for tile layers, this is the - // topmosttile - let idxIdx = result.index.length - 1; - for (; idxIdx >= 0; idxIdx -= 1) { - if (result.extra[result.index[idxIdx]]._quad && - result.extra[result.index[idxIdx]]._quad.image) { - const img = result.extra[result.index[idxIdx]]._quad.image; - const basis = result.extra[result.index[idxIdx]].basis; - const x = Math.floor(basis.x * img.width); - const y = Math.floor(basis.y * img.height); - const canvas = document.createElement('canvas'); - canvas.width = canvas.height = 1; - const context = canvas.getContext('2d'); - context.drawImage(img, x, y, 1, 1, 0, 0, 1, 1); - const pixel = context.getImageData(0, 0, 1, 1).data; - const idx = pixel[0] + pixel[1] * 256 + pixel[2] * 256 * 256; - if (idx === 16777215) { - continue; - } - result = { - index: [idx], - found: [m_this.data()[idx]] - }; - return result; - } - } + const result = m_quadFeature.pointSearch(geo, gcs); + return this._pointSearchProcess(result); } return {index: [], found: []}; }; diff --git a/tests/headed-cases/pixelmapLayer.js b/tests/headed-cases/pixelmapLayer.js new file mode 100644 index 0000000000..f732fe30b7 --- /dev/null +++ b/tests/headed-cases/pixelmapLayer.js @@ -0,0 +1,120 @@ +var $ = require('jquery'); +var geo = require('../test-utils').geo; + +describe('canvasPixelmapLayer', function () { + var imageTest = require('../image-test'); + + var map, layer; + + beforeEach(function () { + imageTest.prepareImageTest(); + }); + + afterEach(function () { + map.exit(); + }); + + /** + * Create a tiled pixelmap layer. + * + * @param {object} layerParams Optional layer parameters. + */ + function createPixelmap(layerParams) { + var params = geo.util.pixelCoordinateParams('#map', 4096, 4096, 2048, 2048); + params.layer.url = '/data/pixelmap_{z}_{x}_{y}.png'; + params.layer.data = new Array(5112).fill(0); + params.layer.style = { + color: (d, i) => { + if (i % 2) { + return 'yellow'; + } + i = Math.floor(i / 2); + switch (i % 3) { + case 0: return 'red'; + case 1: return 'green'; + case 2: return 'blue'; + } + } + }; + if (layerParams) { + params.layer = $.extend({}, params.layer, layerParams); + } + params.layer.renderer = 'canvas'; + map = geo.map(params.map); + layer = map.createLayer('pixelmap', params.layer); + map.draw(); + } + + it('color in params', function () { + createPixelmap({style: undefined, color: 'black'}); + expect(layer.style.get('color')(0, 0)).toEqual({r: 0, b: 0, g: 0}); + }); + it('indexModified', function (done) { + createPixelmap(); + expect(layer.indexModified()).toBe(undefined); + layer.indexModified(2); + expect(layer.indexModified()).toEqual([2, 2]); + layer.indexModified(4); + expect(layer.indexModified()).toEqual([2, 4]); + layer.indexModified(1, 3); + expect(layer.indexModified()).toEqual([1, 4]); + layer.indexModified(undefined, 'clear'); + expect(layer.indexModified()).toBe(undefined); + layer.indexModified(2); + layer.draw(); + map.onIdle(done); + }); + it('geoOn and geoOff', function (done) { + createPixelmap(); + var click = false; + layer.geoOn(geo.event.feature.mouseclick, () => { + click = true; + }); + // wait for the tiles to be available + map.onIdle(() => { + expect(click).toBe(false); + map.interactor().simulateEvent('mousedown', {map: {x: 390, y: 200}}); + map.interactor().simulateEvent('mouseup', {map: {x: 390, y: 200}}); + expect(click).toBe(true); + click = false; + layer.geoOff(geo.event.feature.mouseclick); + map.interactor().simulateEvent('mousedown', {map: {x: 390, y: 200}}); + map.interactor().simulateEvent('mouseup', {map: {x: 390, y: 200}}); + expect(click).toBe(false); + done(); + }); + }); + it('geoOn and geoOff from array', function (done) { + createPixelmap(); + var click = 0; + layer.geoOn([geo.event.feature.mouseclick], () => { + click += 1; + }); + // wait for the tiles to be available + map.onIdle(() => { + expect(click).toEqual(0); + map.interactor().simulateEvent('mousedown', {map: {x: 390, y: 200}}); + map.interactor().simulateEvent('mouseup', {map: {x: 390, y: 200}}); + expect(click).toEqual(1); + layer.geoOff([geo.event.feature.mouseclick]); + map.interactor().simulateEvent('mousedown', {map: {x: 390, y: 200}}); + map.interactor().simulateEvent('mouseup', {map: {x: 390, y: 200}}); + expect(click).toEqual(1); + done(); + }); + }); + it('geospatial', function (done) { + map = geo.map({node: '#map'}); + layer = map.createLayer('pixelmap', { + url: geo.osmLayer.tileSources.osm.url, + data: new Array(5112).fill(0), + color: 'black' + }); + map.draw(); + map.onIdle(() => { + expect(layer instanceof geo.pixelmapLayer).toBe(true); + expect(layer.features().length).toBe(2); + done(); + }); + }); +});