diff --git a/documentation.yml b/documentation.yml index 058ccfa37e8..7f68be195cf 100644 --- a/documentation.yml +++ b/documentation.yml @@ -37,6 +37,7 @@ toc: - GeoJSONSource - VideoSource - ImageSource + - CanvasSource - name: Options description: | These shared option can be supplied as arguments to various methods diff --git a/js/source/canvas_source.js b/js/source/canvas_source.js new file mode 100644 index 00000000000..9c752f6d873 --- /dev/null +++ b/js/source/canvas_source.js @@ -0,0 +1,126 @@ +'use strict'; + +const ImageSource = require('./image_source'); +const window = require('../util/window'); + +/** + * A data source containing the contents of an HTML canvas. + * (See the [Style Specification](https://www.mapbox.com/mapbox-gl-style-spec/#sources-canvas) for detailed documentation of options.) + * @interface CanvasSource + * @example + * // add to map + * map.addSource('some id', { + * type: 'canvas', + * canvas: 'idOfMyHTMLCanvas', + * animate: true, + * coordinates: [ + * [-76.54, 39.18], + * [-76.52, 39.18], + * [-76.52, 39.17], + * [-76.54, 39.17] + * ] + * }); + * + * // update + * var mySource = map.getSource('some id'); + * mySource.setCoordinates([ + * [-76.54335737228394, 39.18579907229748], + * [-76.52803659439087, 39.1838364847587], + * [-76.5295386314392, 39.17683392507606], + * [-76.54520273208618, 39.17876344106642] + * ]); + * + * map.removeSource('some id'); // remove + */ +class CanvasSource extends ImageSource { + + constructor(id, options, dispatcher, eventedParent) { + super(id, options, dispatcher, eventedParent); + this.options = options; + this.animate = options.hasOwnProperty('animate') ? options.animate : true; + } + + load() { + this.canvas = this.canvas || window.document.getElementById(this.options.canvas); + this.width = this.canvas.width; + this.height = this.canvas.height; + if (this._hasInvalidDimensions(this.canvas)) return this.fire('error', new Error('Canvas dimensions cannot be less than or equal to zero.')); + + let loopID; + + this.play = function() { + loopID = this.map.style.animationLoop.set(Infinity); + this.map._rerender(); + }; + + this.pause = function() { + this.map.style.animationLoop.cancel(loopID); + }; + + this._finishLoading(); + } + + /** + * Returns the HTML `canvas` element. + * + * @returns {HTMLCanvasElement} The HTML `canvas` element. + */ + getCanvas() { + return this.canvas; + } + + onAdd(map) { + if (this.map) return; + this.map = map; + this.load(); + if (this.canvas) { + if (this.animate) this.play(); + this.setCoordinates(this.coordinates); + } + } + + /** + * Sets the canvas's coordinates and re-renders the map. + * + * @method setCoordinates + * @param {Array>} coordinates Four geographical coordinates, + * represented as arrays of longitude and latitude numbers, which define the corners of the canvas. + * The coordinates start at the top left corner of the canvas and proceed in clockwise order. + * They do not have to represent a rectangle. + * @returns {CanvasSource} this + */ + // setCoordinates inherited from ImageSource + + prepare() { + let resize = false; + if (this.canvas.width !== this.width) { + this.width = this.canvas.width; + resize = true; + } + if (this.canvas.height !== this.height) { + this.height = this.canvas.height; + resize = true; + } + if (this._hasInvalidDimensions()) return; + + if (!this.tile) return; // not enough data for current position + this._prepareImage(this.map.painter.gl, this.canvas, resize); + } + + serialize() { + return { + type: 'canvas', + canvas: this.canvas, + coordinates: this.coordinates + }; + } + + _hasInvalidDimensions() { + for (const x of [this.canvas.width, this.canvas.height]) { + if (isNaN(x) || x <= 0) return true; + } + return false; + } +} + +module.exports = CanvasSource; diff --git a/js/source/image_source.js b/js/source/image_source.js index 332a080aa79..dc16a88731e 100644 --- a/js/source/image_source.js +++ b/js/source/image_source.js @@ -147,7 +147,7 @@ class ImageSource extends Evented { this._prepareImage(this.map.painter.gl, this.image); } - _prepareImage(gl, image) { + _prepareImage(gl, image, resize) { if (this.tile.state !== 'loaded') { this.tile.state = 'loaded'; this.tile.texture = gl.createTexture(); @@ -157,7 +157,9 @@ class ImageSource extends Evented { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); - } else if (image instanceof window.HTMLVideoElement) { + } else if (resize) { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); + } else if (image instanceof window.HTMLVideoElement || image instanceof window.ImageData || image instanceof window.HTMLCanvasElement) { gl.bindTexture(gl.TEXTURE_2D, this.tile.texture); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); } diff --git a/js/source/source.js b/js/source/source.js index e4a01bad7ce..d77f7032870 100644 --- a/js/source/source.js +++ b/js/source/source.js @@ -7,7 +7,8 @@ const sourceTypes = { 'raster': require('../source/raster_tile_source'), 'geojson': require('../source/geojson_source'), 'video': require('../source/video_source'), - 'image': require('../source/image_source') + 'image': require('../source/image_source'), + 'canvas': require('../source/canvas_source') }; /* diff --git a/js/source/video_source.js b/js/source/video_source.js index 6854ef9932c..25a2a5379b9 100644 --- a/js/source/video_source.js +++ b/js/source/video_source.js @@ -36,6 +36,7 @@ const ImageSource = require('./image_source'); * @see [Add a video](https://www.mapbox.com/mapbox-gl-js/example/video-on-a-map/) */ class VideoSource extends ImageSource { + constructor(id, options, dispatcher, eventedParent) { super(id, options, dispatcher, eventedParent); this.roundZoom = true; @@ -102,7 +103,7 @@ class VideoSource extends ImageSource { * They do not have to represent a rectangle. * @returns {VideoSource} this */ - // setCoordiates inherited from ImageSource + // setCoordinates inherited from ImageSource prepare() { if (!this.tile || this.video.readyState < 2) return; // not enough data for current position diff --git a/js/style/style.js b/js/style/style.js index 9c642e53ce6..47a4a38f8e0 100644 --- a/js/style/style.js +++ b/js/style/style.js @@ -377,7 +377,7 @@ class Style extends Evented { throw new Error(`The type property must be defined, but the only the following properties were given: ${Object.keys(source)}.`); } - const builtIns = ['vector', 'raster', 'geojson', 'video', 'image']; + const builtIns = ['vector', 'raster', 'geojson', 'video', 'image', 'canvas']; const shouldValidate = builtIns.indexOf(source.type) >= 0; if (shouldValidate && this._validate(validateStyle.source, `sources.${id}`, source, null, options)) return; diff --git a/js/util/window.js b/js/util/window.js index f87d367f613..b4a9e02f97f 100644 --- a/js/util/window.js +++ b/js/util/window.js @@ -43,6 +43,10 @@ function restore() { return originalGetContext.call(this, type, attributes); }; + window.useFakeHTMLCanvasGetContext = function() { + window.HTMLCanvasElement.prototype.getContext = sinon.stub().returns('2d'); + }; + window.useFakeXMLHttpRequest = function() { sinon.xhr.supportsCORS = true; window.server = sinon.fakeServer.create(); @@ -53,6 +57,8 @@ function restore() { window.restore = restore; + window.ImageData = window.ImageData || sinon.stub().returns(false); + return window; } diff --git a/package.json b/package.json index 9e655382490..0311bbc70eb 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "geojson-rewind": "^0.1.0", "geojson-vt": "^2.4.0", "grid-index": "^1.0.0", - "mapbox-gl-function": "mapbox/mapbox-gl-function#4f829622413f336080d3c710244c251421c0ec30", - "mapbox-gl-style-spec": "mapbox/mapbox-gl-style-spec#e85407a377510acb647161de6be6357ab4f606dd", + "mapbox-gl-function": "mapbox/mapbox-gl-function#41c6724e2bbd7bd1eb5991451bbf118b7d02b525", + "mapbox-gl-style-spec": "mapbox/mapbox-gl-style-spec#d11f6d2775bf5b22534b3b2fb3410755b2473cdf", "mapbox-gl-supported": "^1.2.0", "package-json-versionify": "^1.0.2", "pbf": "^1.3.2", diff --git a/test/integration/render-tests/line-width/property-function/style.json b/test/integration/render-tests/line-width/property-function/style.json index e08b6d57424..f55b0894d39 100644 --- a/test/integration/render-tests/line-width/property-function/style.json +++ b/test/integration/render-tests/line-width/property-function/style.json @@ -6,6 +6,9 @@ "ignored":{ "native": "https://github.com/mapbox/mapbox-gl-native/issues/4860", "js":"https://github.com/mapbox/mapbox-gl-js/issues/3682#issuecomment-264348200" + }, + "skipped": { + "js": true } } }, diff --git a/test/integration/render-tests/regressions/mapbox-gl-js#3682/style.json b/test/integration/render-tests/regressions/mapbox-gl-js#3682/style.json index d54a8cabcfb..24fc5011f12 100644 --- a/test/integration/render-tests/regressions/mapbox-gl-js#3682/style.json +++ b/test/integration/render-tests/regressions/mapbox-gl-js#3682/style.json @@ -7,6 +7,9 @@ "ignored": { "js": "https://github.com/mapbox/mapbox-gl-js/issues/3682", "native": "https://github.com/mapbox/mapbox-gl-native/issues/4860" + }, + "skipped": { + "js": true } } }, diff --git a/test/js/source/canvas_source.test.js b/test/js/source/canvas_source.test.js new file mode 100644 index 00000000000..d9b73b99991 --- /dev/null +++ b/test/js/source/canvas_source.test.js @@ -0,0 +1,107 @@ +'use strict'; + +const test = require('mapbox-gl-js-test').test; +const CanvasSource = require('../../../js/source/canvas_source'); +const Transform = require('../../../js/geo/transform'); +const Evented = require('../../../js/util/evented'); +const util = require('../../../js/util/util'); +const window = require('../../../js/util/window'); + +function createSource(options) { + window.useFakeHTMLCanvasGetContext(); + + const c = window.document.createElement('canvas'); + c.width = 20; + c.height = 20; + + options = util.extend({ + canvas: 'id', + coordinates: [[0, 0], [1, 0], [1, 1], [0, 1]] + }, options); + + const source = new CanvasSource('id', options, { send: function() {} }, options.eventedParent); + + source.canvas = c; + + return source; +} + +class StubMap extends Evented { + constructor() { + super(); + this.transform = new Transform(); + this.style = { animationLoop: { set: function() {} } }; + } + + _rerender() { + this.fire('rerender'); + } +} + +test('CanvasSource', (t) => { + t.afterEach((callback) => { + window.restore(); + callback(); + }); + + t.test('constructor', (t) => { + const source = createSource(); + + source.on('source.load', () => { + t.equal(source.minzoom, 0); + t.equal(source.maxzoom, 22); + t.equal(source.tileSize, 512); + t.equal(source.animate, true); + t.equal(typeof source.play, 'function'); + t.end(); + }); + + source.onAdd(new StubMap()); + }); + + t.test('rerenders if animated', (t) => { + const source = createSource(); + const map = new StubMap(); + + map.on('rerender', () => { + t.ok(true, 'fires rerender event'); + t.end(); + }); + + source.onAdd(map); + }); + + t.test('can be static', (t) => { + const source = createSource({ + animate: false + }); + const map = new StubMap(); + + map.on('rerender', () => { + t.notOk(true, 'shouldn\'t rerender here'); + t.end(); + }); + + source.on('source.load', () => { + t.ok(true, 'fires load event without rerendering'); + t.end(); + }); + + source.onAdd(map); + }); + + t.end(); +}); + +test('CanvasSource#serialize', (t) => { + const source = createSource(); + + const serialized = source.serialize(); + t.equal(serialized.type, 'canvas'); + t.ok(serialized.canvas); + t.deepEqual(serialized.coordinates, [[0, 0], [1, 0], [1, 1], [0, 1]]); + + window.restore(); + + t.end(); +});