Source: getPixelValues.js

import proj4 from 'proj4';

/**
 * Resolve deck.gl instance from target object
 * @private
 * @param {Object} target - deck.gl instance or map-like object with __deck
 * @returns {Object|null} deck.gl instance or null
 */
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;
}

/**
 * Get tile layer from target deck instance
 * @private
 * @param {Object} target - deck.gl instance or map object
 * @param {string} [layerId='cog-layer'] - Layer ID to search for
 * @returns {Object|null} Tile layer or null
 */
function getTileLayerFromTarget(target, layerId = 'cog-layer') {
  const deckInstance = resolveDeckInstance(target);
  if (!deckInstance || !deckInstance.layerManager || typeof deckInstance.layerManager.getLayers !== 'function') {
    return null;
  }

  const renderedLayers = deckInstance.layerManager.getLayers({ layerIds: [layerId] }) || [];
  const tileLayerId = `${layerId}-tile-layer`;

  let cogLayer = null;
  for (let i = 0; i < renderedLayers.length; i++) {
    const layer = renderedLayers[i];
    if (!layer) {
      continue;
    }

    if (layer.id === tileLayerId) {
      return layer;
    }

    if (!cogLayer && layer.id === layerId && layer.state && layer.state.tileset) {
      cogLayer = layer;
    }

    if (!layer || typeof layer.getSubLayers !== 'function') {
      continue;
    }
    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) {
        return subLayer;
      }
    }
  }

  return cogLayer;
}

/**
 * Normalize lngLat input to [lng, lat] array
 * Accepts array [lng, lat] or object {lng, lat}
 * @private
 * @param {Array|Object} lngLat - LngLat in array or object form
 * @returns {Array<number>|null} [lng, lat] or null if invalid
 */
function normalizeLngLat(lngLat) {
  if (Array.isArray(lngLat) && lngLat.length >= 2) {
    const lng = Number(lngLat[0]);
    const lat = Number(lngLat[1]);
    if (Number.isFinite(lng) && Number.isFinite(lat)) {
      return [lng, lat];
    }
    return null;
  }

  if (lngLat && typeof lngLat === 'object') {
    const lng = Number(lngLat.lng);
    const lat = Number(lngLat.lat);
    if (Number.isFinite(lng) && Number.isFinite(lat)) {
      return [lng, lat];
    }
  }

  return null;
}

/**
 * Normalize bounds from various input formats to [minLng, minLat, maxLng, maxLat]
 * @private
 * @param {Array|Object} bounds - Bounds as array or object with _ne/_sw properties
 * @returns {Array<number>|null} [minLng, minLat, maxLng, maxLat] or null if invalid
 */
function normalizeBounds(bounds) {
  if (!bounds) {
    return null;
  }

  if (
    typeof bounds === 'object' &&
    !Array.isArray(bounds) &&
    bounds.topLeft &&
    bounds.topRight &&
    bounds.bottomLeft &&
    bounds.bottomRight
  ) {
    const corners = [bounds.topLeft, bounds.topRight, bounds.bottomLeft, bounds.bottomRight];
    let west = Infinity;
    let east = -Infinity;
    let south = Infinity;
    let north = -Infinity;

    for (let i = 0; i < 4; i++) {
      const corner = corners[i];
      if (!Array.isArray(corner) || corner.length < 2) {
        return null;
      }
      const x = Number(corner[0]);
      const y = Number(corner[1]);
      if (!Number.isFinite(x) || !Number.isFinite(y)) {
        return null;
      }
      if (x < west) west = x;
      if (x > east) east = x;
      if (y < south) south = y;
      if (y > north) north = y;
    }

    if (west !== Infinity && south !== Infinity && east !== -Infinity && north !== -Infinity) {
      return [west, south, east, north];
    }
    return null;
  }

  if (
    Array.isArray(bounds) &&
    bounds.length === 2 &&
    Array.isArray(bounds[0]) &&
    Array.isArray(bounds[1])
  ) {
    const west = Number(bounds[0][0]);
    const south = Number(bounds[0][1]);
    const east = Number(bounds[1][0]);
    const north = Number(bounds[1][1]);
    if (Number.isFinite(west) && Number.isFinite(south) && Number.isFinite(east) && Number.isFinite(north)) {
      return [west, south, east, north];
    }
    return null;
  }

  if (Array.isArray(bounds) && bounds.length >= 4) {
    const west = Number(bounds[0]);
    const south = Number(bounds[1]);
    const east = Number(bounds[2]);
    const north = Number(bounds[3]);
    if (Number.isFinite(west) && Number.isFinite(south) && Number.isFinite(east) && Number.isFinite(north)) {
      return [west, south, east, north];
    }
    return null;
  }

  if (typeof bounds === 'object') {
    const west = Number(bounds.west ?? bounds.left ?? bounds.minX ?? bounds[0]);
    const south = Number(bounds.south ?? bounds.bottom ?? bounds.minY ?? bounds[1]);
    const east = Number(bounds.east ?? bounds.right ?? bounds.maxX ?? bounds[2]);
    const north = Number(bounds.north ?? bounds.top ?? bounds.maxY ?? bounds[3]);
    if (Number.isFinite(west) && Number.isFinite(south) && Number.isFinite(east) && Number.isFinite(north)) {
      return [west, south, east, north];
    }
  }

  return null;
}

/**
 * Extract tile payload data from tile object
 * @private
 * @param {Object} tile - Tile object from COGLayer
 * @returns {Object|null} Tile data payload or null
 */
function extractTilePayload(tile) {
  if (!tile || typeof tile !== 'object') {
    return null;
  }

  const candidates = [
    tile.content && tile.content.data,
    tile.content,
    tile.data && tile.data.data,
    tile.data
  ];

  for (const candidate of candidates) {
    const payload = candidate && candidate.data ? candidate.data : candidate;
    if (
      payload &&
      payload.data &&
      Number.isFinite(Number(payload.width)) &&
      Number.isFinite(Number(payload.height)) &&
      Number.isFinite(Number(payload.bandCount))
    ) {
      return payload;
    }
  }

  return null;
}

/**
 * Extract tile bounds as [west, south, east, north]
 * @private
 * @param {Object} tile - Tile object
 * @returns {Array<number>|null} [west, south, east, north] or null
 */
function extractTileBounds(tile) {
  if (!tile || typeof tile !== 'object') {
    return null;
  }

  const candidates = [
    tile.projectedBounds,
    tile.bounds,
    tile.bbox,
    tile.boundingBox,
    tile.tileBounds,
    tile.content && tile.content.bounds,
    tile.content && tile.content.bbox,
    tile.content && tile.content.tileBounds
  ];

  for (const candidate of candidates) {
    const normalized = normalizeBounds(candidate);
    if (normalized) {
      return normalized;
    }
  }

  return null;
}

/**
 * Extract projection code from tile payload
 * @private
 * @param {Object} tile - Tile object
 * @param {Object} payload - Tile data payload
 * @returns {number|null} EPSG code or null
 */
function extractProjectionCodeFromTile(tile, payload) {
  const candidates = [
    tile && tile.content && tile.content.geoKeys,
    tile && tile.content && tile.content.data && tile.content.data.geoKeys,
    tile && tile.data && tile.data.geoKeys,
    tile && tile.data && tile.data.data && tile.data.data.geoKeys,
    payload && payload.geoKeys
  ];

  for (const keys of candidates) {
    if (!keys || typeof keys !== 'object') {
      continue;
    }
    const code = Number(keys.ProjectedCSTypeGeoKey ?? keys.GeographicTypeGeoKey);
    if (Number.isFinite(code) && code > 0) {
      return code;
    }
  }

  return null;
}

/**
 * Transform lngLat to CRS coordinates using proj4
 * @private
 * @param {number} lng - Longitude
 * @param {number} lat - Latitude
 * @param {string} crs - Target CRS code
 * @returns {Array<number>|null} Transformed [x, y] or null if transform fails
 */
function transformLngLatToCrs(lng, lat, crs) {
  try {
    const [x, y] = proj4('EPSG:4326', crs, [lng, lat]);
    if (Number.isFinite(x) && Number.isFinite(y)) {
      return [x, y];
    }
  } catch {
    // Ignore unsupported CRS definitions and continue with next candidate.
  }
  return null;
}

/**
 * Build list of projection candidates for coordinate transformation
 * @private
 * @param {number} lng - Longitude
 * @param {number} lat - Latitude
 * @param {number} projectionCode - EPSG code
 * @returns {Array<string>} Array of CRS codes to try
 */
function buildProjectionCandidates(lng, lat, projectionCode) {
  const candidates = [];
  const add = (crs) => {
    if (!crs || candidates.includes(crs)) {
      return;
    }
    candidates.push(crs);
  };

  if (projectionCode) {
    add(`EPSG:${projectionCode}`);
  }

  add('EPSG:3857');

  const zone = Math.max(1, Math.min(60, Math.floor((lng + 180) / 6) + 1));
  const isNorth = lat >= 0;
  add(`EPSG:${isNorth ? 32600 + zone : 32700 + zone}`);
  if (zone > 1) {
    add(`EPSG:${isNorth ? 32600 + (zone - 1) : 32700 + (zone - 1)}`);
  }
  if (zone < 60) {
    add(`EPSG:${isNorth ? 32600 + (zone + 1) : 32700 + (zone + 1)}`);
  }

  return candidates;
}

/**
 * Build input object for style evaluation from sampled pixel context
 * @private
 * @param {Object} context - Sampling context with tile, coordinates, and pixel info
 * @param {Array<number>} bands - Band values at sampled pixel
 * @returns {Object} Input object for style function with x, y, lng, lat, transformedLng, transformedLat, bands properties
 */
function getStyleEvalInput(context, bands) {
  if (!context || !Array.isArray(bands)) {
    const styleInput = new Array(bands.length);
    for (let i = 0; i < bands.length; i++) {
      styleInput[i] = [[bands[i]]];
    }
    return styleInput;
  }

  let scratch = context._styleEvalScratch;
  if (!Array.isArray(scratch) || scratch.length !== bands.length) {
    scratch = new Array(bands.length);
    for (let i = 0; i < bands.length; i++) {
      scratch[i] = [[0]];
    }
    context._styleEvalScratch = scratch;
  }

  for (let i = 0; i < bands.length; i++) {
    scratch[i][0][0] = bands[i];
  }

  return scratch;
}

/**
 * Evaluate style function at given band values for pixel sampling
 * @private
 * @param {Object} style - Style object with fn property
 * @param {Array<number>} bands - Band values at sampled pixel
 * @param {boolean} [debug=false] - Enable debug logging
 * @param {Object} [context=null] - Multiband context for evaluation
 * @returns {*} Style function output or null if evaluation fails
 */
function evaluateStyleAtBands(style, bands, debug = false, context = null) {
  if (!style || typeof style.fn !== 'function' || !Array.isArray(bands)) {
    return null;
  }

  const styleInput = getStyleEvalInput(context, bands);
  try {
    return style.fn.call({ thread: { x: 0, y: 0 } }, styleInput);
  } catch (error) {
    if (debug) {
      console.warn(`[Multiband] getPixelValues: style evaluation failed for "${style.name}"`, error);
    }
    return null;
  }
}

/**
 * Check if bounds exceed geographic coordinate range [-180, 180]
 * @private
 * @param {Array<number>} bounds - [minLng, minLat, maxLng, maxLat]
 * @returns {boolean} True if bounds are projected (not geographic)
 */
function isBoundsProjected(bounds) {
  if (!Array.isArray(bounds) || bounds.length < 2) {
    return false;
  }
  const firstValue = Math.abs(bounds[0]);
  const secondValue = Math.abs(bounds[1]);
  return firstValue > 360 || secondValue > 360;
}

/**
 * Sample pixel values from loaded tiles at clicked lngLat position
 * Evaluates the active style at the sampled pixel location
 * 
 * @param {Array<number>|Object} lngLat - Click position as [lng, lat] or {lng, lat}
 * @param {Object} target - deck.gl instance or map-like object exposing __deck
 * @param {string} [layerId='cog-layer'] - COGLayer id to sample from
 * @returns {Object|null} Sampled pixel data or null if sampling fails
 * @property {Object} latlng - Clicked position {lat, lng}
 * @property {string} selectedstyle - Active style name
 * @property {*} value - Style function output at sampled pixel (Type 1: RGBA array, Type 2: scalar)
 * @property {Array<number>} bands - Raw band values at sampled pixel
 * @public
 */
function getPixelValues(lngLat, target, layerId = 'cog-layer') {
  const normalized = normalizeLngLat(lngLat);
  if (!normalized) {
    console.error('[Multiband] getPixelValues: invalid lngLat input (expected [lng,lat] or {lng,lat})');
    return null;
  }

  const tileLayer = getTileLayerFromTarget(target, layerId);
  if (!tileLayer || !tileLayer.state || !tileLayer.state.tileset || !Array.isArray(tileLayer.state.tileset.tiles)) {
    if (this.debug) {
      console.warn('[Multiband] getPixelValues: tileset not found for target/layer');
    }
    return null;
  }

  const [lng, lat] = normalized;
  const tileset = tileLayer.state.tileset;

  const tileEntries = [];
  let boundsMinX = Infinity;
  let boundsMaxX = -Infinity;
  let boundsMinY = Infinity;
  let boundsMaxY = -Infinity;
  let projectionCode = null;

  const seenTiles = new Set();
  const processTile = (tile) => {
    if (!tile || seenTiles.has(tile)) {
      return;
    }
    seenTiles.add(tile);

    const bounds = extractTileBounds(tile);
    if (!bounds) {
      return;
    }

    const payload = extractTilePayload(tile);
    if (!projectionCode) {
      projectionCode = extractProjectionCodeFromTile(tile, payload);
    }

    const [west, south, east, north] = bounds;
    boundsMinX = Math.min(boundsMinX, west);
    boundsMaxX = Math.max(boundsMaxX, east);
    boundsMinY = Math.min(boundsMinY, south);
    boundsMaxY = Math.max(boundsMaxY, north);

    tileEntries.push({ bounds, payload });
  };

  if (Array.isArray(tileset.selectedTiles)) {
    for (let i = 0; i < tileset.selectedTiles.length; i++) {
      processTile(tileset.selectedTiles[i]);
    }
  }
  if (Array.isArray(tileset.tiles)) {
    for (let i = 0; i < tileset.tiles.length; i++) {
      processTile(tileset.tiles[i]);
    }
  }

  if (tileEntries.length === 0) {
    if (this.debug) {
      console.warn('[Multiband] getPixelValues: no tiles with usable bounds in tileset');
    }
    return null;
  }

  const projectedBounds = isBoundsProjected([boundsMinX, boundsMinY, boundsMaxX, boundsMaxY]);
  let clickX = lng;
  let clickY = lat;
  let resolvedCrs = 'EPSG:4326';

  if (projectedBounds) {
    const candidates = buildProjectionCandidates(lng, lat, projectionCode);
    let projectedClick = null;

    for (const candidate of candidates) {
      const transformed = transformLngLatToCrs(lng, lat, candidate);
      if (!transformed) {
        continue;
      }

      const [x, y] = transformed;
      if (x >= boundsMinX && x <= boundsMaxX && y >= boundsMinY && y <= boundsMaxY) {
        projectedClick = transformed;
        resolvedCrs = candidate;
        break;
      }
    }

    if (projectedClick) {
      [clickX, clickY] = projectedClick;
    } else if (this.debug) {
      console.warn('[Multiband] getPixelValues: could not resolve click CRS against tile bounds envelope');
    }
  }

  let best = null;
  let bestArea = Infinity;

  for (const entry of tileEntries) {
    const { bounds, payload } = entry;
    const [west, south, east, north] = bounds;

    if (clickX < west || clickX > east || clickY < south || clickY > north) {
      continue;
    }

    if (!payload || !payload.data || !payload.width || !payload.height || !payload.bandCount) {
      continue;
    }

    const width = Number(payload.width);
    const height = Number(payload.height);
    const bandCount = Number(payload.bandCount);
    const values = payload.data;
    if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(bandCount) || width <= 0 || height <= 0 || bandCount <= 0) {
      continue;
    }

    const dx = east - west;
    const dy = north - south;
    if (dx <= 0 || dy <= 0) {
      continue;
    }

    const area = dx * dy;
    if (area >= bestArea) {
      continue;
    }

    const u = (clickX - west) / dx;
    const v = (north - clickY) / dy;
    const px = Math.max(0, Math.min(width - 1, Math.floor(u * width)));
    const py = Math.max(0, Math.min(height - 1, Math.floor(v * height)));

    best = {
      width,
      height,
      bandCount,
      values,
      px,
      py
    };
    bestArea = area;
  }

  if (!best) {
    if (this.debug) {
      console.warn(
        `[Multiband] getPixelValues: no loaded tile found at clicked location (crs=${resolvedCrs}, click=[${clickX.toFixed(2)}, ${clickY.toFixed(2)}])`
      );
    }
    return null;
  }

  const { width, height, bandCount, values, px, py } = best;
  const planeSize = width * height;
  const pixelIndex = py * width + px;
  const bands = new Array(bandCount);

  for (let b = 0; b < bandCount; b++) {
    bands[b] = values[b * planeSize + pixelIndex];
  }

  const selectedStyle = (
    this._activeStyle && this._activeStyle.name === this._activeStyleName
      ? this._activeStyle
      : (this._styles.find((s) => s && s.name === this._activeStyleName) || null)
  );
  const isType2 = Boolean(selectedStyle && Array.isArray(selectedStyle.colors) && Array.isArray(selectedStyle.stops));
  const styleOutput = evaluateStyleAtBands(selectedStyle, bands, this.debug, this);

  let value = null;
  if (isType2) {
    value = Array.isArray(styleOutput) ? styleOutput[0] : styleOutput;
  } else {
    value = styleOutput;
  }

  return {
    latlng: { lat, lng },
    selectedstyle: selectedStyle ? selectedStyle.name : this._activeStyleName,
    value,
    bands
  };
}

export { getPixelValues };