diff --git a/web/client/components/map/cesium/Layer.jsx b/web/client/components/map/cesium/Layer.jsx index a951f4c1855..b557f4f70b1 100644 --- a/web/client/components/map/cesium/Layer.jsx +++ b/web/client/components/map/cesium/Layer.jsx @@ -241,6 +241,7 @@ class CesiumLayer extends React.Component { if (isPromise) { this.layer.then((resolvedLayer) => { this.layer = resolvedLayer; + this.layer.layerId = options.id; this.provider = map.imageryLayers.addImageryProvider(resolvedLayer); if (this.layer) { this.layer.layerName = options.name; diff --git a/web/client/components/map/cesium/Map.jsx b/web/client/components/map/cesium/Map.jsx index 5417ca827cc..8343463f73f 100644 --- a/web/client/components/map/cesium/Map.jsx +++ b/web/client/components/map/cesium/Map.jsx @@ -25,6 +25,7 @@ import { } from '../../../utils/MapUtils'; import { reprojectBbox } from '../../../utils/CoordinatesUtils'; import { throttle, isEqual, debounce } from 'lodash'; +import TIFFImageryProvider from 'tiff-imagery-provider'; class CesiumMap extends React.Component { static propTypes = { @@ -265,7 +266,9 @@ class CesiumMap extends React.Component { onClick = (map, movement) => { if (this.props.onClick && movement.position !== null) { const cartesian = map.camera.pickEllipsoid(movement.position, map.scene.globe.ellipsoid); + const intersectedFeatures = this.getIntersectedFeatures(map, movement.position); + let cartographic = ClickUtils.getMouseXYZ(map, movement) || cartesian && Cesium.Cartographic.fromCartesian(cartesian); if (cartographic) { const latitude = cartographic.latitude * 180.0 / Math.PI; @@ -284,23 +287,25 @@ class CesiumMap extends React.Component { const y = (90.0 - latitude) / 180.0 * this.props.standardHeight * (this.props.zoom + 1); const x = (180.0 + longitude) / 360.0 * this.props.standardWidth * (this.props.zoom + 1); - this.props.onClick({ - pixel: { - x: x, - y: y - }, + const latlng = { lat: latitude, lng: longitude, z: elevation }; + const pixel = { x, y }; + const pointToBuildRequest = { + pixel, height: (this.props.mapOptions && this.props.mapOptions.terrainProvider) || intersectedFeatures.length > 0 ? cartographic.height : undefined, cartographic, - latlng: { - lat: latitude, - lng: longitude, - z: elevation - }, + latlng, crs: "EPSG:4326", intersectedFeatures, resolution: getResolutions()[Math.round(this.props.zoom)] + }; + + this.getIntersectedPixels(map, {...movement.position, ...cartographic}).then(intersectedPixels => { + + pointToBuildRequest.intersectedPixels = intersectedPixels; + + this.props.onClick(pointToBuildRequest); }); } } @@ -389,6 +394,33 @@ class CesiumMap extends React.Component { return this.props.zoomToHeight / Math.pow(2, zoom - 1); }; + /** + * 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 + */ + getIntersectedPixels = (map, position) => { + + const tiffLayers = map.imageryLayers._layers.filter(layer => + layer.rendered && + 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 { + 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])) + }; + }); + })); + } + getIntersectedFeatures = (map, position) => { // for consistency with 2D view we allow to drill pick through the first feature // and intersect all the features behind diff --git a/web/client/components/map/cesium/plugins/COGLayer.js b/web/client/components/map/cesium/plugins/COGLayer.js index 1771d79e467..d73f496e27c 100644 --- a/web/client/components/map/cesium/plugins/COGLayer.js +++ b/web/client/components/map/cesium/plugins/COGLayer.js @@ -16,7 +16,7 @@ import proj4 from 'proj4'; */ import TIFFImageryProvider from 'tiff-imagery-provider'; import { COG_LAYER_TYPE } from '../../../../utils/CatalogUtils'; -import {isProjectionAvailable} from '../../../../utils/ProjectionUtils'; +import { isProjectionAvailable } from '../../../../utils/ProjectionUtils'; /* colorScale set of values used by TIFFImageryProvider see https://observablehq.com/@d3/color-schemes @@ -56,7 +56,8 @@ const createLayer = (options) => { } return null; }, - renderOptions: buildRenderOptions(options) + renderOptions: buildRenderOptions(options), + enablePickFeatures: true // required for identify pickFeatures method }); }; diff --git a/web/client/components/map/openlayers/Map.jsx b/web/client/components/map/openlayers/Map.jsx index 3e5550da1e9..17bd60df3ae 100644 --- a/web/client/components/map/openlayers/Map.jsx +++ b/web/client/components/map/openlayers/Map.jsx @@ -28,6 +28,8 @@ import { DEFAULT_INTERACTION_OPTIONS } from '../../../utils/openlayers/DrawUtils import {isEqual, find, throttle, isArray, isNil} from 'lodash'; +import GeoTIFF from 'ol/source/GeoTIFF.js'; + import 'ol/ol.css'; // add overrides for css @@ -221,6 +223,9 @@ class OpenlayersMap extends React.Component { }); const intersectedFeatures = this.getIntersectedFeatures(map, event?.pixel); const tLng = normalizeLng(coords.x); + + const intersectedPixels = this.getIntersectedPixels(map, event?.pixel); + this.props.onClick({ pixel: { x: event.pixel[0], @@ -238,7 +243,8 @@ class OpenlayersMap extends React.Component { metaKey: event.originalEvent.metaKey, // MAC OS shift: event.originalEvent.shiftKey }, - intersectedFeatures + intersectedFeatures, + intersectedPixels }, layerInfo); } } @@ -379,6 +385,35 @@ class OpenlayersMap extends React.Component { return view.getProjection().getExtent() || msGetProjection(props.projection).extent; }; + /** + * + * @param {zoom} map + * @param {x, y, longitude, latitude} position + * @returns Array of layers with relative intersected pixels + */ + getIntersectedPixels = (map, position) => { + + const allLayers = map.getLayers().getArray(); + + const tiffLayers = allLayers.filter(layer => + layer.rendered && + layer.getSource() instanceof GeoTIFF + ); + + const result = tiffLayers.map(layer => { + const rawdata = layer.getData(position); + if (!rawdata) return null; + const data = Array.from(rawdata); + // const source = layer.getSource(); + return { + id: layer.get('msId'), + // remap bands index start from 1 instead of 0 to be consistent with 2D pick and avoid confusion with users + bands: data.reduce((acc, value, index) => ({ ...acc, [index + 1]: value }), {}) + }; + }).filter(val => val !== null); + return result; + } + getIntersectedFeatures = (map, pixel) => { let groupIntersectedFeatures = {}; map.forEachFeatureAtPixel(pixel, (feature, layer) => { diff --git a/web/client/components/map/openlayers/plugins/COGLayer.js b/web/client/components/map/openlayers/plugins/COGLayer.js index 2b12f4e21ed..7a2d6640bb1 100644 --- a/web/client/components/map/openlayers/plugins/COGLayer.js +++ b/web/client/components/map/openlayers/plugins/COGLayer.js @@ -24,7 +24,7 @@ function create(options) { sourceOptions.headers = requestConfig.headers; } } - return new TileLayer({ + const layerOl = new TileLayer({ msId: options.id, style: get(options, 'style.body'), opacity: options.opacity !== undefined ? options.opacity : 1, @@ -35,10 +35,13 @@ function create(options) { wrapX: true, sourceOptions }), + enablePickFeatures: true, zIndex: options.zIndex, minResolution: options.minResolution, maxResolution: options.maxResolution }); + + return layerOl; } Layers.registerType('cog', { diff --git a/web/client/utils/MapInfoUtils.js b/web/client/utils/MapInfoUtils.js index 2837beb88fa..be5db4b4208 100644 --- a/web/client/utils/MapInfoUtils.js +++ b/web/client/utils/MapInfoUtils.js @@ -22,6 +22,7 @@ import vector from './mapinfo/vector'; import threeDTiles from './mapinfo/threeDTiles'; import model from './mapinfo/model'; import arcgis from './mapinfo/arcgis'; +import cog from './mapinfo/cog'; import flatgeobuf from './mapinfo/flatgeobuf'; // TODO import only index in ./mapinfo @@ -377,7 +378,8 @@ export const services = { '3dtiles': threeDTiles, 'model': model, 'arcgis': arcgis, - 'flatgeobuf': flatgeobuf + 'flatgeobuf': flatgeobuf, + 'cog': cog }; /** * To get the custom viewer with the given type diff --git a/web/client/utils/cog/LayerUtils.js b/web/client/utils/cog/LayerUtils.js index e01eb65b068..026dcf55cf6 100644 --- a/web/client/utils/cog/LayerUtils.js +++ b/web/client/utils/cog/LayerUtils.js @@ -119,3 +119,4 @@ LayerUtils = { }; export default LayerUtils; + diff --git a/web/client/utils/mapinfo/__tests__/cog-test.js b/web/client/utils/mapinfo/__tests__/cog-test.js new file mode 100644 index 00000000000..cac7bc9f019 --- /dev/null +++ b/web/client/utils/mapinfo/__tests__/cog-test.js @@ -0,0 +1,116 @@ +/* + * 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 cog from "../cog"; + +describe("mapinfo COG utils", () => { + it("should create a getFeatureInfo request", () => { + const layerId = "6ba42670-f3c-21f0-8e1f-dd66f6ae634d"; + const layer = { + "id": layerId, + "format": "cog", + "title": { + "en-US": "Cloud layer title" + }, + "type": "cog", + "url": "https://mydomain.com/cog.tif", + "visibility": true, + "singleTile": false, + "dimensions": [], + "hideLoading": false, + "handleClickOnLayer": false, + "useForElevation": false, + "hidden": false, + "expanded": false + }; + const currentLocale = "en-US"; + const pixValueRaw = new Uint8Array([140, 80, 80, 255]); + const pixValueBands = pixValueRaw.reduce((acc, value, index) => ({ ...acc, [index + 1]: value }), {}); + const latlng = { + "lat": 40.19133465092119, "lng": -92.60925292968749 + }; + const point = { + "pixel": { + "x": 460, "y": 110 + }, + "latlng": latlng, + "rawPos": [ + -92.60925292968749, 40.19133465092119 + ], + "modifiers": { + "alt": false, + "ctrl": false, + "metaKey": false, + "shift": false + }, + "intersectedPixels": { + "0": { + "id": layerId, + "bands": pixValueBands + } + }, + "intersectedFeatures": [ + { + "id": layerId, + "features": [ + { + "id": 165, + "properties": { + "id": "USA", + "name": "United States of America" + }, + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-159.34512, 21.982], + [-159.46372, 21.88299], + [-159.80051, 22.06533], + [-159.34512, 21.982] + ] + ] + ] + } + } + ] + } + ] + }; + + 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" + }, + "metadata": { + "title": "Cloud layer title" + }, + "url": "https://mydomain.com/cog.tif" + }; + + expect(request).toEqual(expectedRequest); + }); +}); diff --git a/web/client/utils/mapinfo/cog.js b/web/client/utils/mapinfo/cog.js new file mode 100644 index 00000000000..0499e796861 --- /dev/null +++ b/web/client/utils/mapinfo/cog.js @@ -0,0 +1,54 @@ +/** + * 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 { Observable } from 'rxjs'; +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' + }, + metadata: { + title: isObject(layer.title) + ? layer.title[currentLocale] || layer.title.default + : layer.title + }, + url: layer.url + }; + }, + getIdentifyFlow: (layer, basePath, {features = []} = {}) => { + + return Observable.of({ + data: { + features + } + }); + } +};