Source: stylesManager.js

/**
 * 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
};