From 473789b39ee8e2d6dbf5199342f5b9ad525358db Mon Sep 17 00:00:00 2001 From: Ansis Brammanis Date: Wed, 6 Mar 2019 15:05:59 -0500 Subject: [PATCH 1/5] add support for animated images Add support for a `StyleImageInterface` that allows images to be rerendered with a `render` method. Add a `map.updateImage(...)` method that can be used to do one-off updates to images. --- docs/documentation.yml | 1 + docs/pages/example/add-image-animated.html | 85 +++++++++++++++ docs/pages/example/add-image-animated.js | 11 ++ src/render/image_atlas.js | 37 ++++++- src/render/image_manager.js | 71 ++++++++++-- src/render/painter.js | 2 + src/render/texture.js | 9 +- src/source/source_cache.js | 4 +- src/source/tile.js | 7 ++ src/style/style.js | 4 + src/style/style_image.js | 121 ++++++++++++++++++++- src/ui/map.js | 59 +++++++++- src/util/image.js | 8 ++ 13 files changed, 396 insertions(+), 23 deletions(-) create mode 100644 docs/pages/example/add-image-animated.html create mode 100644 docs/pages/example/add-image-animated.js diff --git a/docs/documentation.yml b/docs/documentation.yml index 1bf4e883133..00ff10f2c81 100644 --- a/docs/documentation.yml +++ b/docs/documentation.yml @@ -9,6 +9,7 @@ toc: - CameraOptions - PaddingOptions - RequestParameters + - StyleImageInterface - CustomLayerInterface - name: Geography & Geometry description: | diff --git a/docs/pages/example/add-image-animated.html b/docs/pages/example/add-image-animated.html new file mode 100644 index 00000000000..387cfbbf742 --- /dev/null +++ b/docs/pages/example/add-image-animated.html @@ -0,0 +1,85 @@ +
+ + diff --git a/docs/pages/example/add-image-animated.js b/docs/pages/example/add-image-animated.js new file mode 100644 index 00000000000..097a92010e2 --- /dev/null +++ b/docs/pages/example/add-image-animated.js @@ -0,0 +1,11 @@ +/*--- +title: Add an animated icon to the map +description: Add an animated icon to the map that was generated at runtime with a Canvas. +tags: + - styles + - layers +pathname: /mapbox-gl-js/example/add-image-animated/ +---*/ +import Example from '../../components/example'; +import html from './add-image-animated.html'; +export default Example(html); diff --git a/src/render/image_atlas.js b/src/render/image_atlas.js index 8bb25e12d8a..19270fddf8f 100644 --- a/src/render/image_atlas.js +++ b/src/render/image_atlas.js @@ -5,6 +5,8 @@ import { register } from '../util/web_worker_transfer'; import potpack from 'potpack'; import type {StyleImage} from '../style/style_image'; +import type ImageManager from './image_manager'; +import type Texture from './texture'; const padding = 1; @@ -19,10 +21,12 @@ type Rect = { export class ImagePosition { paddedRect: Rect; pixelRatio: number; + version: number; - constructor(paddedRect: Rect, {pixelRatio}: StyleImage) { + constructor(paddedRect: Rect, {pixelRatio, version}: StyleImage) { this.paddedRect = paddedRect; this.pixelRatio = pixelRatio; + this.version = version; } get tl(): [number, number] { @@ -55,10 +59,11 @@ export default class ImageAtlas { image: RGBAImage; iconPositions: {[string]: ImagePosition}; patternPositions: {[string]: ImagePosition}; + haveRenderCallbacks: Array; uploaded: ?boolean; constructor(icons: {[string]: StyleImage}, patterns: {[string]: StyleImage}) { - const iconPositions = {}, patternPositions = {}; + const iconPositions = {}, patternPositions = {}, haveRenderCallbacks = []; const bins = []; for (const id in icons) { @@ -71,6 +76,10 @@ export default class ImageAtlas { }; bins.push(bin); iconPositions[id] = new ImagePosition(bin, src); + + if (src.hasRenderCallback) { + haveRenderCallbacks.push(id); + } } for (const id in patterns) { @@ -83,6 +92,10 @@ export default class ImageAtlas { }; bins.push(bin); patternPositions[id] = new ImagePosition(bin, src); + + if (src.hasRenderCallback) { + haveRenderCallbacks.push(id); + } } const {w, h} = potpack(bins); @@ -113,7 +126,27 @@ export default class ImageAtlas { this.image = image; this.iconPositions = iconPositions; this.patternPositions = patternPositions; + this.haveRenderCallbacks = haveRenderCallbacks; + } + + patchUpdatedImages(imageManager: ImageManager, texture: Texture) { + imageManager.dispatchRenderCallbacks(this.haveRenderCallbacks); + for (const name in imageManager.updatedImages) { + this.patchUpdatedImage(this.iconPositions[name], imageManager.getImage(name), texture); + this.patchUpdatedImage(this.patternPositions[name], imageManager.getImage(name), texture); + } } + + patchUpdatedImage(position: ?ImagePosition, image: ?StyleImage, texture: Texture) { + if (!position || !image) return; + + if (position.version === image.version) return; + + position.version = image.version; + const tl = position.tl; + texture.update(image.data, undefined, { x: tl[0], y: tl[1] }); + } + } register('ImagePosition', ImagePosition); diff --git a/src/render/image_manager.js b/src/render/image_manager.js index cc0c57c44c3..8dbba9b95c6 100644 --- a/src/render/image_manager.js +++ b/src/render/image_manager.js @@ -7,6 +7,7 @@ import { RGBAImage } from '../util/image'; import { ImagePosition } from './image_atlas'; import Texture from './texture'; import assert from 'assert'; +import {renderStyleImage} from '../style/style_image'; import type {StyleImage} from '../style/style_image'; import type Context from '../gl/context'; @@ -25,7 +26,7 @@ type Pattern = { const padding = 1; /* - ImageManager does two things: + ImageManager does three things: 1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled. 2. Builds a texture atlas for pattern images. @@ -36,6 +37,8 @@ const padding = 1; */ class ImageManager extends Evented { images: {[string]: StyleImage}; + updatedImages: {[string]: boolean}; + callbackDispatchedThisFrame: {[string]: boolean}; loaded: boolean; requestors: Array<{ids: Array, callback: Callback<{[string]: StyleImage}>}>; @@ -47,6 +50,8 @@ class ImageManager extends Evented { constructor() { super(); this.images = {}; + this.updatedImages = {}; + this.callbackDispatchedThisFrame = {}; this.loaded = false; this.requestors = []; @@ -83,10 +88,25 @@ class ImageManager extends Evented { this.images[id] = image; } + updateImage(id: string, image: StyleImage) { + const oldImage = this.images[id]; + assert(oldImage); + assert(oldImage.data.width === image.data.width); + assert(oldImage.data.height === image.data.height); + image.version = oldImage.version + 1; + this.images[id] = image; + this.updatedImages[id] = true; + } + removeImage(id: string) { assert(this.images[id]); + const image = this.images[id]; delete this.images[id]; delete this.patterns[id]; + + if (image.userImage && image.userImage.onRemove) { + image.userImage.onRemove(); + } } listImages(): Array { @@ -126,7 +146,9 @@ class ImageManager extends Evented { response[id] = { data: image.data.clone(), pixelRatio: image.pixelRatio, - sdf: image.sdf + sdf: image.sdf, + version: image.version, + hasRenderCallback: Boolean(image.userImage && image.userImage.render) }; } } @@ -143,23 +165,29 @@ class ImageManager extends Evented { getPattern(id: string): ?ImagePosition { const pattern = this.patterns[id]; - if (pattern) { - return pattern.position; - } const image = this.getImage(id); if (!image) { return null; } - const w = image.data.width + padding * 2; - const h = image.data.height + padding * 2; - const bin = {w, h, x: 0, y: 0}; - const position = new ImagePosition(bin, image); - this.patterns[id] = {bin, position}; + if (pattern && pattern.position.version === image.version) { + return pattern.position; + } + + if (!pattern) { + const w = image.data.width + padding * 2; + const h = image.data.height + padding * 2; + const bin = {w, h, x: 0, y: 0}; + const position = new ImagePosition(bin, image); + this.patterns[id] = {bin, position}; + } else { + pattern.position.version = image.version; + } + this._updatePatternAtlas(); - return position; + return this.patterns[id].position; } bind(context: Context) { @@ -204,6 +232,27 @@ class ImageManager extends Evented { this.dirty = true; } + + beginFrame() { + this.callbackDispatchedThisFrame = {}; + } + + dispatchRenderCallbacks(ids: Array) { + for (const id of ids) { + + // the callback for the image was already dispatched for a different frame + if (this.callbackDispatchedThisFrame[id]) continue; + this.callbackDispatchedThisFrame[id] = true; + + const image = this.images[id]; + assert(image); + + const updated = renderStyleImage(image); + if (updated) { + this.updateImage(id, image); + } + } + } } export default ImageManager; diff --git a/src/render/painter.js b/src/render/painter.js index 03cadf9e8ba..6f749086f25 100644 --- a/src/render/painter.js +++ b/src/render/painter.js @@ -329,6 +329,8 @@ class Painter { this.symbolFadeChange = style.placement.symbolFadeChange(browser.now()); + this.imageManager.beginFrame(); + const layerIds = this.style._order; const sourceCaches = this.style.sourceCaches; diff --git a/src/render/texture.js b/src/render/texture.js index 160266fa442..ef71a1a519a 100644 --- a/src/render/texture.js +++ b/src/render/texture.js @@ -49,9 +49,9 @@ class Texture { this.update(image, options); } - update(image: TextureImage, options: ?{premultiply?: boolean, useMipmap?: boolean}) { + update(image: TextureImage, options: ?{premultiply?: boolean, useMipmap?: boolean}, position?: { x: number, y: number }) { const {width, height} = image; - const resize = !this.size || this.size[0] !== width || this.size[1] !== height; + const resize = (!this.size || this.size[0] !== width || this.size[1] !== height) && !position; const {context} = this; const {gl} = context; @@ -72,10 +72,11 @@ class Texture { } } else { + const {x, y} = position || { x: 0, y: 0}; if (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || image instanceof HTMLVideoElement || image instanceof ImageData) { - gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, gl.RGBA, gl.UNSIGNED_BYTE, image); } else { - gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, image.data); + gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, image.data); } } diff --git a/src/source/source_cache.js b/src/source/source_cache.js index 76a5cb2660d..b501eaa2d95 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -169,7 +169,9 @@ class SourceCache extends Evented { this._state.coalesceChanges(this._tiles, this.map ? this.map.painter : null); for (const i in this._tiles) { - this._tiles[i].upload(context); + const tile = this._tiles[i]; + tile.upload(context); + tile.prepare(this.map.style.imageManager); } } diff --git a/src/source/tile.js b/src/source/tile.js index c5e80a046b9..f04e3fb993b 100644 --- a/src/source/tile.js +++ b/src/source/tile.js @@ -25,6 +25,7 @@ import type {WorkerTileResult} from './worker_source'; import type DEMData from '../data/dem_data'; import type {AlphaImage} from '../util/image'; import type ImageAtlas from '../render/image_atlas'; +import type ImageManager from '../render/image_manager'; import type Mask from '../render/tile_mask'; import type Context from '../gl/context'; import type IndexBuffer from '../gl/index_buffer'; @@ -254,6 +255,12 @@ class Tile { } } + prepare(imageManager: ImageManager) { + if (this.imageAtlas) { + this.imageAtlas.patchUpdatedImages(imageManager, this.imageAtlasTexture); + } + } + // Queries non-symbol features rendered for this tile. // Symbol features are queried globally queryRenderedFeatures(layers: {[string]: StyleLayer}, diff --git a/src/style/style.js b/src/style/style.js index 6027b44cbe7..deff109dd11 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -466,6 +466,10 @@ class Style extends Evented { this.fire(new Event('data', {dataType: 'style'})); } + updateImage(id: string, image: StyleImage) { + this.imageManager.updateImage(id, image); + } + getImage(id: string): ?StyleImage { return this.imageManager.getImage(id); } diff --git a/src/style/style_image.js b/src/style/style_image.js index 90e8b6317e1..a746cdcb9c3 100644 --- a/src/style/style_image.js +++ b/src/style/style_image.js @@ -1,9 +1,126 @@ // @flow -import type {RGBAImage} from '../util/image'; +import {RGBAImage} from '../util/image'; + +import type Map from '../ui/map'; export type StyleImage = { data: RGBAImage, pixelRatio: number, - sdf: boolean + sdf: boolean, + version: number, + hasRenderCallback?: boolean, + userImage?: StyleImageInterface +}; + +export type StyleImageInterface = { + width: number, + height: number, + data: Uint8Array | Uint8ClampedArray, + render?: () => void, + onAdd?: (map: Map, id: string) => void, + onRemove?: () => void }; + +export function renderStyleImage(image: StyleImage) { + const {userImage} = image; + if (userImage && userImage.render) { + const updated = userImage.render(); + if (updated) { + image.data.replace(new Uint8Array(userImage.data.buffer)); + return true; + } + } + return false; +} + +/** + * Interface for dynamically generated style images. This is a specification for + * implementers to model: it is not an exported method or class. + * + * Images implementing this interface can be redrawn for every frame. They can be used to animate + * icons and patterns or make them respond to user input. Style images can implement a + * {@link StyleImageInterface#render} method. The method is called every frame and + * can be used to update the image. + * + * @interface StyleImageInterface + * @property {number} width + * @property {number} height + * @property {Uint8Array | Uint8ClampedArray} data + * + * @see [Add an animated icon to the map.](https://docs.mapbox.com/mapbox-gl-js/example/add-image-animated/) + * + * @example + * var flashingSquare = { + * width: 64, + * height: 64, + * data: new Uint8Array(64 * 64 * 4), + * + * onAdd: function(map) { + * this.map = map; + * }, + * + * render: function() { + * // keep repainting while the icon is on the map + * this.map.triggerRepaint(); + * + * // alternate between black and white based on the time + * var value = Math.round(Date.now() / 1000) % 2 === 0 ? 255 : 0; + * + * // check if image needs to be changed + * if (value !== this.previousValue) { + * this.previousValue = value; + * + * var bytesPerPixel = 4; + * for (var x = 0; x < this.width; x++) { + * for (var y = 0; y < this.height; y++) { + * var offset = (y * this.width + x) * bytesPerPixel; + * this.data[offset + 0] = value; + * this.data[offset + 1] = value; + * this.data[offset + 2] = value; + * this.data[offset + 3] = 255; + * } + * } + * + * // return true to indicate that the image changed + * return true; + * } + * } + * } + * + * map.addImage('flashing_square', flashingSquare); + */ + +/** + * This method is called once before every frame where the icon will be used. + * The method can optionally update the image's `data` member with a new image. + * + * If the method updates the image it must return `true` to commit the change. + * If the method returns `false` or nothing the image is assumed to not have changed. + * + * @function + * @memberof StyleImageInterface + * @instance + * @name render + * @return {boolean} `true` if this method updated the image. `false` if the image was not changed. + */ + +/** + * Optional method called when the layer has been added to the Map with {@link Map#addImage}. + * + * @function + * @memberof StyleImageInterface + * @instance + * @name onAdd + * @param {Map} map The Map this custom layer was just added to. + */ + +/** + * Optional method called when the icon is removed from the map with {@link Map#removeImage}. + * This gives the image a chance to clean up resources and event listeners. + * + * @function + * @memberof StyleImageInterface + * @instance + * @name onRemove + */ diff --git a/src/ui/map.js b/src/ui/map.js index 6b1141363b3..e02ccca3f6c 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -34,6 +34,7 @@ import type {RequestParameters} from '../util/ajax'; import type {StyleOptions, StyleSetterOptions} from '../style/style'; import type {MapEvent, MapDataEvent} from './events'; import type {CustomLayerInterface} from '../style/style_layer/custom_style_layer'; +import type {StyleImageInterface} from '../style/style_image'; import type ScrollZoomHandler from './handler/scroll_zoom'; import type BoxZoomHandler from './handler/box_zoom'; @@ -1145,19 +1146,71 @@ class Map extends Camera { * @param options.sdf Whether the image should be interpreted as an SDF image */ addImage(id: string, - image: HTMLImageElement | ImageData | {width: number, height: number, data: Uint8Array | Uint8ClampedArray}, + image: HTMLImageElement | ImageData | {width: number, height: number, data: Uint8Array | Uint8ClampedArray} | StyleImageInterface, {pixelRatio = 1, sdf = false}: {pixelRatio?: number, sdf?: boolean} = {}) { + + const version = 0; + if (image instanceof HTMLImageElement) { const {width, height, data} = browser.getImageData(image); - this.style.addImage(id, { data: new RGBAImage({width, height}, data), pixelRatio, sdf }); + this.style.addImage(id, { data: new RGBAImage({width, height}, data), pixelRatio, sdf, version }); } else if (image.width === undefined || image.height === undefined) { return this.fire(new ErrorEvent(new Error( 'Invalid arguments to map.addImage(). The second argument must be an `HTMLImageElement`, `ImageData`, ' + 'or object with `width`, `height`, and `data` properties with the same format as `ImageData`'))); } else { const {width, height, data} = image; - this.style.addImage(id, { data: new RGBAImage({width, height}, new Uint8Array(data)), pixelRatio, sdf }); + const userImage = ((image: any): StyleImageInterface); + + this.style.addImage(id, { + data: new RGBAImage({width, height}, new Uint8Array(data)), + pixelRatio, + sdf, + version, + userImage + }); + + if (userImage.onAdd) { + userImage.onAdd(this, id); + } + } + } + + /** + * Update an existing style image. This image can be used in `icon-image`, + * `background-pattern`, `fill-pattern`, and `line-pattern`. + * + * @param id The ID of the image. + * @param image The image as an `HTMLImageElement`, `ImageData`, or object with `width`, `height`, and `data` + * properties with the same format as `ImageData`. + */ + updateImage(id: string, + image: HTMLImageElement | ImageData | {width: number, height: number, data: Uint8Array | Uint8ClampedArray} | StyleImageInterface) { + + const existingImage = this.style.getImage(id); + if (!existingImage) { + return this.fire(new ErrorEvent(new Error( + 'The map has no image with that id. If you are adding a new image use `map.addImage(...)` instead.'))); } + + const imageData = image instanceof HTMLImageElement ? browser.getImageData(image) : image; + const {width, height, data} = imageData; + + if (width === undefined || height === undefined) { + return this.fire(new ErrorEvent(new Error( + 'Invalid arguments to map.updateImage(). The second argument must be an `HTMLImageElement`, `ImageData`, ' + + 'or object with `width`, `height`, and `data` properties with the same format as `ImageData`'))); + } + + if (width !== existingImage.data.width || height !== existingImage.data.height) { + return this.fire(new ErrorEvent(new Error( + 'The width and height of the updated image must be that same as the previous version of the image'))); + } + + const copy = !(image instanceof HTMLImageElement); + existingImage.data.replace(data, copy); + + this.style.updateImage(id, existingImage); } /** diff --git a/src/util/image.js b/src/util/image.js index 2e356bfe4c2..fd3a4395ae2 100644 --- a/src/util/image.js +++ b/src/util/image.js @@ -115,6 +115,14 @@ export class RGBAImage { resizeImage(this, size, 4); } + replace(data: Uint8Array | Uint8ClampedArray, copy?: boolean) { + if (copy) { + this.data.set(data); + } else { + this.data = data; + } + } + clone() { return new RGBAImage({width: this.width, height: this.height}, new Uint8Array(this.data)); } From 2bc2eb6316977ee1407c323fc1470bf40d50ff9c Mon Sep 17 00:00:00 2001 From: Ansis Brammanis Date: Tue, 12 Mar 2019 15:13:52 -0400 Subject: [PATCH 2/5] fix: es6 nit --- src/render/image_atlas.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/render/image_atlas.js b/src/render/image_atlas.js index 19270fddf8f..1d920e6ff96 100644 --- a/src/render/image_atlas.js +++ b/src/render/image_atlas.js @@ -143,8 +143,8 @@ export default class ImageAtlas { if (position.version === image.version) return; position.version = image.version; - const tl = position.tl; - texture.update(image.data, undefined, { x: tl[0], y: tl[1] }); + const [x, y] = position.tl; + texture.update(image.data, undefined, {x, y}); } } From 92b19fa454864ad2302ec0bfcbc50dc4cc1ac787 Mon Sep 17 00:00:00 2001 From: Ansis Brammanis Date: Tue, 12 Mar 2019 15:14:06 -0400 Subject: [PATCH 3/5] fix: dry up loop --- src/render/image_atlas.js | 54 ++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/src/render/image_atlas.js b/src/render/image_atlas.js index 1d920e6ff96..51ad8c729e7 100644 --- a/src/render/image_atlas.js +++ b/src/render/image_atlas.js @@ -63,40 +63,13 @@ export default class ImageAtlas { uploaded: ?boolean; constructor(icons: {[string]: StyleImage}, patterns: {[string]: StyleImage}) { - const iconPositions = {}, patternPositions = {}, haveRenderCallbacks = []; + const iconPositions = {}, patternPositions = {}; + this.haveRenderCallbacks = []; const bins = []; - for (const id in icons) { - const src = icons[id]; - const bin = { - x: 0, - y: 0, - w: src.data.width + 2 * padding, - h: src.data.height + 2 * padding, - }; - bins.push(bin); - iconPositions[id] = new ImagePosition(bin, src); - if (src.hasRenderCallback) { - haveRenderCallbacks.push(id); - } - } - - for (const id in patterns) { - const src = patterns[id]; - const bin = { - x: 0, - y: 0, - w: src.data.width + 2 * padding, - h: src.data.height + 2 * padding, - }; - bins.push(bin); - patternPositions[id] = new ImagePosition(bin, src); - - if (src.hasRenderCallback) { - haveRenderCallbacks.push(id); - } - } + this.addImages(icons, iconPositions, bins); + this.addImages(patterns, patternPositions, bins); const {w, h} = potpack(bins); const image = new RGBAImage({width: w || 1, height: h || 1}); @@ -126,7 +99,24 @@ export default class ImageAtlas { this.image = image; this.iconPositions = iconPositions; this.patternPositions = patternPositions; - this.haveRenderCallbacks = haveRenderCallbacks; + } + + addImages(images: {[string]: StyleImage}, positions: {[string]: ImagePosition}, bins: Array) { + for (const id in images) { + const src = images[id]; + const bin = { + x: 0, + y: 0, + w: src.data.width + 2 * padding, + h: src.data.height + 2 * padding, + }; + bins.push(bin); + positions[id] = new ImagePosition(bin, src); + + if (src.hasRenderCallback) { + this.haveRenderCallbacks.push(id); + } + } } patchUpdatedImages(imageManager: ImageManager, texture: Texture) { From 740518f51c43813916c67b19684662de649ec85b Mon Sep 17 00:00:00 2001 From: Ansis Brammanis Date: Tue, 12 Mar 2019 15:17:58 -0400 Subject: [PATCH 4/5] add the third thing --- src/render/image_manager.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/render/image_manager.js b/src/render/image_manager.js index 8dbba9b95c6..b2a633a9add 100644 --- a/src/render/image_manager.js +++ b/src/render/image_manager.js @@ -30,6 +30,7 @@ const padding = 1; 1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled. 2. Builds a texture atlas for pattern images. + 3. Rerenders renderable images once per frame These are disparate responsibilities and should eventually be handled by different classes. When we implement data-driven support for `*-pattern`, we'll likely use per-bucket pattern atlases, and that would be a good time From 719a245675a18df313810be90a152c94cc347595 Mon Sep 17 00:00:00 2001 From: Ansis Brammanis Date: Tue, 12 Mar 2019 15:31:02 -0400 Subject: [PATCH 5/5] fix: direct people towards map.updateImage --- src/style/style_image.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/style/style_image.js b/src/style/style_image.js index a746cdcb9c3..fdb62515301 100644 --- a/src/style/style_image.js +++ b/src/style/style_image.js @@ -98,6 +98,9 @@ export function renderStyleImage(image: StyleImage) { * If the method updates the image it must return `true` to commit the change. * If the method returns `false` or nothing the image is assumed to not have changed. * + * If updates are infrequent it maybe easier to use {@link Map#updateImage} to update + * the image instead of implementing this method. + * * @function * @memberof StyleImageInterface * @instance