diff --git a/web/client/api/GeoNode.js b/web/client/api/GeoNode.js index 612b96cc78a..d8a2e180e08 100644 --- a/web/client/api/GeoNode.js +++ b/web/client/api/GeoNode.js @@ -28,6 +28,7 @@ export const GEONODE_DEFAULT_SORT = '-date'; export const RESOURCES = 'resources'; export const DATASETS = 'datasets'; export const DOCUMENTS = 'documents'; +export const MAPS = 'maps'; export const FACETS = 'facets'; let endpoints = { @@ -160,6 +161,14 @@ export const getDocumentByPk = (baseUrl, pk) => { .then(({ data }) => data.document); }; +export const getMapByPk = (baseUrl, pk) => { + return axios.get(getEndpointUrl(baseUrl, MAPS, pk), { + params: mergePresetParams('VIEWER_COMMON', 'MAP'), + ...paramsSerializer() + }) + .then(({ data }) => data.map); +}; + export const getResources = ({ q, pageSize = 10, diff --git a/web/client/api/__tests__/GeoNode-test.js b/web/client/api/__tests__/GeoNode-test.js index 66ddb09b5b0..24fc27a44fc 100644 --- a/web/client/api/__tests__/GeoNode-test.js +++ b/web/client/api/__tests__/GeoNode-test.js @@ -221,4 +221,26 @@ describe('Test correctness of the GeoNode APIs (mock axios)', () => { options: { service: { defaultSort: '-popular_count' } } }).then(() => done()).catch(done); }); + + it('getMapByPk requests the maps endpoint and unwraps the map', (done) => { + mockAxios.onGet().reply((config) => { + try { + expect(config.url).toBe('https://example.com/api/v2/maps/2'); + } catch (e) { + done(e); + } + return [200, { map: { pk: 2, title: 'mappa_gruppi', data: { map: { layers: [], groups: [] } } } }]; + }); + + API.getMapByPk('https://example.com', 2).then((map) => { + try { + expect(map.pk).toBe(2); + expect(map.title).toBe('mappa_gruppi'); + expect(map.data.map).toEqual({ layers: [], groups: [] }); + done(); + } catch (e) { + done(e); + } + }).catch(done); + }); }); diff --git a/web/client/api/catalog/GeoNode.js b/web/client/api/catalog/GeoNode.js index 82ee226fff8..037062d7419 100644 --- a/web/client/api/catalog/GeoNode.js +++ b/web/client/api/catalog/GeoNode.js @@ -6,21 +6,16 @@ * LICENSE file in the root directory of this source tree. */ - -import { v4 as uuid } from 'uuid'; -import isEmpty from 'lodash/isEmpty'; -import turfCenter from '@turf/center'; -import { textSearch as geonodeTextSearch, getDatasetByPk, getResourceByPk, getDocumentByPk } from '../GeoNode'; +import { textSearch as geonodeTextSearch, getDatasetByPk, getResourceByPk, getDocumentByPk, getMapByPk } from '../GeoNode'; import { getLayerTitleTranslations } from '../../utils/LayersUtils'; -import { getMessageById } from '../../utils/LocaleUtils'; import { resourceToLayerConfig, isDefaultDatasetSubtype, getTagConfig, - ResourceTypes, - GEONODE_DOCUMENTS_ROW_VIEWER + documentsToLayerConfig as documentsToLayerConfigSync, + resourceMapToLayerGroup, + ResourceTypes } from '../../utils/GeoNodeUtils'; -import { getPolygonFromExtent } from '../../utils/CoordinatesUtils'; import { getConfigProp } from '../../utils/ConfigUtils'; import { preprocess as commonPreprocess, @@ -99,7 +94,11 @@ export const getCatalogRecords = (records, options) => { tagFilterType, creator: record.owner?.username, identifier: record?.pk, - icon: record.resource_type === ResourceTypes.DOCUMENT ? { glyph: 'document' } : undefined, + icon: record.resource_type === ResourceTypes.DOCUMENT + ? { glyph: 'document' } + : record.resource_type === ResourceTypes.MAP + ? { glyph: '1-map' } + : undefined, isValid: true }; }); @@ -120,93 +119,6 @@ export const getLayerFromRecord = (record, options, asPromise = false) => { .then((resource) => resourceToLayerConfig({ ...record, ...resource }, options)); }; -const calculateBbox = (coordinates) => { - const validCoords = (coordinates || []).filter(coord => coord && coord.length === 2); - if (validCoords.length === 0) { - return null; - } - const lons = validCoords.map(coord => coord[0]); - const lats = validCoords.map(coord => coord[1]); - return { - bounds: { - minx: Math.min(...lons), - miny: Math.min(...lats), - maxx: Math.max(...lons), - maxy: Math.max(...lats) - }, - crs: 'EPSG:4326' - }; -}; - -const documentMarkerSymbolizer = (glyph) => [{ - kind: 'Icon', - size: 46, - image: { args: [{ color: 'blue', glyph, shape: 'circle' }], name: 'msMarkerIcon' }, - anchor: 'bottom', - rotate: 0, - opacity: 1, - symbolizerId: '01', - msBringToFront: false, - msHeightReference: 'none' -}]; - -const getDocumentsStyle = (locales) => ({ - format: 'geostyler', - metadata: { editorType: 'visual' }, - body: { - rules: [ - { name: getMessageById(locales, 'catalog.subtypes.video'), ruleId: '01', mandatory: false, filter: ['&&', ['==', 'subtype', 'video']], symbolizers: documentMarkerSymbolizer('video-camera') }, - { name: getMessageById(locales, 'catalog.subtypes.image'), ruleId: '02', mandatory: false, filter: ['&&', ['==', 'subtype', 'image']], symbolizers: documentMarkerSymbolizer('camera') }, - { name: getMessageById(locales, 'catalog.subtypes.file'), ruleId: '03', mandatory: false, filter: ['&&', ['!=', 'subtype', 'image'], ['!=', 'subtype', 'video']], symbolizers: documentMarkerSymbolizer('file') } - ] - } -}); - -/** - * Build a single MapStore vector layer that collects the given GeoNode documents - * as point features (located at the center of each document extent). Documents - * without an extent are skipped. - */ -export const documentsToLayerConfig = (documents = [], options = {}) => { - const baseURL = options?.service?.url; - const locales = options?.locales; - // resilient per document: a failed fetch is skipped, not fatal to the whole layer - return Promise.all(documents.map(doc => getDocumentByPk(baseURL, doc.pk).catch(() => null))) - .then((fullDocs) => { - const features = fullDocs - .map((doc) => { - const extent = doc?.extent?.coords; - const polygon = !isEmpty(extent) ? getPolygonFromExtent(extent) : null; - const center = polygon ? turfCenter(polygon) : null; - if (!center) { - return null; - } - return { - type: 'Feature', - properties: doc, - geometry: { - type: 'Point', - coordinates: center.geometry.coordinates - }, - id: doc.pk - }; - }) - .filter(Boolean); - const bbox = calculateBbox(features.map(feature => feature.geometry.coordinates)); - return { - id: uuid(), - type: 'vector', - visibility: true, - name: 'Documents', - title: `${getMessageById(locales, 'catalog.resourceTypes.document')} (${features.length})`, - ...(bbox && { bbox }), - features, - style: getDocumentsStyle(locales), - rowViewer: GEONODE_DOCUMENTS_ROW_VIEWER - }; - }); -}; - /** * Process the whole selected record set into map content (N records -> M layers). * GeoNode documents collapse into a single vector layer; every other record type @@ -217,20 +129,55 @@ export const processRecords = (records = [], options = {}, locales) => { const applySecurity = (layer) => layer && protectedId ? { ...layer, security: { type: 'basic', sourceId: protectedId } } : layer; + + const others = records.filter(record => ![ResourceTypes.DOCUMENT, ResourceTypes.MAP].includes(record.resource_type)); const documents = records.filter(record => record.resource_type === ResourceTypes.DOCUMENT); - const others = records.filter(record => record.resource_type !== ResourceTypes.DOCUMENT); + const maps = records.filter(record => record.resource_type === ResourceTypes.MAP); + const otherLayersPromise = Promise.all( // resilient per record: a failed conversion is skipped, not fatal to the batch others.map(record => getLayerFromRecord(record, options, true).then(applySecurity).catch(() => null)) ); const documentsLayerPromise = documents.length - ? documentsToLayerConfig(documents, { ...options, locales }).catch(() => null) + ? Promise.all( + documents.map(doc => getDocumentByPk(options?.service?.url, doc.pk).catch(() => null)) + ) + .then((docs) => documentsToLayerConfigSync(docs, locales)) + .catch(() => null) : Promise.resolve(null); - return Promise.all([otherLayersPromise, documentsLayerPromise]) - .then(([otherLayers, documentsLayer]) => ({ - layers: [...otherLayers, documentsLayer].filter(Boolean), - groups: [] - })); + + const mapContentsPromise = Promise.all( + maps.map(record => getMapByPk(options?.service?.url, record.pk) + .then((mapResource) => resourceMapToLayerGroup(mapResource)) + .catch(() => null)) + ); + return Promise.all([ + otherLayersPromise, + documentsLayerPromise, + mapContentsPromise + ]) + .then(([otherLayers, documentsLayer, mapContents]) => { + const validMapContents = mapContents.filter(Boolean); + return { + layers: [ + ...otherLayers, + documentsLayer, + ...validMapContents.flatMap(content => content.layers.map(applySecurity)) + ].filter(Boolean), + groups: validMapContents.flatMap(content => content.groups) + }; + }); +}; + +// used by https://github.com/GeoNode/geonode-mapstore-client/issues/2583 +export const documentsToLayerConfig = (documents = [], options = {}) => { + const baseURL = options?.service?.url; + const locales = options?.locales; + // resilient per document: a failed fetch is skipped, not fatal to the whole layer + return Promise.all(documents.map(doc => getDocumentByPk(baseURL, doc.pk).catch(() => null))) + .then((fullDocs) => { + return documentsToLayerConfigSync(fullDocs, locales); + }); }; export const getCapabilities = ({ service } = {}) => { diff --git a/web/client/api/catalog/__tests__/GeoNode-test.js b/web/client/api/catalog/__tests__/GeoNode-test.js index 9ea6bca22dc..1ab70a6e803 100644 --- a/web/client/api/catalog/__tests__/GeoNode-test.js +++ b/web/client/api/catalog/__tests__/GeoNode-test.js @@ -12,7 +12,6 @@ import axios from '../../../libs/ajax'; import { getCatalogRecords, getLayerFromRecord, - documentsToLayerConfig, processRecords } from '../GeoNode'; @@ -152,27 +151,6 @@ describe('GeoNode catalog processRecords / documents', () => { links: [{ link_type: 'OGC:WMS', url: 'http://sample?name=layer1' }] }; - it('documentsToLayerConfig builds a single vector layer of point features', (done) => { - mockDocuments(); - documentsToLayerConfig([{ pk: 10 }, { pk: 11 }], { service: { url: 'http://gn' } }) - .then((layer) => { - try { - expect(layer.type).toBe('vector'); - expect(layer.name).toBe('Documents'); - expect(layer.rowViewer).toBe('GEONODE_DOCUMENTS_ROW_VIEWER'); - // doc 11 has no extent -> skipped - expect(layer.features.length).toBe(1); - expect(layer.features[0].id).toBe(10); - expect(layer.features[0].geometry.type).toBe('Point'); - expect(layer.features[0].geometry.coordinates).toEqual([5, 5]); - expect(layer.bbox).toExist(); - done(); - } catch (e) { - done(e); - } - }); - }); - it('processRecords collapses documents and converts other records to layers', (done) => { mockDocuments(); const records = [datasetRecord, { resource_type: 'document', pk: 10 }]; @@ -223,25 +201,6 @@ describe('GeoNode catalog processRecords / documents', () => { }); }); - it('documentsToLayerConfig skips documents whose fetch fails', (done) => { - mockAxios.onGet().reply((config) => { - if (config.url.indexOf('/documents/10') !== -1) { - return [200, { document: { pk: 10, title: 'Doc 10', subtype: 'image', extent: { coords: [0, 0, 10, 10] } } }]; - } - return [500]; - }); - documentsToLayerConfig([{ pk: 10 }, { pk: 12 }], { service: { url: 'http://gn' } }) - .then((layer) => { - try { - expect(layer.features.length).toBe(1); - expect(layer.features[0].id).toBe(10); - done(); - } catch (e) { - done(e); - } - }); - }); - it('processRecords skips records that fail and keeps the rest', (done) => { mockAxios.onGet().reply((config) => { if (config.url.indexOf('/documents/10') !== -1) { diff --git a/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx b/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx index 0a58b095976..40b9bcfee71 100644 --- a/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx +++ b/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx @@ -24,7 +24,8 @@ const TAG_FILTER_TYPE_OPTIONS = [ const RESOURCE_TYPE_OPTIONS = [ { value: 'dataset', label: }, - { value: 'document', label: } + { value: 'document', label: }, + { value: 'map', label: } ]; /** diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 7b9591c41ac..f9edfabff02 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1741,7 +1741,8 @@ "resourceTypes": { "label": "Ressourcentypen", "dataset": "Datensätze", - "document": "Dokumente" + "document": "Dokumente", + "map": "Karten" }, "subtypes": { "label": "Inhaltstyp", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 65e6bfa4eae..bfed712d7a8 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1702,7 +1702,8 @@ "resourceTypes": { "label": "Resource types", "dataset": "Datasets", - "document": "Documents" + "document": "Documents", + "map": "Maps" }, "subtypes": { "label": "Content type", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index dd3f3016138..21c59adf63b 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1702,7 +1702,8 @@ "resourceTypes": { "label": "Tipos de recurso", "dataset": "Conjuntos de datos", - "document": "Documentos" + "document": "Documentos", + "map": "Mapas" }, "subtypes": { "label": "Tipo de contenido", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 96a1135d2c8..bb5e86bc73b 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1702,7 +1702,8 @@ "resourceTypes": { "label": "Types de ressource", "dataset": "Jeux de données", - "document": "Documents" + "document": "Documents", + "map": "Cartes" }, "subtypes": { "label": "Type de contenu", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 895bc0ee713..bc6a173c315 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1701,7 +1701,8 @@ "resourceTypes": { "label": "Tipi di risorsa", "dataset": "Dataset", - "document": "Documenti" + "document": "Documenti", + "map": "Mappe" }, "subtypes": { "label": "Tipo di contenuto", diff --git a/web/client/utils/GeoNodeUtils.js b/web/client/utils/GeoNodeUtils.js index 2be83fe3c7f..473a38b5bcf 100644 --- a/web/client/utils/GeoNodeUtils.js +++ b/web/client/utils/GeoNodeUtils.js @@ -6,14 +6,18 @@ * LICENSE file in the root directory of this source tree. */ -import { isImageServerUrl } from './ArcGISUtils'; -import { getConfigProp } from './ConfigUtils'; -import { getSupportedLocales, shortLocale } from './LocaleUtils'; + import { v4 as uuid } from 'uuid'; import { isEmpty } from 'lodash'; import queryString from 'query-string'; import url from 'url'; +import turfCenter from '@turf/center'; + +import { isImageServerUrl } from './ArcGISUtils'; +import { getConfigProp } from './ConfigUtils'; +import { getSupportedLocales, shortLocale, getMessageById } from './LocaleUtils'; import { ServerTypes } from './LayersUtils'; +import { getPolygonFromExtent } from './CoordinatesUtils'; export const SOURCE_TYPES = { LOCAL: 'LOCAL', @@ -305,6 +309,63 @@ const getLocalizedValues = (resource, key, defaultValue) => { return defaultValue; }; +const calculateBbox = (coordinates) => { + const validCoords = (coordinates || []).filter(coord => coord && coord.length === 2); + if (validCoords.length === 0) { + return null; + } + const lons = validCoords.map(coord => coord[0]); + const lats = validCoords.map(coord => coord[1]); + return { + bounds: { + minx: Math.min(...lons), + miny: Math.min(...lats), + maxx: Math.max(...lons), + maxy: Math.max(...lats) + }, + crs: 'EPSG:4326' + }; +}; + +const documentMarkerSymbolizer = (glyph) => [{ + kind: 'Icon', + size: 46, + image: { args: [{ color: 'blue', glyph, shape: 'circle' }], name: 'msMarkerIcon' }, + anchor: 'bottom', + rotate: 0, + opacity: 1, + symbolizerId: '01', + msBringToFront: false, + msHeightReference: 'none' +}]; + +const getDocumentsStyle = (locales) => ({ + format: 'geostyler', + metadata: { editorType: 'visual' }, + body: { + rules: [ + { name: getMessageById(locales, 'catalog.subtypes.video'), + ruleId: '01', + mandatory: false, + filter: ['&&', ['==', 'subtype', 'video']], + symbolizers: documentMarkerSymbolizer('video-camera') + }, + { name: getMessageById(locales, 'catalog.subtypes.image'), + ruleId: '02', + mandatory: false, + filter: ['&&', ['==', 'subtype', 'image']], + symbolizers: documentMarkerSymbolizer('camera') + }, + { name: getMessageById(locales, 'catalog.subtypes.file'), + ruleId: '03', + mandatory: false, + filter: ['&&', ['!=', 'subtype', 'image'], ['!=', 'subtype', 'video']], + symbolizers: documentMarkerSymbolizer('file') + } + ] + } +}); + /** * convert resource layer configuration to a mapstore layer object * @param {object} resource geonode layer resource @@ -461,25 +522,88 @@ export const resourceToLayerConfig = (resource, options) => { }; -// For map : if we need to also add map then we need this -export const resourceToLayers = (resource) => { - if (resource?.resource_type === ResourceTypes.DATASET) { - return [{...resourceToLayerConfig(resource), isDataset: true}]; - } - if (resource.maplayers && resource?.resource_type === ResourceTypes.MAP) { - return resource.maplayers - .map(maplayer => { - maplayer.dataset ? resourceToLayerConfig(maplayer.dataset) : null; - if (maplayer.dataset) { - const layer = resourceToLayerConfig(maplayer.dataset); - return { - ...layer, - style: maplayer.current_style - }; - } +/** + * Build a single MapStore vector layer that collects the given GeoNode documents + * as point features (located at the center of each document extent). Documents + * without an extent are skipped. + * @param {array} documents - array of GeoNode document resources + * @param {object} options - options object, may include locales + * @returns {object} a MapStore vector layer config + */ +export const documentsToLayerConfig = (documents = [], locales) => { + const features = documents + .map((doc) => { + const extent = doc?.extent?.coords; + const polygon = !isEmpty(extent) ? getPolygonFromExtent(extent) : null; + const center = polygon ? turfCenter(polygon) : null; + if (!center) { return null; - }) - .filter(value => value); - } - return []; + } + return { + type: 'Feature', + properties: doc, + geometry: { + type: 'Point', + coordinates: center.geometry.coordinates + }, + id: doc.pk + }; + }) + .filter(Boolean); + const bbox = calculateBbox(features.map(feature => feature.geometry.coordinates)); + return { + id: uuid(), + type: 'vector', + visibility: true, + name: 'Documents', + title: `${getMessageById(locales, 'catalog.resourceTypes.document')} (${features.length})`, + ...(bbox && { bbox }), + features, + style: getDocumentsStyle(locales), + rowViewer: GEONODE_DOCUMENTS_ROW_VIEWER + }; +}; + +/** +* Convert a GeoNode map resource into structure `{ layers, groups }` ready for the catalog container: +* all the map layers exclude backgrounds, nested under a new parent group titled how the map name. +* @param {object} - GeoNode map resource +* @returns {object} - `{ layers, groups }` where `layers` is an array of layer objects and `groups` is an array of group objects +*/ +export const resourceMapToLayerGroup = (mapResource) => { + const mapConfig = mapResource?.data?.map || {}; + const parentId = `Default.${uuid()}`; + const parentGroup = { + title: mapResource?.title, + parent: 'Default', + options: { id: parentId }, + asFirst: true + }; + const oldToNewPath = { Default: parentId }; + const groups = (mapConfig.groups || []) + .filter((group) => group?.id && group.id !== 'Default') + .sort((a, b) => a.id.split('.').length - b.id.split('.').length) + .map((group) => { + const segments = group.id.split('.'); + const oldParentPath = segments.slice(0, -1).join('.'); + const newParentPath = oldToNewPath[oldParentPath] || parentId; + const newSegment = uuid(); + const newPath = `${newParentPath}.${newSegment}`; + oldToNewPath[group.id] = newPath; + const { id, title, ...groupOptions } = group; + return { + title, + parent: newParentPath, + options: { ...groupOptions, id: newPath, name: newSegment }, + asFirst: false + }; + }); + const layers = (mapConfig.layers || []) + .filter((layer) => layer?.group !== 'background') + .map((layer) => ({ + ...layer, + id: uuid(), + group: oldToNewPath[layer?.group] || parentId + })); + return { layers, groups: [parentGroup, ...groups] }; }; diff --git a/web/client/utils/__tests__/GeoNodeUtils-test.js b/web/client/utils/__tests__/GeoNodeUtils-test.js index 0665a2d6ef9..2cbffb6e276 100644 --- a/web/client/utils/__tests__/GeoNodeUtils-test.js +++ b/web/client/utils/__tests__/GeoNodeUtils-test.js @@ -9,7 +9,7 @@ import expect from 'expect'; import { setSupportedLocales, getSupportedLocales } from '../LocaleUtils'; -import { resourceToLayerConfig, getDimensions, resolveApiPresetParams, mergePresetParams } from '../GeoNodeUtils'; +import { resourceToLayerConfig, getDimensions, resolveApiPresetParams, mergePresetParams, documentsToLayerConfig } from '../GeoNodeUtils'; describe('GeoNodeUtils', () => { describe('resourceToLayerConfig', () => { @@ -277,4 +277,33 @@ describe('GeoNodeUtils', () => { expect(merged.exclude).toContain('*'); }); }); + + describe('GeoNode catalog documents, maps', () => { + + it('documentsToLayerConfig builds a single vector layer of point features', () => { + const layer = documentsToLayerConfig([ + { pk: 10, title: 'Doc 10', subtype: 'image', detail_url: '/documents/10', extent: { coords: [0, 0, 10, 10] } }, + { pk: 11, title: 'Doc 11', subtype: 'document', detail_url: '/documents/11' } + ]); + expect(layer.type).toBe('vector'); + expect(layer.name).toBe('Documents'); + expect(layer.rowViewer).toBe('GEONODE_DOCUMENTS_ROW_VIEWER'); + // doc 11 has no extent -> skipped + expect(layer.features.length).toBe(1); + expect(layer.features[0].id).toBe(10); + expect(layer.features[0].geometry.type).toBe('Point'); + expect(layer.features[0].geometry.coordinates).toEqual([5, 5]); + expect(layer.bbox).toExist(); + }); + + it('documentsToLayerConfig skips documents whose fetch fails', () => { + const layer = documentsToLayerConfig([ + { pk: 10, title: 'Doc 10', subtype: 'image', extent: { coords: [0, 0, 10, 10] } }, + null + ]); + expect(layer.features.length).toBe(1); + expect(layer.features[0].id).toBe(10); + }); + }); + });