import { load } from '@loaders.gl/core'; import { createIterable } from '@deck.gl/core'; const DEFAULT_CANVAS_WIDTH = 1024; const DEFAULT_BUFFER = 4; const noop = () => { }; const DEFAULT_SAMPLER_PARAMETERS = { minFilter: 'linear', mipmapFilter: 'linear', // LINEAR is the default value but explicitly set it here magFilter: 'linear', // minimize texture boundary artifacts addressModeU: 'clamp-to-edge', addressModeV: 'clamp-to-edge' }; const MISSING_ICON = { x: 0, y: 0, width: 0, height: 0 }; function nextPowOfTwo(number) { return Math.pow(2, Math.ceil(Math.log2(number))); } // update comment to create a new texture and copy original data. function resizeImage(ctx, imageData, maxWidth, maxHeight) { const resizeRatio = Math.min(maxWidth / imageData.width, maxHeight / imageData.height); const width = Math.floor(imageData.width * resizeRatio); const height = Math.floor(imageData.height * resizeRatio); if (resizeRatio === 1) { // No resizing required return { data: imageData, width, height }; } ctx.canvas.height = height; ctx.canvas.width = width; ctx.clearRect(0, 0, width, height); // image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight ctx.drawImage(imageData, 0, 0, imageData.width, imageData.height, 0, 0, width, height); return { data: ctx.canvas, width, height }; } function getIconId(icon) { return icon && (icon.id || icon.url); } // resize texture without losing original data function resizeTexture(texture, width, height, sampler) { const { width: oldWidth, height: oldHeight, device } = texture; const newTexture = device.createTexture({ format: 'rgba8unorm', width, height, sampler }); const commandEncoder = device.createCommandEncoder(); commandEncoder.copyTextureToTexture({ source: texture, destination: newTexture, width: oldWidth, height: oldHeight }); commandEncoder.finish(); texture.destroy(); return newTexture; } // traverse icons in a row of icon atlas // extend each icon with left-top coordinates function buildRowMapping(mapping, columns, yOffset) { for (let i = 0; i < columns.length; i++) { const { icon, xOffset } = columns[i]; const id = getIconId(icon); mapping[id] = { ...icon, x: xOffset, y: yOffset }; } } /** * Generate coordinate mapping to retrieve icon left-top position from an icon atlas */ export function buildMapping({ icons, buffer, mapping = {}, xOffset = 0, yOffset = 0, rowHeight = 0, canvasWidth }) { let columns = []; // Strategy to layout all the icons into a texture: // traverse the icons sequentially, layout the icons from left to right, top to bottom // when the sum of the icons width is equal or larger than canvasWidth, // move to next row starting from total height so far plus max height of the icons in previous row // row width is equal to canvasWidth // row height is decided by the max height of the icons in that row // mapping coordinates of each icon is its left-top position in the texture for (let i = 0; i < icons.length; i++) { const icon = icons[i]; const id = getIconId(icon); if (!mapping[id]) { const { height, width } = icon; // fill one row if (xOffset + width + buffer > canvasWidth) { buildRowMapping(mapping, columns, yOffset); xOffset = 0; yOffset = rowHeight + yOffset + buffer; rowHeight = 0; columns = []; } columns.push({ icon, xOffset }); xOffset = xOffset + width + buffer; rowHeight = Math.max(rowHeight, height); } } if (columns.length > 0) { buildRowMapping(mapping, columns, yOffset); } return { mapping, rowHeight, xOffset, yOffset, canvasWidth, canvasHeight: nextPowOfTwo(rowHeight + yOffset + buffer) }; } // extract icons from data // return icons should be unique, and not cached or cached but url changed export function getDiffIcons(data, getIcon, cachedIcons) { if (!data || !getIcon) { return null; } cachedIcons = cachedIcons || {}; const icons = {}; const { iterable, objectInfo } = createIterable(data); for (const object of iterable) { objectInfo.index++; const icon = getIcon(object, objectInfo); const id = getIconId(icon); if (!icon) { throw new Error('Icon is missing.'); } if (!icon.url) { throw new Error('Icon url is missing.'); } if (!icons[id] && (!cachedIcons[id] || icon.url !== cachedIcons[id].url)) { icons[id] = { ...icon, source: object, sourceIndex: objectInfo.index }; } } return icons; } export default class IconManager { constructor(device, { onUpdate = noop, onError = noop }) { this._loadOptions = null; this._texture = null; this._externalTexture = null; this._mapping = {}; this._textureParameters = null; /** count of pending requests to fetch icons */ this._pendingCount = 0; this._autoPacking = false; // / internal state used for autoPacking this._xOffset = 0; this._yOffset = 0; this._rowHeight = 0; this._buffer = DEFAULT_BUFFER; this._canvasWidth = DEFAULT_CANVAS_WIDTH; this._canvasHeight = 0; this._canvas = null; this.device = device; this.onUpdate = onUpdate; this.onError = onError; } finalize() { this._texture?.delete(); } getTexture() { return this._texture || this._externalTexture; } getIconMapping(icon) { const id = this._autoPacking ? getIconId(icon) : icon; return this._mapping[id] || MISSING_ICON; } setProps({ loadOptions, autoPacking, iconAtlas, iconMapping, textureParameters }) { if (loadOptions) { this._loadOptions = loadOptions; } if (autoPacking !== undefined) { this._autoPacking = autoPacking; } if (iconMapping) { this._mapping = iconMapping; } if (iconAtlas) { this._texture?.delete(); this._texture = null; this._externalTexture = iconAtlas; } if (textureParameters) { this._textureParameters = textureParameters; } } get isLoaded() { return this._pendingCount === 0; } packIcons(data, getIcon) { if (!this._autoPacking || typeof document === 'undefined') { return; } const icons = Object.values(getDiffIcons(data, getIcon, this._mapping) || {}); if (icons.length > 0) { // generate icon mapping const { mapping, xOffset, yOffset, rowHeight, canvasHeight } = buildMapping({ icons, buffer: this._buffer, canvasWidth: this._canvasWidth, mapping: this._mapping, rowHeight: this._rowHeight, xOffset: this._xOffset, yOffset: this._yOffset }); this._rowHeight = rowHeight; this._mapping = mapping; this._xOffset = xOffset; this._yOffset = yOffset; this._canvasHeight = canvasHeight; // create new texture if (!this._texture) { this._texture = this.device.createTexture({ format: 'rgba8unorm', width: this._canvasWidth, height: this._canvasHeight, sampler: this._textureParameters || DEFAULT_SAMPLER_PARAMETERS }); } if (this._texture.height !== this._canvasHeight) { this._texture = resizeTexture(this._texture, this._canvasWidth, this._canvasHeight, this._textureParameters || DEFAULT_SAMPLER_PARAMETERS); } this.onUpdate(); // load images this._canvas = this._canvas || document.createElement('canvas'); this._loadIcons(icons); } } _loadIcons(icons) { // This method is only called in the auto packing case, where _canvas is defined const ctx = this._canvas.getContext('2d', { willReadFrequently: true }); for (const icon of icons) { this._pendingCount++; load(icon.url, this._loadOptions) .then(imageData => { const id = getIconId(icon); const iconDef = this._mapping[id]; const { x, y, width: maxWidth, height: maxHeight } = iconDef; const { data, width, height } = resizeImage(ctx, imageData, maxWidth, maxHeight); // @ts-expect-error TODO v9 API not yet clear this._texture.setSubImageData({ data, x: x + (maxWidth - width) / 2, y: y + (maxHeight - height) / 2, width, height }); iconDef.width = width; iconDef.height = height; // Call to regenerate mipmaps after modifying texture(s) // @ts-expect-error TODO v9 API not yet clear this._texture.generateMipmap(); this.onUpdate(); }) .catch(error => { this.onError({ url: icon.url, source: icon.source, sourceIndex: icon.sourceIndex, loadOptions: this._loadOptions, error }); }) .finally(() => { this._pendingCount--; }); } } }