/**
* Convert hex color string to normalized RGBA array [0,1]
* @private
* @param {string} hex - Hex color string (e.g., '#FF0000' or '#FF0000FF')
* @returns {number[]} Normalized RGBA array [r, g, b, a]
*/
const hexToRGBA = (hex) => {
const hexClean = hex.replace('#', '');
const r = parseInt(hexClean.substring(0, 2), 16) / 255;
const g = parseInt(hexClean.substring(2, 4), 16) / 255;
const b = parseInt(hexClean.substring(4, 6), 16) / 255;
const a = hexClean.length === 8 ? parseInt(hexClean.substring(6, 8), 16) / 255 : 1.0;
return [r, g, b, a];
};
/**
* Add a new style or update an existing one
* Supports two types of styles:
* - Type 1 (Direct RGBA): Function returns [r, g, b, a] directly
* - Type 2 (Gradient): Function returns [scalar, 0, 0, alpha] with colors/stops/domain in options
*
* @param {string} name - Unique style name
* @param {Function} fn - GPU.js kernel function that processes band data
* @param {Object} [options={}] - Configuration for gradient-based styles (Type 2)
* @param {string[]} [options.colors] - Array of hex color strings for gradient
* @param {number[]} [options.stops] - Array of stop values corresponding to colors
* @param {number[]} [options.domain] - Optional scalar domain [min, max] for LUT mapping
* @returns {void}
* @public
* @example
* // Type 1: Direct RGBA
* multiband.addStyle('True Color', function(data) {
* const r = data[3][this.thread.y][this.thread.x] * 0.00003051757;
* const g = data[2][this.thread.y][this.thread.x] * 0.00003051757;
* const b = data[1][this.thread.y][this.thread.x] * 0.00003051757;
* return [r, g, b, 0.8];
* });
*
* // Type 2: Gradient
* multiband.addStyle('NDVI', function(data) {
* const b4 = data[3][this.thread.y][this.thread.x];
* const b8 = data[7][this.thread.y][this.thread.x];
* const ndvi = (b8 - b4) / (b8 + b4);
* return [ndvi, 0, 0, 0.8];
* }, {
* colors: ['#a50026', '#d73027', '#006837'],
* stops: [-0.2, 0.0, 0.8],
* domain: [-1.0, 1.0]
* });
*/
function addStyle(name, fn, options = {}) {
if (typeof name !== 'string' || !name.trim()) {
console.error('[Multiband] addStyle: name must be a non-empty string');
return;
}
if (typeof fn !== 'function') {
console.error('[Multiband] addStyle: fn must be a function');
return;
}
let colors = null;
let stops = null;
let rgbaColors = null;
let domain = null;
if (typeof options.domain !== 'undefined') {
if (!Array.isArray(options.domain) || options.domain.length !== 2) {
console.error('[Multiband] addStyle: domain must be a 2-element array [min, max]');
return;
}
const domainMin = Number(options.domain[0]);
const domainMax = Number(options.domain[1]);
if (!Number.isFinite(domainMin) || !Number.isFinite(domainMax) || domainMax <= domainMin) {
console.error('[Multiband] addStyle: domain values must be finite and satisfy max > min');
return;
}
domain = [domainMin, domainMax];
}
if (options.colors && options.stops) {
if (!Array.isArray(options.colors) || !Array.isArray(options.stops)) {
console.error('[Multiband] addStyle: colors and stops must be arrays');
return;
}
if (options.colors.length !== options.stops.length) {
console.error('[Multiband] addStyle: colors and stops arrays must have the same length');
return;
}
if (options.colors.length < 2) {
console.error('[Multiband] addStyle: colors and stops arrays must have at least 2 elements');
return;
}
colors = options.colors;
stops = options.stops;
try {
rgbaColors = colors.map(hexToRGBA);
} catch (error) {
console.error(`[Multiband] addStyle: failed to convert hex colors for style "${name}"`, error);
return;
}
} else if (domain) {
console.error('[Multiband] addStyle: domain can only be used with gradient styles (requires colors and stops)');
return;
}
let kernel = null;
try {
kernel = this._compileStyleKernel(fn);
} catch (error) {
console.error(`[Multiband] addStyle: failed to compile kernel for style "${name}"`, error);
return;
}
const existingIndex = this._styles.findIndex((s) => s.name === name);
if (existingIndex >= 0) {
const previousKernel = this._styles[existingIndex].kernel;
if (previousKernel && typeof previousKernel.destroy === 'function') {
previousKernel.destroy();
}
this._styles[existingIndex] = {
name,
fn,
kernel,
colors,
stops,
domain,
rgbaColors,
colorLUT: null
};
if (name === this._activeStyleName) {
this._activeStyleKernel = kernel;
this._activeStyle = this._styles[existingIndex];
}
if (this.debug) {
console.log(`[Multiband] Updated existing style: ${name}`);
}
} else {
this._styles.push({
name,
fn,
kernel,
colors,
stops,
domain,
rgbaColors,
colorLUT: null
});
this._stylesCacheInvalid = true;
if (name === this._activeStyleName) {
this._activeStyleKernel = kernel;
this._activeStyle = this._styles[this._styles.length - 1];
}
if (this.debug) {
console.log(`[Multiband] Added new style: ${name}`);
}
}
}
/**
* Remove a style from the collection
* The default style "Band 1 - grayscale" cannot be removed
*
* @param {string} name - Style name to remove
* @returns {void}
* @public
*/
function removeStyle(name) {
if (name === 'Band 1 - grayscale') {
if (this.debug) {
console.warn('[Multiband] Cannot remove default style "Band 1 - grayscale"');
}
return;
}
const index = this._styles.findIndex((s) => s.name === name);
if (index >= 0) {
const styleToRemove = this._styles[index];
if (styleToRemove && styleToRemove.kernel && typeof styleToRemove.kernel.destroy === 'function') {
styleToRemove.kernel.destroy();
}
this._styles.splice(index, 1);
this._stylesCacheInvalid = true;
if (this._activeStyleName === name) {
this._activeStyleName = 'Band 1 - grayscale';
const defaultStyle = this._styles.find((s) => s.name === 'Band 1 - grayscale');
this._activeStyleKernel = defaultStyle ? defaultStyle.kernel : null;
this._activeStyle = defaultStyle || null;
if (this.debug) {
console.log(`[Multiband] Active style was removed, reset to: ${this._activeStyleName}`);
}
}
if (this.debug) {
console.log(`[Multiband] Removed style: ${name}`);
}
} else if (this.debug) {
console.log(`[Multiband] Style not found: ${name}`);
}
}
/**
* Set the active rendering style and optionally refresh tiles
* If the style is not found, defaults to "Band 1 - grayscale"
*
* @param {string} name - Style name to activate
* @param {Object} [target] - deck.gl instance or map-like object exposing __deck; if provided, tiles are refreshed immediately
* @param {string} [layerId='cog-layer'] - COGLayer id used when refreshing tiles
* @returns {void}
* @public
*/
function setActiveStyle(name, target, layerId = 'cog-layer') {
const style = this._styles.find((s) => s.name === name);
if (!style) {
console.error(`[Multiband] Style not found: ${name}, defaulting to "Band 1 - grayscale"`);
this._activeStyleName = 'Band 1 - grayscale';
const defaultStyle = this._styles.find((s) => s.name === 'Band 1 - grayscale');
this._activeStyleKernel = this._ensureStyleKernel(defaultStyle);
this._activeStyle = defaultStyle || null;
} else {
const previousStyle = this._activeStyleName;
this._activeStyleName = name;
this._activeStyleKernel = this._ensureStyleKernel(style);
this._activeStyle = style;
if (this.debug) {
console.log(`[Multiband] Active style changed: "${previousStyle}" → "${name}"`);
}
}
if (target) {
refreshTiles.call(this, target, layerId);
}
}
/**
* Get the name of the currently active style
*
* @returns {string} Active style name
* @public
*/
function getActiveStyle() {
return this._activeStyleName;
}
/**
* Get an array of all available style names
* Results are cached and only recomputed when styles are added/removed
*
* @returns {string[]} Array of style names
* @public
*/
function getStyles() {
if (!this._cachedStyleNames || this._stylesCacheInvalid) {
this._cachedStyleNames = this._styles.map((s) => s.name);
this._stylesCacheInvalid = false;
}
return this._cachedStyleNames;
}
/**
* Refresh tiles by clearing cached luma textures and forcing re-render
* Called internally when active style changes
*
* @param {Object} target - deck.gl instance or map-like object
* @param {string} [layerId='cog-layer'] - COGLayer id to refresh
* @returns {void}
* @private
*/
function refreshTiles(target, layerId = 'cog-layer') {
if (this.debug) {
console.log(`[Multiband] refreshTiles called for layer: ${layerId}, active style: ${this._activeStyleName}`);
}
const deckInstance = resolveDeckInstance(target);
const requestRepaint = () => {
if (target && typeof target.triggerRepaint === 'function') {
target.triggerRepaint();
} else if (deckInstance && typeof deckInstance.redraw === 'function') {
deckInstance.redraw();
}
};
if (!deckInstance) {
if (this.debug) {
console.warn('[Multiband] refreshTiles: deck instance not found (expected deck instance or map.__deck)');
}
requestRepaint();
return;
}
const layerManager = deckInstance.layerManager;
if (!layerManager || typeof layerManager.getLayers !== 'function') {
if (this.debug) {
console.warn('[Multiband] refreshTiles: layerManager not available');
}
requestRepaint();
return;
}
const renderedLayers = layerManager.getLayers({ layerIds: [layerId] }) || [];
const tileLayerId = `${layerId}-tile-layer`;
let tileLayer = null;
let cogLayer = null;
for (let i = 0; i < renderedLayers.length; i++) {
const layer = renderedLayers[i];
if (!layer) {
continue;
}
if (layer.id === tileLayerId) {
tileLayer = layer;
break;
}
if (!cogLayer && layer.id === layerId && layer.state && layer.state.tileset) {
cogLayer = layer;
}
if (typeof layer.getSubLayers === 'function') {
const subLayers = layer.getSubLayers();
if (!Array.isArray(subLayers)) {
continue;
}
for (let j = 0; j < subLayers.length; j++) {
const subLayer = subLayers[j];
if (subLayer && subLayer.state && subLayer.state.tileset) {
tileLayer = subLayer;
break;
}
}
if (tileLayer) {
break;
}
}
}
if (!tileLayer && cogLayer) {
tileLayer = cogLayer;
}
const tileset = tileLayer && tileLayer.state && tileLayer.state.tileset;
if (!tileset || !Array.isArray(tileset.tiles)) {
if (this.debug) {
console.warn('[Multiband] refreshTiles: tileset not found or has no tiles');
}
requestRepaint();
return;
}
if (this._trackedTilesets && typeof this._trackedTilesets.add === 'function') {
this._trackedTilesets.add(tileset);
}
let tilesCleared = 0;
for (let i = 0; i < tileset.tiles.length; i++) {
const tile = tileset.tiles[i];
const candidates = [
tile && tile.content && tile.content.data,
tile && tile.content,
tile && tile.data && tile.data.data,
tile && tile.data
];
for (let j = 0; j < candidates.length; j++) {
const payload = candidates[j] && candidates[j].data ? candidates[j].data : candidates[j];
if (payload && payload._lumaTexture && typeof payload._lumaTexture.destroy === 'function' && !payload._lumaTexture.destroyed) {
payload._lumaTexture.destroy();
payload._lumaTexture = null;
payload._lumaTextureFormat = null;
}
}
if (tile.layers !== null) {
tile.layers = null;
tilesCleared++;
}
}
if (typeof tileLayer.setNeedsUpdate === 'function') {
tileLayer.setNeedsUpdate();
}
if (typeof layerManager.setNeedsUpdate === 'function') {
layerManager.setNeedsUpdate('style changed');
}
if (typeof layerManager.setNeedsRedraw === 'function') {
layerManager.setNeedsRedraw('style changed');
}
requestRepaint();
if (this.debug) {
console.log(`[Multiband] refreshTiles complete: cleared ${tilesCleared} tile sublayers, triggered redraw`);
}
}
/**
* Resolve deck.gl instance from target object
* Handles both direct deck instances and map objects with __deck property
*
* @param {Object} target - deck.gl instance or map-like object
* @returns {Object|null} deck.gl instance or null if not found
* @private
*/
function resolveDeckInstance(target) {
if (!target || typeof target !== 'object') {
return null;
}
if (target.layerManager && typeof target.redraw === 'function') {
return target;
}
if (target.__deck && typeof target.__deck === 'object') {
return target.__deck;
}
return null;
}
export {
addStyle,
removeStyle,
setActiveStyle,
getActiveStyle,
getStyles
};