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