From 450b0e54ba0a354e6c391bb989c1650a2e45c014 Mon Sep 17 00:00:00 2001 From: stefano bovio Date: Fri, 24 Mar 2023 10:58:32 +0100 Subject: [PATCH] #8055 allow to load 3dtiles tileset via viewer parameters (#9021) Co-authored-by: Lorenzo Natali --- docs/developer-guide/map-query-parameters.md | 27 +++- web/client/api/catalog/ThreeDTiles.js | 5 +- .../api/catalog/__tests__/ThreeDTiles-test.js | 4 +- web/client/components/catalog/Catalog.jsx | 4 +- web/client/epics/__tests__/catalog-test.js | 130 ++++++++++++++++++ web/client/epics/__tests__/queryparam-test.js | 62 ++++++++- web/client/epics/catalog.js | 32 ++++- web/client/epics/queryparams.js | 25 +++- 8 files changed, 272 insertions(+), 17 deletions(-) diff --git a/docs/developer-guide/map-query-parameters.md b/docs/developer-guide/map-query-parameters.md index 038cfdd3b8..d88040ab9b 100644 --- a/docs/developer-guide/map-query-parameters.md +++ b/docs/developer-guide/map-query-parameters.md @@ -343,7 +343,7 @@ Requirements: - The number of layers should match the number of sources - The source name can be a string that must match a catalog service name present in the map or an object that defines an external catalog (see example) -Supported layer types are WMS, WMTS and WFS. +Supported layer types are WMS, WMTS, WFS and 3D Tiles. Example: @@ -371,3 +371,28 @@ Data of resulting layer can be additionally filtered by passing "CQL_FILTER" int GET `#/viewer/config?actions=[{"type":"CATALOG:ADD_LAYERS_FROM_CATALOGS","layers":["layer1","layer2","workspace:externallayername"],"sources":["catalog1","catalog2",{"type":"WMS","url":"https://example.com/wms"}],"options": [{"params":{"CQL_FILTER":"NAME='value'"}}, {}, {"params":{"CQL_FILTER":"NAME='value2'"}}]}]` Number of objects passed to the options can be different to the number of layers, in this case options will be applied to the first X layers, where X is the length of options array. + +The 3D tiles service endpoint does not contain a default property for the name of the layer and it returns only a single record for this reason the name used in the layers array will be used to apply the title to the added 3D Tiles layer: + +```json +{ + "type": "CATALOG:ADD_LAYERS_FROM_CATALOGS", + "layers": ["My 3D Tiles Layer"], + "sources": [{ "type":"3dtiles", "url":"https://example.com/tileset-pathname/tileset.json" }] +} +``` + +GET: `#/viewer/config?actions=[{"type":"CATALOG:ADD_LAYERS_FROM_CATALOGS","layers":["My 3D Tiles Layer"],"sources":[{"type":"3dtiles","url":"https://example.com/tileset-pathname/tileset.json"}]}]` + +For the 3D Tiles you can pass also the layer options, to customize the layer. Here and example to localize the title: + +```json +{ + "type": "CATALOG:ADD_LAYERS_FROM_CATALOGS", + "layers": ["My 3D Tiles Layer"], + "sources": [{ "type":"3dtiles", "url":"https://example.com/tileset-pathname/tileset.json" }], + "options":[{ "title": { "en-US": "LayerTitle", "it-IT": "TitoloLivello" }}] +} +``` + +GET: `#/viewer/config?actions=[{"type":"CATALOG:ADD_LAYERS_FROM_CATALOGS","layers":["My 3D Tiles Layer"],"sources":[{"type":"3dtiles","url":"https://example.com/tileset-pathname/tileset.json"}],"options":[{"title":{"en-US":"LayerTitle","it-IT":"TitoloLivello"}}]}]` diff --git a/web/client/api/catalog/ThreeDTiles.js b/web/client/api/catalog/ThreeDTiles.js index ee9f043dca..f13279f838 100644 --- a/web/client/api/catalog/ThreeDTiles.js +++ b/web/client/api/catalog/ThreeDTiles.js @@ -23,6 +23,9 @@ function validateUrl(serviceUrl) { } const recordToLayer = (record) => { + if (!record) { + return null; + } const { bbox, format, properties } = record; return { type: '3dtiles', @@ -53,7 +56,7 @@ const getRecords = (url, startPosition, maxRecords, text, info) => { type: '3dtiles', tileset, ...properties - }].filter(({ title }) => !text || title?.toLowerCase().includes(text?.toLowerCase() || '')); + }]; return { numberOfRecordsMatched: records.length, numberOfRecordsReturned: records.length, diff --git a/web/client/api/catalog/__tests__/ThreeDTiles-test.js b/web/client/api/catalog/__tests__/ThreeDTiles-test.js index 93928d8292..938ad2108c 100644 --- a/web/client/api/catalog/__tests__/ThreeDTiles-test.js +++ b/web/client/api/catalog/__tests__/ThreeDTiles-test.js @@ -85,11 +85,11 @@ describe('Test 3D tiles catalog API', () => { done(); }); }); - it('should not return a single record if title not match the filter', (done) => { + it('should always return a single record even if title not match the filter', (done) => { mockAxios.onGet().reply(200, TILSET_JSON); textSearch('http://service.org/tileset.json', undefined, undefined, 'filter') .then((response) => { - expect(response.records.length).toBe(0); + expect(response.records.length).toBe(1); done(); }); }); diff --git a/web/client/components/catalog/Catalog.jsx b/web/client/components/catalog/Catalog.jsx index fd1b332327..0326e4a122 100644 --- a/web/client/components/catalog/Catalog.jsx +++ b/web/client/components/catalog/Catalog.jsx @@ -347,9 +347,9 @@ class Catalog extends React.Component { - + {this.props.services?.[this.props.selectedService]?.type !== '3dtiles' && {this.renderTextSearch()} - + } {this.renderButtons()} {this.props.layerError ? this.renderError(this.props.layerError) : null} diff --git a/web/client/epics/__tests__/catalog-test.js b/web/client/epics/__tests__/catalog-test.js index 485de89dde..c7fc8c9bdf 100644 --- a/web/client/epics/__tests__/catalog-test.js +++ b/web/client/epics/__tests__/catalog-test.js @@ -45,7 +45,10 @@ import { ADD_CATALOG_SERVICE, addService } from '../../actions/catalog'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '../../libs/ajax'; +let mockAxios; describe('catalog Epics', () => { it('getMetadataRecordById', (done) => { @@ -808,4 +811,131 @@ describe('catalog Epics', () => { } }, state, done); }); + + describe('addLayersFromCatalogsEpic 3d tiles', () => { + + beforeEach(done => { + mockAxios = new MockAdapter(axios); + setTimeout(done); + }); + + afterEach(done => { + mockAxios.restore(); + setTimeout(done); + }); + it('should add layer with title', (done) => { + const NUM_ACTIONS = 1; + const tileset = { + "asset": { + "version": "1.0" + }, + "properties": { + "Height": { + "minimum": 0, + "maximum": 7 + } + }, + "geometricError": 70, + "root": { + "refine": "ADD", + "boundingVolume": { + "region": [ + -1.3197004795898053, + 0.6988582109, + -1.3196595204101946, + 0.6988897891, + 0, + 20 + ] + }, + "geometricError": 0, + "content": { + "uri": "model.b3dm" + } + } + }; + mockAxios.onGet(/tileset\.json/).reply(() => ([ 200, tileset ])); + testEpic( + addLayersFromCatalogsEpic, + NUM_ACTIONS, + addLayersMapViewerUrl(["Title"], [{ url: 'https://server.org/name/tileset.json', type: '3dtiles' }]), + (actions) => { + try { + const [ + addLayerAndDescribeAction + ] = actions; + expect(addLayerAndDescribeAction.type).toBe(ADD_LAYER_AND_DESCRIBE); + expect(addLayerAndDescribeAction.layer).toBeTruthy(); + expect(addLayerAndDescribeAction.layer.type).toBe("3dtiles"); + expect(addLayerAndDescribeAction.layer.url).toBe("https://server.org/name/tileset.json"); + expect(addLayerAndDescribeAction.layer.title).toBe("Title"); + } catch (e) { + done(e); + } + done(); + }, {}); + }); + it('should add layer with catalog id', (done) => { + const NUM_ACTIONS = 1; + const tileset = { + "asset": { + "version": "1.0" + }, + "properties": { + "Height": { + "minimum": 0, + "maximum": 7 + } + }, + "geometricError": 70, + "root": { + "refine": "ADD", + "boundingVolume": { + "region": [ + -1.3197004795898053, + 0.6988582109, + -1.3196595204101946, + 0.6988897891, + 0, + 20 + ] + }, + "geometricError": 0, + "content": { + "uri": "model.b3dm" + } + } + }; + mockAxios.onGet(/tileset\.json/).reply(() => ([ 200, tileset ])); + testEpic( + addLayersFromCatalogsEpic, + NUM_ACTIONS, + addLayersMapViewerUrl(["name"], ["3dTilesCatalog"]), + (actions) => { + try { + const [ + addLayerAndDescribeAction + ] = actions; + expect(addLayerAndDescribeAction.type).toBe(ADD_LAYER_AND_DESCRIBE); + expect(addLayerAndDescribeAction.layer).toBeTruthy(); + expect(addLayerAndDescribeAction.layer.type).toBe("3dtiles"); + expect(addLayerAndDescribeAction.layer.url).toBe("https://server.org/name/tileset.json"); + expect(addLayerAndDescribeAction.layer.title).toBe("name"); + } catch (e) { + done(e); + } + done(); + }, { + catalog: { + selectedService: "3dTilesCatalog", + services: { + "3dTilesCatalog": { + url: 'https://server.org/name/tileset.json', + type: '3dtiles' + } + } + } + }); + }); + }); }); diff --git a/web/client/epics/__tests__/queryparam-test.js b/web/client/epics/__tests__/queryparam-test.js index 929d29dc07..69e75fb69b 100644 --- a/web/client/epics/__tests__/queryparam-test.js +++ b/web/client/epics/__tests__/queryparam-test.js @@ -596,7 +596,7 @@ describe('queryparam epics', () => { done(); }, state, false, true); }); - it('switch map type to cesium if cesium viewer options are found', (done) => { + it('switch map type to 3D if cesium viewer options are found', (done) => { const state = { maptype: { mapType: 'openlayers' @@ -622,6 +622,66 @@ describe('queryparam epics', () => { } }, state); }); + it('switch map type to 3D if actions param includes 3d tiles service', (done) => { + const state = { + maptype: { + mapType: 'openlayers' + }, + router: { + location: { + search: '?actions=[{"type":"CATALOG:ADD_LAYERS_FROM_CATALOGS","layers":["Layer"],"sources":[{"type":"3dtiles","url":"https://tileset.org/tileset.json"}]}]' + } + } + }; + const NUMBER_OF_ACTIONS = 2; + testEpic(addTimeoutEpic(readQueryParamsOnMapEpic, 10), NUMBER_OF_ACTIONS, [ + onLocationChanged({}), + configureMap() + ], (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + expect(actions[0].type).toBe(VISUALIZATION_MODE_CHANGED); + expect(actions[0].visualizationMode).toBe(VisualizationModes._3D); + done(); + } catch (e) { + done(e); + } + }, state); + }); + it('switch map type to 3D if addLayers param includes 3d tiles service', (done) => { + const state = { + maptype: { + mapType: 'openlayers' + }, + router: { + location: { + search: '?addLayers=Layer;serviceId3DTiles' + } + }, + catalog: { + services: { + serviceId3DTiles: { + type: "3dtiles", + url: "https://tileset.org/tileset.json" + } + } + } + }; + const NUMBER_OF_ACTIONS = 2; + testEpic(addTimeoutEpic(readQueryParamsOnMapEpic, 10), NUMBER_OF_ACTIONS, [ + onLocationChanged({}), + configureMap() + ], (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + expect(actions[0].type).toBe(VISUALIZATION_MODE_CHANGED); + expect(actions[0].visualizationMode).toBe(VisualizationModes._3D); + done(); + } catch (e) { + done(e); + } + }, state); + }); it('switch map type to cesium if cesium viewer options are found in sessionStorage', (done) => { const state = { maptype: { diff --git a/web/client/epics/catalog.js b/web/client/epics/catalog.js index 0ae6ad7379..2a36ff1603 100644 --- a/web/client/epics/catalog.js +++ b/web/client/epics/catalog.js @@ -67,7 +67,7 @@ import { import { getSupportedFormat, getCapabilities, describeLayers, flatLayers } from '../api/WMS'; import CoordinatesUtils from '../utils/CoordinatesUtils'; import ConfigUtils from '../utils/ConfigUtils'; -import {getCapabilitiesUrl, getLayerId, getLayerUrl, removeWorkspace } from '../utils/LayersUtils'; +import {getCapabilitiesUrl, getLayerId, getLayerUrl, removeWorkspace} from '../utils/LayersUtils'; import { wrapStartStop } from '../observables/epics'; import {zoomToExtent} from "../actions/map"; import CSW from '../api/CSW'; @@ -151,16 +151,31 @@ export default (API) => ({ const state = store.getState(); const services = servicesSelectorWithBackgrounds(state); const actions = layers - .filter((l, i) => !!services[sources[i]] || typeof sources[i] === 'object') // check for catalog name or object definition + .filter((l, i) => !!services?.[sources[i]] || typeof sources[i] === 'object') // check for catalog name or object definition .map((l, i) => { - const layerOptions = get(options, i, searchOptionsSelector(state)); const source = sources[i]; const service = typeof source === 'object' ? source : services[source]; const format = service.type.toLowerCase(); const url = service.url; const text = layers[i]; + const layerOptionsParam = get(options, i, searchOptionsSelector(state)); + // use the selected layer text as title for 3d tiles + // because currently we get only a single record for this service type + const layerOptions = format === '3dtiles' + ? { + ...layerOptionsParam, + title: isObject(layerOptionsParam?.title) + ? { + ...layerOptionsParam?.title, + "default": layerOptionsParam?.title?.default || text + } + : layerOptionsParam?.title || text + } + : layerOptionsParam; return Rx.Observable.defer(() => - API[format].textSearch(url, startPosition, maxRecords, text, {...layerOptions, ...service}).catch(() => ({ results: [] })) + API[format] + .textSearch(url, startPosition, maxRecords, text, { ...layerOptions, ...service, options: { service } }) + .catch(() => ({ records: [] })) ).map(r => ({ ...r, format, url, text, layerOptions, service })); }); return Rx.Observable.forkJoin(actions) @@ -207,12 +222,15 @@ export default (API) => ({ // return one notification for all records that have not been found actions = [recordsNotFound(allRecordsNotFound)]; } + + const layers = results + .filter(r => isObject(r[0])) + .map(r => merge({}, r[0], r[1])); + // add all layers found to the map actions = [ ...actions, - ...results.filter(r => isObject(r[0])).map(r => { - return addLayer(merge({}, r[0], r[1])); - }) + ...layers.map(layer => addLayer(layer)) ]; return Rx.Observable.from(actions); } diff --git a/web/client/epics/queryparams.js b/web/client/epics/queryparams.js index 178537f936..b071f94d1e 100644 --- a/web/client/epics/queryparams.js +++ b/web/client/epics/queryparams.js @@ -8,7 +8,7 @@ import * as Rx from 'rxjs'; import {LOCATION_CHANGE} from 'connected-react-router'; -import {get, head, isUndefined} from 'lodash'; +import {get, head, isUndefined, castArray, isString} from 'lodash'; import { CHANGE_MAP_VIEW, CLICK_ON_MAP } from '../actions/map'; import { MAP_CONFIG_LOADED } from '../actions/config'; @@ -24,6 +24,8 @@ import { changeVisualizationMode } from '../actions/maptype'; import {getCesiumViewerOptions, getParametersValues, getQueryActions, paramActions} from "../utils/QueryParamsUtils"; import {semaphore} from "../utils/EpicsUtils"; import { VisualizationModes, MapLibraries } from '../utils/MapTypeUtils'; +import { servicesSelectorWithBackgrounds, selectedServiceSelector } from '../selectors/catalog'; +import { ADD_LAYERS_FROM_CATALOGS } from '../actions/catalog'; /** * Intercept on `LOCATION_CHANGE` to get query params from router.location.search string. @@ -51,9 +53,26 @@ export const readQueryParamsOnMapEpic = (action$, store) => { .take(1) .switchMap(() => { // On map initialization, query params containing cesium viewer options - // is used to determine cesium map type + // or 3d tiles services + // are used to determine 3D visualization mode const cesiumViewerOptions = getCesiumViewerOptions(parameters); - if (cesiumViewerOptions) { + const services = servicesSelectorWithBackgrounds(store.getState()); + const defaultSource = selectedServiceSelector(store.getState()); + const hasAction3DTileService = parameters?.actions + ? !!castArray(parameters.actions) + .find(({ type, sources = [] }) => + type === ADD_LAYERS_FROM_CATALOGS + && !!sources.find((source) => + (isString(source) ? services[source] : source)?.type === '3dtiles' + )) + : false; + const hasAddLayers3DTileService = parameters?.addLayers + ? !!parameters.addLayers.split(',') + .find((parsed) => + (services[parsed?.split(';')?.[1]] || defaultSource)?.type === '3dtiles' + ) + : false; + if (cesiumViewerOptions || hasAction3DTileService || hasAddLayers3DTileService) { skipProcessing = true; return Rx.Observable.of(changeVisualizationMode(VisualizationModes._3D)); }