Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions web/client/components/map/cesium/Map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { reprojectBbox } from '../../../utils/CoordinatesUtils';
import { throttle, isEqual, debounce } from 'lodash';
import TIFFImageryProvider from 'tiff-imagery-provider';
import { getCOGPixelData } from '../../../utils/cog/IdentifyUtils';

class CesiumMap extends React.Component {
static propTypes = {
Expand Down Expand Up @@ -304,12 +305,8 @@ class CesiumMap extends React.Component {
resolution: getResolutions()[Math.round(this.props.zoom)]
};

this.getIntersectedPixels(map, {...movement.position, ...cartographic}).then(intersectedPixels => {

pointToBuildRequest.intersectedPixels = intersectedPixels;

this.props.onClick(pointToBuildRequest);
});
pointToBuildRequest.intersectedPixelsPromise = this.getIntersectedPixels(map, cartographic);
this.props.onClick(pointToBuildRequest);
}
}
};
Expand Down Expand Up @@ -398,30 +395,35 @@ class CesiumMap extends React.Component {
};

/**
* wrapper for TIFFImageryProvider pickFeatures() is async operation and we need append results and call onClick
* https://github.com/hongfaqiu/TIFFImageryProvider/blob/v2.17.1/packages/TIFFImageryProvider/src/TIFFImageryProvider.ts#L768
* @param {zoom} map
* @param {x, y, longitude, latitude} position
* @returns Array of layers with relative intersected pixels
* Resolve COG pixel values for visible Cesium TIFF imagery layers.
* @param {object} map Cesium viewer
* @param {object} position cartographic click position
* @returns {Promise<Array>} layers with relative intersected pixels
*/
getIntersectedPixels = (map, position) => {

const tiffLayers = map.imageryLayers._layers.filter(layer =>
layer.rendered &&
layer.show &&
layer.imageryProvider instanceof TIFFImageryProvider
);

return Promise.all(tiffLayers.map(layer => {
return layer.imageryProvider.pickFeatures(position.x, position.y, map.zoom, position.longitude, position.latitude)
.then(pickedLayers => {
const {data} = pickedLayers[0] || {};
return Promise.all(tiffLayers.map(layer =>
getCOGPixelData({
provider: layer.imageryProvider,
position,
zoom: this.props.zoom
})
.then(data => {
if (!data) {
return null;
}
return {
id: layer._imageryProvider.layerId,
// remap bands index start from 1 instead of 0 to be consistent with 2D pick and avoid confusion with users
bands: Object.fromEntries(Object.entries(data).map(([key, value]) => [Number(key) + 1, value]))
};
});
}));
})
)).then(intersectedPixels => intersectedPixels.filter(Boolean));
}

getIntersectedFeatures = (map, position) => {
Expand Down
4 changes: 2 additions & 2 deletions web/client/components/map/openlayers/Map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ class OpenlayersMap extends React.Component {
const finalLat = markerCoords ? markerCoords.y : lat;
const finalLng = markerCoords ? normalizeLng(markerCoords.x) : lng;
const intersectedFeatures = this.getIntersectedFeatures(map, event?.pixel);
const intersectedPixels = this.getIntersectedPixels(map, event?.pixel);
const intersectedPixelsPromise = Promise.resolve(this.getIntersectedPixels(map, event?.pixel));

this.props.onClick({
pixel: {
Expand All @@ -223,7 +223,7 @@ class OpenlayersMap extends React.Component {
shift: event.originalEvent.shiftKey
},
intersectedFeatures,
intersectedPixels
intersectedPixelsPromise
}, layerInfo);
}
});
Expand Down
60 changes: 60 additions & 0 deletions web/client/utils/cog/IdentifyUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Copyright 2026, GeoSolutions Sas.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/

import * as Cesium from 'cesium';

/**
* Convert a cartographic position to a pixel index inside loaded COG tile data.
* The click is converted to a ratio inside the Cesium tile rectangle.
*
*/
export const getPixelFromTileData = ({ data, width, height, rectangle, position }) => {
if (!data || !width || !height) {
return null;
}
// Locate the click as a ratio inside the Cesium tile rectangle.
const xRatio = (position.longitude - rectangle.west) / (rectangle.east - rectangle.west);
const yRatio = (rectangle.north - position.latitude) / (rectangle.north - rectangle.south);
// Pixel column in the loaded tile data.
const x = Math.max(0, Math.min(width - 1, Math.floor(xRatio * width)));
// Pixel row in the loaded tile data.
const y = Math.max(0, Math.min(height - 1, Math.floor(yRatio * height)));
// Flat array index used by each band array.
const index = y * width + x;
return Object.fromEntries(data.map((value, idx) => [idx, value?.[index]]));
};

/**
* Load the COG tile covering the click position and extract its pixel values.
* Cesium resolves the tile from the click coordinate; the provider loads the
* raster arrays for that tile and getPixelFromTileData reads the band values.
*/
export const getCOGPixelData = ({ provider, position, zoom }) => {
if (!provider._loadTile || !provider.tilingScheme?.positionToTileXY) {
return Promise.resolve(null);
}
// Use the closest available provider level for the current map zoom.
const maximumLevel = Math.max(0, (provider.requestLevels?.length || 1) - 1);
const tileZoom = Math.max(provider.minimumLevel || 0, Math.min(maximumLevel, Math.round(zoom)));
const cartographic = new Cesium.Cartographic(position.longitude, position.latitude);
// Find the Cesium imagery tile that contains the clicked coordinate.
const tile = provider.tilingScheme.positionToTileXY(cartographic, tileZoom);
if (!tile) {
return Promise.resolve(null);
}
const rectangle = provider.tilingScheme.tileXYToRectangle(tile.x, tile.y, tileZoom);
// Load the tile raster arrays and read the clicked pixel from each band.
return provider._loadTile(tile.x, tile.y, tileZoom)
.then(({ data, width, height }) =>
getPixelFromTileData({ data, width, height, rectangle, position }));
};

export default {
getCOGPixelData,
getPixelFromTileData
};
61 changes: 61 additions & 0 deletions web/client/utils/cog/__tests__/IdentifyUtils-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2026, GeoSolutions Sas.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/

import expect from 'expect';
import { getCOGPixelData } from '../IdentifyUtils';

describe('COG - IdentifyUtils', () => {
it('should get pixel values from a COG provider tile', (done) => {
const data = [
[10, 11, 12, 13, 14, 15],
[20, 21, 22, 23, 24, 25],
[30, 31, 32, 33, 34, 35]
];
const provider = {
requestLevels: [0, 1, 2],
minimumLevel: 0,
tilingScheme: {
positionToTileXY: () => ({ x: 1, y: 2 }),
tileXYToRectangle: () => ({
west: 0,
east: 10,
north: 10,
south: 0
})
},
_loadTile: (x, y, zoom) => {
expect(x).toBe(1);
expect(y).toBe(2);
expect(zoom).toBe(2);
return Promise.resolve({
data,
width: 3,
height: 2
});
}
};

getCOGPixelData({
provider,
position: {
longitude: 5,
latitude: 5
},
zoom: 2
})
.then(result => {
expect(result).toEqual({
0: 14,
1: 24,
2: 34
});
done();
})
.catch(done);
});
});
83 changes: 61 additions & 22 deletions web/client/utils/mapinfo/__tests__/cog-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ describe("mapinfo COG utils", () => {
const currentLocale = "en-US";
const pixValueRaw = new Uint8Array([140, 80, 80, 255]);
const pixValueBands = pixValueRaw.reduce((acc, value, index) => ({ ...acc, [index + 1]: value }), {});
const intersectedPixelsPromise = Promise.resolve({
"0": {
"id": layerId,
"bands": pixValueBands
}
});
const latlng = {
"lat": 40.19133465092119, "lng": -92.60925292968749
};
Expand All @@ -49,12 +55,7 @@ describe("mapinfo COG utils", () => {
"metaKey": false,
"shift": false
},
"intersectedPixels": {
"0": {
"id": layerId,
"bands": pixValueBands
}
},
intersectedPixelsPromise,
"intersectedFeatures": [
{
"id": layerId,
Expand Down Expand Up @@ -88,22 +89,12 @@ describe("mapinfo COG utils", () => {
const request = cog.buildRequest(layer, { point, currentLocale });
const expectedRequest = {
"request": {
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [latlng.lng, latlng.lat]
},
"properties": {
"band 1": pixValueRaw[0],
"band 2": pixValueRaw[1],
"band 3": pixValueRaw[2],
"band 4": pixValueRaw[3]
}
}
],
"outputFormat": "application/json"
"features": [],
"outputFormat": "application/json",
intersectedPixelsPromise,
point: {
latlng
}
},
"metadata": {
"title": "Cloud layer title"
Expand All @@ -114,6 +105,54 @@ describe("mapinfo COG utils", () => {
expect(request).toEqual(expectedRequest);
});

it("should create features from intersected pixels promise", (done) => {
const layerId = "6ba42670-f3c-21f0-8e1f-dd66f6ae634d";
const layer = { id: layerId };
const latlng = {
"lat": 40.19133465092119, "lng": -92.60925292968749
};
const intersectedPixelsPromise = Promise.resolve({
"0": {
"id": layerId,
"bands": {
"1": 140,
"2": 80,
"3": 80,
"4": 255
}
}
});

cog.getIdentifyFlow(layer, undefined, {
intersectedPixelsPromise,
point: {
latlng
}
})
.toPromise()
.then((response) => {
expect(response).toEqual({
data: {
features: [{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [latlng.lng, latlng.lat]
},
"properties": {
"band 1": 140,
"band 2": 80,
"band 3": 80,
"band 4": 255
}
}]
}
});
done();
})
.catch(done);
});

it("should use sources[0].url as fallback when layer.url is not set", () => {
const layerId = "6ba42670-f3c-21f0-8e1f-dd66f6ae634d";
const sourceUrl = "https://mydomain.com/cog.tif";
Expand Down
59 changes: 38 additions & 21 deletions web/client/utils/mapinfo/cog.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,14 @@ import isObject from 'lodash/isObject';
export default {
buildRequest: (layer, { point, currentLocale } = {}) => { // executed for each COG layer in TOC

const pickValues = Object.values(point?.intersectedPixels);
const arrayValues = pickValues ? Array.from(pickValues) : [];
const filteredValues = arrayValues.filter(({ id }) => id === layer.id);

const features = filteredValues.map((value) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.latlng.lng, point.latlng.lat]
},
properties: value?.bands ?
Object.entries(value.bands).reduce((acc, [key, val]) => {
acc[`band ${key}`] = val;
return acc;
}, {})
: {}
}));

return {
request: {
features: [...features],
outputFormat: 'application/json'
features: [],
outputFormat: 'application/json',
intersectedPixelsPromise: point?.intersectedPixelsPromise,
point: {
latlng: point?.latlng
}
},
metadata: {
title: isObject(layer.title)
Expand All @@ -43,7 +29,38 @@ export default {
url: layer.url || layer?.sources?.[0]?.url
};
},
getIdentifyFlow: (layer, basePath, {features = []} = {}) => {
getIdentifyFlow: (layer, _, {features = [], intersectedPixelsPromise, point} = {}) => {

if (intersectedPixelsPromise && point) {
return Observable.fromPromise(intersectedPixelsPromise)
.map((intersectedPixels = []) => {
const pickValues = Object.values(intersectedPixels);
const arrayValues = pickValues ? Array.from(pickValues) : [];
const filteredValues = arrayValues.filter(({ id }) => id === layer.id);
return {
data: {
features: filteredValues.map((value) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.latlng.lng, point.latlng.lat]
},
properties: value?.bands ?
Object.entries(value.bands).reduce((acc, [key, val]) => {
acc[`band ${key}`] = val;
return acc;
}, {})
: {}
}))
}
};
})
.catch(() => Observable.of({
data: {
features: []
}
}));
}

return Observable.of({
data: {
Expand Down
Loading