diff --git a/docs/developer-guide/maps-configuration.md b/docs/developer-guide/maps-configuration.md index 4c6670930a..8feed84a74 100644 --- a/docs/developer-guide/maps-configuration.md +++ b/docs/developer-guide/maps-configuration.md @@ -1292,9 +1292,16 @@ i.e. "visibility": false, "name": "Name", "sources": [ - { "url": "https://host-sample/cog1.tif" }, - { "url": "https://host-sample/cog2.tif" } - ] + { "url": "https://host-sample/cog1.tif", min: 1, max: 100, nodata: 0}, + { "url": "https://host-sample/cog2.tif", min: 1, max: 100, nodata: 255} + ], + "style": { + "body": { // cog style currently supports only RGB with alpha band or single/gray band + "color": ["array", ["band", 1], ["band", 2], ["band", 3], ["band", 4]] // RGB with alpha band + // "color": ["array", ["band", 1], ["band", 1], ["band", 1], ["band", 2]] - single/gray band + }, + "format": "openlayers", + } } ``` diff --git a/web/client/api/catalog/COG.js b/web/client/api/catalog/COG.js index 0629141038..b4dc74299c 100644 --- a/web/client/api/catalog/COG.js +++ b/web/client/api/catalog/COG.js @@ -7,14 +7,13 @@ */ import get from 'lodash/get'; -import isEmpty from 'lodash/isEmpty'; import isNil from 'lodash/isNil'; import { Observable } from 'rxjs'; -import { fromUrl as fromGeotiffUrl } from 'geotiff'; + import { isValidURL } from '../../utils/URLUtils'; import ConfigUtils from '../../utils/ConfigUtils'; -import { isProjectionAvailable } from '../../utils/ProjectionUtils'; +import LayerUtils from '../../utils/cog/LayerUtils'; import { COG_LAYER_TYPE } from '../../utils/CatalogUtils'; const searchAndPaginate = (layers, startPosition, maxRecords, text) => { @@ -31,51 +30,7 @@ const searchAndPaginate = (layers, startPosition, maxRecords, text) => { records }; }; -/** - * Get projection code from geokeys - * @param {Object} image - * @returns {string} projection code - */ -export const getProjectionFromGeoKeys = (image) => { - const geoKeys = image.geoKeys; - if (!geoKeys) { - return null; - } - if ( - geoKeys.ProjectedCSTypeGeoKey && - geoKeys.ProjectedCSTypeGeoKey !== 32767 - ) { - return "EPSG:" + geoKeys.ProjectedCSTypeGeoKey; - } - - if ( - geoKeys.GeographicTypeGeoKey && - geoKeys.GeographicTypeGeoKey !== 32767 - ) { - return "EPSG:" + geoKeys.GeographicTypeGeoKey; - } - - return null; -}; -const abortError = (reject) => reject(new DOMException("Aborted", "AbortError")); -/** - * fromUrl with abort fetching of data and data slices - * Note: The abort action will not cancel data fetch request but just the promise, - * because of the issue in https://github.com/geotiffjs/geotiff.js/issues/408 - */ -const fromUrl = (url, signal) => { - if (signal?.aborted) { - return abortError(Promise.reject); - } - return new Promise((resolve, reject) => { - signal?.addEventListener("abort", () => abortError(reject)); - return fromGeotiffUrl(url) - .then((image)=> image.getImage()) // Fetch and read first image to get medatadata of the tif - .then((image) => resolve(image)) - .catch(()=> abortError(reject)); - }); -}; let capabilitiesCache = {}; export const getRecords = (_url, startPosition, maxRecords, text, info = {}) => { const service = get(info, 'options.service'); @@ -93,50 +48,21 @@ export const getRecords = (_url, startPosition, maxRecords, text, info = {}) => }; const controller = get(info, 'options.controller'); const isSave = get(info, 'options.save', false); + const cached = capabilitiesCache[url]; + if (cached && new Date().getTime() < cached.timestamp + (ConfigUtils.getConfigProp('cacheExpire') || 60) * 1000) { + return {...cached.data}; + } // Fetch metadata only on saving the service (skip on search) if ((isNil(service.fetchMetadata) || service.fetchMetadata) && isSave) { - const cached = capabilitiesCache[url]; - if (cached && new Date().getTime() < cached.timestamp + (ConfigUtils.getConfigProp('cacheExpire') || 60) * 1000) { - return {...cached.data}; - } - return fromUrl(url, controller?.signal) - .then(image => { - const crs = getProjectionFromGeoKeys(image); - const extent = image.getBoundingBox(); - const isProjectionDefined = isProjectionAvailable(crs); - layer = { - ...layer, - sourceMetadata: { - crs, - extent: extent, - width: image.getWidth(), - height: image.getHeight(), - tileWidth: image.getTileWidth(), - tileHeight: image.getTileHeight(), - origin: image.getOrigin(), - resolution: image.getResolution() - }, - // skip adding bbox when geokeys or extent is empty - ...(!isEmpty(extent) && !isEmpty(crs) && { - bbox: { - crs, - ...(isProjectionDefined && { - bounds: { - minx: extent[0], - miny: extent[1], - maxx: extent[2], - maxy: extent[3] - }} - ) - } - }) - }; + return LayerUtils.getLayerConfig({url, controller, layer}) + .then(updatedLayer => { capabilitiesCache[url] = { timestamp: new Date().getTime(), - data: {...layer} + data: {...updatedLayer} }; - return layer; - }).catch(() => ({...layer})); + return updatedLayer; + }) + .catch(() => ({...layer})); } return Promise.resolve(layer); }); diff --git a/web/client/api/catalog/__tests__/COG-test.js b/web/client/api/catalog/__tests__/COG-test.js index 315b996603..14050426e2 100644 --- a/web/client/api/catalog/__tests__/COG-test.js +++ b/web/client/api/catalog/__tests__/COG-test.js @@ -5,8 +5,8 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ +import { getLayerFromRecord, getCatalogRecords, validate } from '../COG'; import { COG_LAYER_TYPE } from '../../../utils/CatalogUtils'; -import { getLayerFromRecord, getCatalogRecords, validate, getProjectionFromGeoKeys} from '../COG'; import expect from 'expect'; @@ -62,10 +62,4 @@ describe('COG (Abstraction) API', () => { const service = {title: "some", records: [{url: "https://some.tif"}]}; expect(validate(service)).toBeTruthy(); }); - it('test getProjectionFromGeoKeys', () => { - expect(getProjectionFromGeoKeys({geoKeys: {ProjectedCSTypeGeoKey: 4326}})).toBe('EPSG:4326'); - expect(getProjectionFromGeoKeys({geoKeys: {GeographicTypeGeoKey: 3857}})).toBe('EPSG:3857'); - expect(getProjectionFromGeoKeys({geoKeys: null})).toBe(null); - expect(getProjectionFromGeoKeys({geoKeys: {ProjectedCSTypeGeoKey: 32767}})).toBe(null); - }); }); diff --git a/web/client/components/map/openlayers/plugins/COGLayer.js b/web/client/components/map/openlayers/plugins/COGLayer.js index 591361b86b..ce0032967e 100644 --- a/web/client/components/map/openlayers/plugins/COGLayer.js +++ b/web/client/components/map/openlayers/plugins/COGLayer.js @@ -7,6 +7,8 @@ */ import Layers from '../../../../utils/openlayers/Layers'; +import isEqual from 'lodash/isEqual'; +import get from 'lodash/get'; import GeoTIFF from 'ol/source/GeoTIFF.js'; import TileLayer from 'ol/layer/WebGLTile.js'; @@ -15,7 +17,7 @@ import { isProjectionAvailable } from '../../../../utils/ProjectionUtils'; function create(options) { return new TileLayer({ msId: options.id, - style: options.style, // TODO style needs to be improved. Currently renders only predefined band and ranges when specified in config + style: get(options, 'style.body'), opacity: options.opacity !== undefined ? options.opacity : 1, visible: options.visibility, source: new GeoTIFF({ @@ -32,7 +34,10 @@ function create(options) { Layers.registerType('cog', { create, update(layer, newOptions, oldOptions, map) { - if (newOptions.srs !== oldOptions.srs) { + if (newOptions.srs !== oldOptions.srs + || !isEqual(newOptions.style, oldOptions.style) + || !isEqual(newOptions.sources, oldOptions.sources) // min/max source data value can change + ) { return create(newOptions, map); } if (oldOptions.minResolution !== newOptions.minResolution) { diff --git a/web/client/plugins/styleeditor/MultiBandEditor.jsx b/web/client/plugins/styleeditor/MultiBandEditor.jsx new file mode 100644 index 0000000000..6c61df39ed --- /dev/null +++ b/web/client/plugins/styleeditor/MultiBandEditor.jsx @@ -0,0 +1,204 @@ +/* + * Copyright 2024, 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 React from "react"; +import get from "lodash/get"; +import isEmpty from "lodash/isEmpty"; +import castArray from "lodash/castArray"; +import cloneDeep from "lodash/cloneDeep"; +import ReactSelect from "react-select"; +import { + ControlLabel, + FormControl as FormControlRB, + FormGroup, + InputGroup +} from "react-bootstrap"; + +import PropertyField from "../../components/styleeditor/PropertyField"; +import withDebounceOnCallback from "../../components/misc/enhancers/withDebounceOnCallback"; +import localizedProps from "../../components/misc/enhancers/localizedProps"; +import SwitchButton from "../../components/misc/switch/SwitchButton"; +import Message from "../../components/I18N/Message"; + +const Select = localizedProps(["placeholder"])(ReactSelect); +const FormControl = withDebounceOnCallback( + "onChange", + "value" +)( + localizedProps("placeholder")(({ debounceTime, ...props }) => ( + props.onChange(event.target.value)} + /> + )) +); + +const getBandOptions = (bands = []) => + bands.map((band) => ({ value: band, label: `Band ${band}` })); +const defaultRGBAColorExpression = ["array", ["band", 1], ["band", 2], ["band", 3], ["band", 4]]; +const defaultSingleColorExpression = ["array", ["band", 1], ["band", 1], ["band", 1], ["band", 2]]; + +/** + * Multi-Band Editor component + * The DataTileSource supports multi-bands and the Geotiff source can provide multi-band data, + * however the editor currently supports only RGB bands (3 samples) along with an optional alpha band and a single/gray band (1 sample) + */ +const MultiBandEditor = ({ + element: layer, + bands: defaultBands, + rbgBandLabels, + onUpdateNode +}) => { + const isBandStylingEnabled = !isEmpty(get(layer, "style.body.color")); + /** + * Samples per pixel are a combination of image samples + extra samples (alpha) [nodata presence adds the alpha channel] + * Each pixel can have N extra samples, however currently only +1 extra sample included + * i.e Multi extra samples are not supported + * Sample >=3 is generally considered 'RGB' as the GeoTIFF source is set with convertToRGB:'auto' + */ + const samples = get(layer, "sourceMetadata.samples"); + + /** + * There are instances where the sample is 3 or above with PhotometricInterpretation as 0 or 1 [gray scale indicator], + * this could be because the band channels are not properly defined. + * Hence we consider if the sample is >=3 or PhotometricInterpretation is not a gray scale image, + * to determine the RGB image + */ + const isRGB = samples >= 3 + || ![0, 1].includes(get(layer, "sourceMetadata.fileDirectory.PhotometricInterpretation")); + + const bands = samples === 3 ? defaultBands.slice(0, -1) : defaultBands; + + const updateStyle = (color) => { + onUpdateNode(layer.id, "layers", { + style: { body: { color }, format: "openlayers"} + }); + }; + + const onEnableBandStyle = (flag) => { + let color; + if (flag) { + color = [...(isRGB ? defaultRGBAColorExpression : defaultSingleColorExpression)]; + } + updateStyle(color); + }; + + const onChangeBand = (index, value) => { + let color = cloneDeep(get(layer, "style.body.color") ?? defaultRGBAColorExpression); + color[index] = value ? ["band", value] : 1; + updateStyle(color); + }; + + const onChangeMinMax = (type, value) => { + const source = get(layer, "sources[0]"); + onUpdateNode(layer.id, "layers", { + sources: [{ ...source, [type]: isEmpty(value) ? undefined : value }] + }); + }; + + const getColors = () => { + const colors = get(layer, "style.body.color"); + if (isEmpty(colors)) { + return [...bands]; + } + return colors + .filter((_, i) => i !== 0) // skip first index expression notation 'array' + .map((c) => castArray(c)?.[1] ?? ""); // band expression is `['band', ${value}]` + }; + + const bandColors = getColors(); + const minSourceValue = get(layer, "sources[0].min"); + const maxSourceValue = get(layer, "sources[0].max"); + + return ( +
+
+
+
+ + onEnableBandStyle(!isBandStylingEnabled)} + checked={isBandStylingEnabled} + /> +
+ {isRGB ? ( + bands.map((_, index) => { + const bandLength = bands.length; + // 4th band is alpha channel + const isAlpha = bandLength === 4 && index === bandLength - 1; + return ( + + + + )} + + + + onChangeMinMax("min", value)} + /> + + + + + + + onChangeMinMax("max", value)} + /> + + + +
+
+
+ ); +}; + +MultiBandEditor.defaultProps = { + bands: [1, 2, 3, 4], + rbgBandLabels: [ + "styleeditor.redChannel", + "styleeditor.greenChannel", + "styleeditor.blueChannel", + "styleeditor.alphaChannel" + ], + onUpdateNode: () => {} +}; + +export default MultiBandEditor; diff --git a/web/client/plugins/styleeditor/__tests__/MultiBandEditor-test.jsx b/web/client/plugins/styleeditor/__tests__/MultiBandEditor-test.jsx new file mode 100644 index 0000000000..4e1f3e3276 --- /dev/null +++ b/web/client/plugins/styleeditor/__tests__/MultiBandEditor-test.jsx @@ -0,0 +1,99 @@ +/* + * Copyright 2024, 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 React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import expect from 'expect'; +import TestUtils from 'react-dom/test-utils'; + +import MultiBandEditor from '../MultiBandEditor'; + +describe("MultiBandEditor", () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + it("editor with single band data", () => { + const props = { + element: {sourceMetadata: {samples: 1, fileDirectory: {PhotometricInterpretation: 0}} } + }; + + render(, document.getElementById("container")); + const enableBanding = document.querySelector('.enable-band'); + expect(enableBanding).toBeTruthy(); + expect(enableBanding.innerHTML).toContain("styleeditor.enableBanding"); + + const propertyFields = document.querySelectorAll('.ms-symbolizer-field'); + const grayChannel = document.querySelector('.ms-symbolizer-label'); + expect(propertyFields.length).toBe(3); + expect(grayChannel).toBeTruthy(); + expect(grayChannel.innerHTML).toContain('styleeditor.grayChannel'); + + expect(propertyFields[1].innerHTML).toContain('styleeditor.minLabel'); + expect(propertyFields[2].innerHTML).toContain('styleeditor.maxLabel'); + }); + it("editor with RGB band data", () => { + const props = { + element: {sourceMetadata: {samples: 3} } + }; + + render(, document.getElementById("container")); + const enableBanding = document.querySelector('.enable-band'); + expect(enableBanding).toBeTruthy(); + expect(enableBanding.innerHTML).toContain("styleeditor.enableBanding"); + + const propertyFields = document.querySelectorAll('.ms-symbolizer-field'); + expect(propertyFields.length).toBe(5); + }); + it("editor with multi band data", () => { + const props = { + element: {sourceMetadata: {samples: 4} } + }; + + render(, document.getElementById("container")); + const enableBanding = document.querySelector('.enable-band'); + expect(enableBanding).toBeTruthy(); + expect(enableBanding.innerHTML).toContain("styleeditor.enableBanding"); + + const propertyFields = document.querySelectorAll('.ms-symbolizer-field'); + expect(propertyFields.length).toBe(6); + }); + it("editor on apply & un-apply banding", () => { + let props = { + element: {sourceMetadata: {samples: 4} } + }; + const action = { + onUpdateNode: () => {} + }; + const spyOnUpdate = expect.spyOn(action, 'onUpdateNode'); + + render(, document.getElementById("container")); + const enableBandBtn = document.querySelector('.enable-band .mapstore-switch-btn input'); + expect(enableBandBtn).toBeTruthy(); + + TestUtils.Simulate.change(enableBandBtn, { "target": { "checked": true }}); + expect(spyOnUpdate).toHaveBeenCalled(); + expect(spyOnUpdate.calls[0].arguments[1]).toBe("layers"); + expect(spyOnUpdate.calls[0].arguments[2].style.body.color).toBeTruthy(); + + props = { + element: {sourceMetadata: {samples: 4}, style: {body: {color: ["array"]}}} + }; + render(, document.getElementById("container")); + TestUtils.Simulate.change(enableBandBtn, { "target": { "checked": false }}); + expect(spyOnUpdate).toHaveBeenCalled(); + expect(spyOnUpdate.calls[1].arguments[1]).toBe("layers"); + expect(spyOnUpdate.calls[1].arguments[2].style).toEqual({ body: { color: undefined }, format: 'openlayers' }); + }); +}); diff --git a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js index 09c2676b91..a0469544e5 100644 --- a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js +++ b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js @@ -28,6 +28,7 @@ import FeatureInfo from './tabs/FeatureInfo'; import VectorStyleEditor from '../styleeditor/VectorStyleEditor'; +import MultiBandEditor from '../styleeditor/MultiBandEditor'; import { mapSelector } from '../../selectors/map'; @@ -45,12 +46,13 @@ const ConnectedVectorStyleEditor = connect( const isLayerNode = ({settings = {}} = {}) => settings.nodeType === 'layers'; const isVectorStylableLayer = ({element = {}} = {}) => element.type === "wfs" || element.type === "3dtiles" || element.type === "vector" && !isAnnotationLayer(element); +const isCOGStylableLayer = ({element = {}} = {}) => element.type === "cog"; const isWMS = ({element = {}} = {}) => element.type === "wms"; const isWFS = ({element = {}} = {}) => element.type === "wfs"; const isStylableLayer = (props) => isLayerNode(props) - && (isWMS(props) || isVectorStylableLayer(props)); + && (isWMS(props) || isVectorStylableLayer(props) || isCOGStylableLayer(props)); const configuredPlugins = {}; @@ -74,6 +76,9 @@ export const getStyleTabPlugin = ({ settings, items = [], loadedPlugins, onToggl if (isVectorStylableLayer({element})) { return { Component: ConnectedVectorStyleEditor }; } + if (isCOGStylableLayer({element})) { + return { Component: MultiBandEditor }; + } // get Higher priority plugin that satisfies requirements. const candidatePluginItems = sortBy(filter([...items], { target: 'style' }), ["priority"]) // find out plugins with target panel 'style' and sort by priority diff --git a/web/client/themes/default/less/style-editor.less b/web/client/themes/default/less/style-editor.less index d38184a002..f80b148251 100644 --- a/web/client/themes/default/less/style-editor.less +++ b/web/client/themes/default/less/style-editor.less @@ -146,6 +146,14 @@ .ms-rule-collapse * { .color-var(@theme-vars[main-color]); } + + .ms-style-band-container { + .ms-style-band-editor { + .ms-style-band { + .border-color-var(@theme-vars[main-border-color]); + } + } + } .ms-classification-layer-settings { .color-var(@theme-vars[main-color]); .background-color-var(@theme-vars[main-bg]); @@ -797,6 +805,23 @@ Ported to CodeMirror by Peter Kroon height: 16px; } } +.ms-style-band-container { + padding: 8px; + .ms-style-band-editor { + padding: 8px; + .shadow-soft; + .ms-style-band { + padding: 8px; + border: 1px solid @ms-main-border-color; + .enable-band { + display: flex; + justify-content: flex-end; + gap: 4px; + padding-right: 4px; + } + } + } +} .ms-classification-layer-settings { display: flex; diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 9de3311781..dba0f6a0d1 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -2634,6 +2634,7 @@ "redChannel": "Roter Kanal", "greenChannel": "Grüner Kanal", "blueChannel": "Blauer Kanal", + "alphaChannel": "Alpha Kanal", "grayChannel": "Graukanal", "ruleClassification": "Klassifikation", "ruleRaster": "Raster", @@ -2701,6 +2702,12 @@ "msExtrusionColor": "Extrusionsfarbe", "msExtrusionType": "Extrusionstyp", "wall": "Wand", + "enableBanding": "Band styling", + "selectChannel": "Wählen Sie eine Band aus", + "minLabel": "Min", + "maxLabel": "Max", + "minSourceValue": "Mindestwert der Quelldaten", + "maxSourceValue": "Maximaler Quelldatenwert", "customParams": "Benutzerdefinierte Parameter", "wrongFormatMsg": "Die eingegebene Konfiguration ist in falschem Format !!" }, diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 345c200c8d..27c0c7417d 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -2606,6 +2606,7 @@ "redChannel": "Red channel", "greenChannel": "Green channel", "blueChannel": "Blue channel", + "alphaChannel": "Alpha channel", "grayChannel": "Channel", "ruleClassification": "Classification", "ruleRaster": "Raster", @@ -2673,6 +2674,12 @@ "msExtrusionColor": "Extrusion color", "msExtrusionType": "Extrusion type", "wall": "Wall", + "enableBanding": "Band styling", + "selectChannel": "Select a band", + "minLabel": "Min", + "maxLabel": "Max", + "minSourceValue": "Minimum source data value", + "maxSourceValue": "Maximum source data value", "customParams": "Custom Parameters", "wrongFormatMsg": "The entered configuration is in wrong format !!" }, diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 633df1e71f..35f35818d4 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -2596,6 +2596,7 @@ "redChannel": "Red channel", "greenChannel": "Green channel", "blueChannel": "Blue channel", + "alphaChannel": "Alpha channel", "grayChannel": "Channel", "ruleClassification": "Clasificación", "ruleRaster": "Ráster", @@ -2663,6 +2664,12 @@ "msExtrusionColor": "Color de extrusión", "msExtrusionType": "Tipo de extrusión", "wall": "Muro", + "enableBanding": "Estilo de banda", + "selectChannel": "Selecciona una banda", + "minLabel": "Min", + "maxLabel": "Max", + "minSourceValue": "Valor mínimo de datos de origen", + "maxSourceValue": "Valor máximo de datos de origen", "customParams": "Parámetros personalizados", "wrongFormatMsg": "¡La configuración ingresada está en formato incorrecto!" }, diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 83716108a3..1d0183e2e5 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -2596,6 +2596,7 @@ "redChannel": "Red channel", "greenChannel": "Green channel", "blueChannel": "Blue channel", + "alphaChannel": "Alpha channel", "grayChannel": "Channel", "ruleClassification": "Classification", "ruleRaster": "Raster", @@ -2663,6 +2664,12 @@ "msExtrusionColor": "Couleur d'extrusion", "msExtrusionType": "Type d'extrusion", "wall": "Mur", + "enableBanding": "Style de bande", + "selectChannel": "Sélectionnez un groupe", + "minLabel": "Min", + "maxLabel": "Max", + "minSourceValue": "Valeur minimale des données sources", + "maxSourceValue": "Valeur maximale des données sources", "customParams": "Paramètres personnalisés", "wrongFormatMsg": "La configuration entrée est en mauvais format !!" }, diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 730fdafce4..f53190faf9 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -2597,6 +2597,7 @@ "redChannel": "Canale rosso", "greenChannel": "Canale verde", "blueChannel": "Canale blue", + "alphaChannel": "Canale alfa", "grayChannel": "Canale", "ruleClassification": "Classificazione", "ruleRaster": "Raster", @@ -2664,6 +2665,12 @@ "msExtrusionColor": "Colore estrusione", "msExtrusionType": "Tipo estrusione", "wall": "Muro", + "enableBanding": "Stile a fascia", + "selectChannel": "Seleziona una banda", + "minLabel": "Min", + "maxLabel": "Max", + "minSourceValue": "Valore minimo dei dati di origine", + "maxSourceValue": "Valore massimo dei dati di origine", "customParams": "Parametri personalizzati", "wrongFormatMsg": "La configurazione immessa è in formato sbagliato !!" }, diff --git a/web/client/utils/cog/LayerUtils.js b/web/client/utils/cog/LayerUtils.js new file mode 100644 index 0000000000..1ab27cbee0 --- /dev/null +++ b/web/client/utils/cog/LayerUtils.js @@ -0,0 +1,118 @@ +import { fromUrl as fromGeotiffUrl } from 'geotiff'; +import isEmpty from 'lodash/isEmpty'; +import get from 'lodash/get'; + +import { isProjectionAvailable } from "../ProjectionUtils"; + +let LayerUtils; + +/** + * Get projection code from geokeys + * @param {Object} image + * @returns {string} projection code + */ +export const getProjectionFromGeoKeys = (image) => { + const geoKeys = image.geoKeys; + if (!geoKeys) { + return null; + } + + if ( + geoKeys.ProjectedCSTypeGeoKey && + geoKeys.ProjectedCSTypeGeoKey !== 32767 + ) { + return "EPSG:" + geoKeys.ProjectedCSTypeGeoKey; + } + + if ( + geoKeys.GeographicTypeGeoKey && + geoKeys.GeographicTypeGeoKey !== 32767 + ) { + return "EPSG:" + geoKeys.GeographicTypeGeoKey; + } + + return null; +}; + +const abortError = (reject) => reject(new DOMException("Aborted", "AbortError")); +/** + * fromUrl with abort fetching of data and data slices + * Note: The abort action will not cancel data fetch request but just the promise, + * because of the issue in https://github.com/geotiffjs/geotiff.js/issues/408 + */ +export const fromUrl = (url, signal) => { + if (signal?.aborted) { + return abortError(Promise.reject); + } + return new Promise((resolve, reject) => { + signal?.addEventListener("abort", () => abortError(reject)); + return fromGeotiffUrl(url) + .then((image)=> image.getImage()) // Fetch and read first image to get medatadata of the tif + .then((image) => resolve(image)) + .catch(()=> abortError(reject)); + }); +}; + +export const getLayerConfig = ({ url, layer, controller }) => { + return LayerUtils.fromUrl(url, controller?.signal) + .then(image => { + const crs = LayerUtils.getProjectionFromGeoKeys(image); + const extent = image.getBoundingBox(); + const isProjectionDefined = isProjectionAvailable(crs); + const samples = image.getSamplesPerPixel(); + const { STATISTICS_MINIMUM, STATISTICS_MAXIMUM } = image.getGDALMetadata() ?? {}; + + // `nodata` is usually present in the tif's source data, currently defaults to 0 when not present. (TODO should be made configurable in the future) + // Adds an alpha channel when present and helps with visualization and eliminates no data tile around the image + const nodata = image.getGDALNoData() ?? 0; + + const updatedLayer = { + ...layer, + sources: layer?.sources?.map(source => ({ + ...source, + min: source.min ?? STATISTICS_MINIMUM, + max: source.max ?? STATISTICS_MAXIMUM, + nodata + })), + sourceMetadata: { + crs, + extent: extent, + width: image.getWidth(), + height: image.getHeight(), + tileWidth: image.getTileWidth(), + tileHeight: image.getTileHeight(), + origin: image.getOrigin(), + resolution: image.getResolution(), + samples, + fileDirectory: { + // add more fileDirectory properties based on requirement + PhotometricInterpretation: get(image, 'fileDirectory.PhotometricInterpretation') + } + }, + // skip adding bbox when geokeys or extent is empty + ...(!isEmpty(extent) && !isEmpty(crs) && { + bbox: { + crs, + ...(isProjectionDefined && { + bounds: { + minx: extent[0], + miny: extent[1], + maxx: extent[2], + maxy: extent[3] + }} + ) + } + }) + }; + return updatedLayer; + }) + .catch(() => ({...layer})); +}; + +LayerUtils = { + getProjectionFromGeoKeys, + fromUrl, + getLayerConfig +}; + +export default LayerUtils; diff --git a/web/client/utils/cog/__tests__/LayerUtils-test.jsx b/web/client/utils/cog/__tests__/LayerUtils-test.jsx new file mode 100644 index 0000000000..531c5a746e --- /dev/null +++ b/web/client/utils/cog/__tests__/LayerUtils-test.jsx @@ -0,0 +1,26 @@ +/* + * Copyright 2024, 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 { getProjectionFromGeoKeys} from '../LayerUtils'; +import expect from 'expect'; + + +describe('COG - LayerUtils', () => { + beforeEach(done => { + setTimeout(done); + }); + + afterEach(done => { + setTimeout(done); + }); + it('test getProjectionFromGeoKeys', () => { + expect(getProjectionFromGeoKeys({geoKeys: {ProjectedCSTypeGeoKey: 4326}})).toBe('EPSG:4326'); + expect(getProjectionFromGeoKeys({geoKeys: {GeographicTypeGeoKey: 3857}})).toBe('EPSG:3857'); + expect(getProjectionFromGeoKeys({geoKeys: null})).toBe(null); + expect(getProjectionFromGeoKeys({geoKeys: {ProjectedCSTypeGeoKey: 32767}})).toBe(null); + }); +});