@deck.gl/layers
Version:
deck.gl core layers
286 lines • 10.2 kB
JavaScript
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
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 { image: 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 { image: 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,
mipmaps: true
});
const commandEncoder = device.createCommandEncoder();
commandEncoder.copyTextureToTexture({
sourceTexture: texture,
destinationTexture: 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._samplerParameters = 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._samplerParameters = 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._samplerParameters || DEFAULT_SAMPLER_PARAMETERS,
mipmaps: true
});
}
if (this._texture.height !== this._canvasHeight) {
this._texture = resizeTexture(this._texture, this._canvasWidth, this._canvasHeight, this._samplerParameters || 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 { image, width, height } = resizeImage(ctx, imageData, maxWidth, maxHeight);
this._texture?.copyExternalImage({
image,
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--;
});
}
}
}
//# sourceMappingURL=icon-manager.js.map