diff --git a/docs/guide/custom-app.md b/docs/guide/custom-app.md index d2d3bb5bc4..ff5b4a02c6 100644 --- a/docs/guide/custom-app.md +++ b/docs/guide/custom-app.md @@ -118,7 +118,6 @@ the following settings to the `angular.json` file at the root of your project: "@rgrove/parse-xml", "@messageformat/core", "rbush", - "@camptocamp/ogc-client", "pbf", "alasql" // add dependencies here if other warnings show up and you want to hide them diff --git a/jest.preset.js b/jest.preset.js index 6fa1ea73a1..e7f46ce016 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -5,7 +5,7 @@ module.exports = { coverageReporters: ['text'], setupFiles: ['jest-canvas-mock'], transformIgnorePatterns: [ - 'node_modules/(?!(color-*|ol|@mapbox|@geospatial-sdk|.*.mjs$))', + 'node_modules/(?!(color-*|ol|@mapbox|@geospatial-sdk|@camptocamp/ogc-client|.*.mjs$))', ], transform: { '^.+\\.(ts|mjs|js|html)$': [ diff --git a/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.ts b/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.ts index 278dca0fc2..837da4f23b 100644 --- a/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.ts +++ b/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.ts @@ -59,7 +59,11 @@ export class AddLayerRecordPreviewComponent extends RecordPreviewComponent { name: link.name, }) } else if (link.accessServiceProtocol === 'wmts') { - return this.mapUtils.getWmtsLayerFromCapabilities(link) + return of({ + url: link.url.toString(), + type: MapContextLayerTypeEnum.WMTS, + name: link.name, + }) } return throwError(() => 'protocol not supported') } diff --git a/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.css b/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.html b/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.html new file mode 100644 index 0000000000..47d5fed454 --- /dev/null +++ b/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.html @@ -0,0 +1,36 @@ +
+ + +
+ +
+ {{ errorMessage }} +
+ +
+

map.loading.service

+
+ +
+

map.layers.available

+ +
+

+ {{ layer }} +

+ map.layer.add +
+
+
diff --git a/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.spec.ts b/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.spec.ts new file mode 100644 index 0000000000..ab58f79785 --- /dev/null +++ b/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { AddLayerFromOgcApiComponent } from './add-layer-from-ogc-api.component' +import { MapFacade } from '../+state/map.facade' +import { TranslateModule } from '@ngx-translate/core' +import { NO_ERRORS_SCHEMA } from '@angular/core' +import { MapContextLayerTypeEnum } from '../map-context/map-context.model' + +jest.mock('@camptocamp/ogc-client', () => ({ + OgcApiEndpoint: class { + constructor(private url) {} + isReady() { + if (this.url.indexOf('error') > -1) { + return Promise.reject(new Error('Something went wrong')) + } + if (this.url.indexOf('wait') > -1) { + return new Promise(() => { + // do nothing + }) + } + return Promise.resolve(this) + } + get featureCollections() { + if (this.url.includes('error')) { + return Promise.reject(new Error('Simulated loading error')) + } + return Promise.resolve(['layer1', 'layer2', 'layer3']) + } + getCollectionItemsUrl(collectionId) { + return Promise.resolve( + `http://example.com/collections/${collectionId}/items` + ) + } + }, +})) + +describe('AddLayerFromOgcApiComponent', () => { + let component: AddLayerFromOgcApiComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), AddLayerFromOgcApiComponent], + declarations: [], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents() + + fixture = TestBed.createComponent(AddLayerFromOgcApiComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + expect(component.errorMessage).toBeFalsy() + expect(component.loading).toBe(false) + expect(component.layers.length).toBe(0) + }) + + describe('loadLayers', () => { + it('should clear layers if OGC URL is empty', async () => { + component.ogcUrl = '' + await component.loadLayers() + expect(component.layers.length).toBe(0) + }) + + it('should load layers on valid OGC API service', async () => { + component.ogcUrl = 'http://example.com/ogc' + await component.loadLayers() + expect(component.errorMessage).toBeFalsy() + expect(component.loading).toBe(false) + expect(component.layers).toEqual(['layer1', 'layer2', 'layer3']) + }) + + it('should handle errors while loading layers', async () => { + component.ogcUrl = 'http://example.com/error' + await component.loadLayers() + expect(component.errorMessage).toContain('Error loading layers:') + expect(component.loading).toBe(false) + expect(component.layers.length).toBe(0) + }) + }) +}) diff --git a/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.ts b/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.ts new file mode 100644 index 0000000000..6010111cd3 --- /dev/null +++ b/libs/feature/map/src/lib/add-layer-from-ogc-api/add-layer-from-ogc-api.component.ts @@ -0,0 +1,80 @@ +import { + Component, + OnInit, + Output, + EventEmitter, + ChangeDetectionStrategy, + Input, + ChangeDetectorRef, +} from '@angular/core' +import { OgcApiEndpoint } from '@camptocamp/ogc-client' +import { Subject, debounceTime } from 'rxjs' +import { + MapContextLayerModel, + MapContextLayerTypeEnum, +} from '../map-context/map-context.model' +import { TranslateModule } from '@ngx-translate/core' +import { UiInputsModule } from '@geonetwork-ui/ui/inputs' +import { CommonModule } from '@angular/common' +import { MapLayer } from '../+state/map.models' + +@Component({ + selector: 'gn-ui-add-layer-from-ogc-api', + templateUrl: './add-layer-from-ogc-api.component.html', + styleUrls: ['./add-layer-from-ogc-api.component.css'], + standalone: true, + imports: [CommonModule, TranslateModule, UiInputsModule], +}) +export class AddLayerFromOgcApiComponent implements OnInit { + @Input() ogcUrl: string + @Output() layerAdded = new EventEmitter() + + urlChange = new Subject() + layerUrl = '' + loading = false + layers: string[] = [] + ogcEndpoint: OgcApiEndpoint = null + errorMessage: string | null = null + + constructor(private changeDetectorRef: ChangeDetectorRef) {} + + ngOnInit() { + this.urlChange.pipe(debounceTime(700)).subscribe(() => { + this.loadLayers() + this.changeDetectorRef.detectChanges() // manually trigger change detection + }) + } + + async loadLayers() { + this.errorMessage = null + try { + this.loading = true + if (this.ogcUrl.trim() === '') { + this.layers = [] + return + } + this.ogcEndpoint = await new OgcApiEndpoint(this.ogcUrl) + + // Currently only supports feature collections + this.layers = await this.ogcEndpoint.featureCollections + } catch (error) { + const err = error as Error + this.layers = [] + this.errorMessage = 'Error loading layers: ' + err.message + } finally { + this.loading = false + this.changeDetectorRef.markForCheck() + } + } + + async addLayer(layer: string) { + this.layerUrl = await this.ogcEndpoint.getCollectionItemsUrl(layer) + + const layerToAdd: MapContextLayerModel = { + name: layer, + url: this.layerUrl, + type: MapContextLayerTypeEnum.OGCAPI, + } + this.layerAdded.emit({ ...layerToAdd, title: layer }) + } +} diff --git a/libs/feature/map/src/lib/feature-map.module.ts b/libs/feature/map/src/lib/feature-map.module.ts index 8365e02f8f..f2db017ee2 100644 --- a/libs/feature/map/src/lib/feature-map.module.ts +++ b/libs/feature/map/src/lib/feature-map.module.ts @@ -25,6 +25,7 @@ import { AddLayerFromFileComponent } from './add-layer-from-file/add-layer-from- import { AddLayerFromWfsComponent } from './add-layer-from-wfs/add-layer-from-wfs.component' import { GeocodingComponent } from './geocoding/geocoding.component' import { GEOCODING_PROVIDER, GeocodingProvider } from './geocoding.service' +import { AddLayerFromOgcApiComponent } from './add-layer-from-ogc-api/add-layer-from-ogc-api.component' @NgModule({ declarations: [ @@ -59,6 +60,7 @@ import { GEOCODING_PROVIDER, GeocodingProvider } from './geocoding.service' EffectsModule.forFeature([MapEffects]), UiElementsModule, UiInputsModule, + AddLayerFromOgcApiComponent, ], providers: [ { diff --git a/libs/feature/map/src/lib/layers-panel/layers-panel.component.html b/libs/feature/map/src/lib/layers-panel/layers-panel.component.html index 199dcaa606..ce5d06cdfb 100644 --- a/libs/feature/map/src/lib/layers-panel/layers-panel.component.html +++ b/libs/feature/map/src/lib/layers-panel/layers-panel.component.html @@ -40,6 +40,14 @@ + +
+ +
+
diff --git a/libs/feature/map/src/lib/layers-panel/layers-panel.component.ts b/libs/feature/map/src/lib/layers-panel/layers-panel.component.ts index 389f8561d0..f0cb32051d 100644 --- a/libs/feature/map/src/lib/layers-panel/layers-panel.component.ts +++ b/libs/feature/map/src/lib/layers-panel/layers-panel.component.ts @@ -9,9 +9,14 @@ import { MapFacade } from '../+state/map.facade' }) export class LayersPanelComponent { layers$ = this.mapFacade.layers$ + ogcUrl = '' constructor(private mapFacade: MapFacade) {} deleteLayer(index: number) { this.mapFacade.removeLayer(index) } + + addLayer(layer) { + this.mapFacade.addLayer(layer) + } } diff --git a/libs/feature/map/src/lib/map-context/map-context.model.ts b/libs/feature/map/src/lib/map-context/map-context.model.ts index e005842a20..12f07631ee 100644 --- a/libs/feature/map/src/lib/map-context/map-context.model.ts +++ b/libs/feature/map/src/lib/map-context/map-context.model.ts @@ -1,7 +1,6 @@ import type { FeatureCollection } from 'geojson' import { Coordinate } from 'ol/coordinate' import type { Extent } from 'ol/extent' -import { Options } from 'ol/source/WMTS' export enum MapContextLayerTypeEnum { XYZ = 'xyz', @@ -9,6 +8,7 @@ export enum MapContextLayerTypeEnum { WMTS = 'wmts', WFS = 'wfs', GEOJSON = 'geojson', + OGCAPI = 'ogcapi', } export interface MapContextModel { @@ -24,8 +24,8 @@ export interface MapContextLayerWmsModel { export interface MapContextLayerWmtsModel { type: 'wmts' - options: Options - extent?: Extent + url: string + name: string } interface MapContextLayerWfsModel { @@ -34,6 +34,12 @@ interface MapContextLayerWfsModel { name: string } +export interface MapContextLayerOgcapiModel { + type: 'ogcapi' + url: string + name: string +} + interface LayerXyzModel { type: 'xyz' name?: string @@ -71,6 +77,7 @@ export type MapContextLayerModel = | MapContextLayerWfsModel | MapContextLayerXyzModel | MapContextLayerGeojsonModel + | MapContextLayerOgcapiModel export interface MapContextViewModel { center?: Coordinate // expressed in long/lat (EPSG:4326) diff --git a/libs/feature/map/src/lib/map-context/map-context.service.spec.ts b/libs/feature/map/src/lib/map-context/map-context.service.spec.ts index f522e93b7a..9de1279924 100644 --- a/libs/feature/map/src/lib/map-context/map-context.service.spec.ts +++ b/libs/feature/map/src/lib/map-context/map-context.service.spec.ts @@ -41,6 +41,48 @@ const mapStyleServiceMock = { defaultHL: DEFAULT_STYLE_HL_FIXTURE, }, } + +jest.mock('@camptocamp/ogc-client', () => ({ + WmtsEndpoint: class { + constructor(private url) {} + isReady() { + return Promise.resolve({ + getLayerByName: (name) => { + if (this.url.indexOf('error') > -1) { + throw new Error('Something went wrong') + } + return { + name, + latLonBoundingBox: [1.33, 48.81, 4.3, 51.1], + } + }, + }) + } + }, + WfsEndpoint: class { + constructor(private url) {} + isReady() { + return Promise.resolve({ + getLayerByName: (name) => { + if (this.url.indexOf('error') > -1) { + throw new Error('Something went wrong') + } + return { + name, + latLonBoundingBox: [1.33, 48.81, 4.3, 51.1], + } + }, + getSingleFeatureTypeName: () => { + return 'ms:commune_actuelle_3857' + }, + getFeatureUrl: () => { + return 'https://www.geograndest.fr/geoserver/region-grand-est/ows?service=WFS&version=1.1.0&request=GetFeature&outputFormat=application%2Fjson&typename=ms%3Acommune_actuelle_3857&srsname=EPSG%3A3857&bbox=10%2C20%2C30%2C40%2CEPSG%3A3857&maxFeatures=10000' + }, + }) + } + }, +})) + describe('MapContextService', () => { let service: MapContextService @@ -110,7 +152,7 @@ describe('MapContextService', () => { const urls = source.getUrls() expect(urls.length).toBe(1) expect(urls[0]).toBe( - 'https://www.geograndest.fr/geoserver/region-grand-est/ows' + 'https://www.geograndest.fr/geoserver/region-grand-est/ows?REQUEST=GetCapabilities&SERVICE=WMS' ) }) it('set WMS gutter of 20px', () => { @@ -322,7 +364,7 @@ describe('MapContextService', () => { const layerWMSUrl = (map.getLayers().item(1) as TileLayer) .getSource() .getUrls()[0] - expect(layerWMSUrl).toEqual('https://some-wms-server/') + expect(layerWMSUrl).toEqual('https://some-wms-server') }) it('add one WFS layer from config on top of baselayer', () => { const layerWFSSource = ( diff --git a/libs/feature/map/src/lib/map-context/map-context.service.ts b/libs/feature/map/src/lib/map-context/map-context.service.ts index 6b2d53fc94..5e723badbe 100644 --- a/libs/feature/map/src/lib/map-context/map-context.service.ts +++ b/libs/feature/map/src/lib/map-context/map-context.service.ts @@ -22,9 +22,9 @@ import { LayerConfig, MapConfig } from '@geonetwork-ui/util/app-config' import { FeatureCollection } from 'geojson' import { fromLonLat } from 'ol/proj' import WMTS from 'ol/source/WMTS' -import { removeSearchParams } from '@geonetwork-ui/util/shared' import { Geometry } from 'ol/geom' import Feature from 'ol/Feature' +import { WfsEndpoint, WmtsEndpoint } from '@camptocamp/ogc-client' export const DEFAULT_BASELAYER_CONTEXT: MapContextLayerXyzModel = { type: MapContextLayerTypeEnum.XYZ, @@ -77,6 +77,15 @@ export class MapContextService { const { type } = layerModel const style = this.styleService.styles.default switch (type) { + case MapContextLayerTypeEnum.OGCAPI: + return new VectorLayer({ + source: new VectorSource({ + format: new GeoJSON(), + url: layerModel.url, + }), + style, + }) + case MapContextLayerTypeEnum.XYZ: return new TileLayer({ source: new XYZ({ @@ -87,44 +96,63 @@ export class MapContextService { case MapContextLayerTypeEnum.WMS: return new TileLayer({ source: new TileWMS({ - url: removeSearchParams(layerModel.url, ['request', 'service']), + url: layerModel.url, params: { LAYERS: layerModel.name }, gutter: 20, }), }) - case MapContextLayerTypeEnum.WMTS: - return new TileLayer({ - source: new WMTS(layerModel.options), + case MapContextLayerTypeEnum.WMTS: { + // TODO: isolate this in utils service + const olLayer = new TileLayer({}) + const endpoint = new WmtsEndpoint(layerModel.url) + endpoint.isReady().then(async (endpoint) => { + const layerName = endpoint.getSingleLayerName() ?? layerModel.name + const layer = endpoint.getLayerByName(layerName) + const matrixSet = layer.matrixSets[0] + const tileGrid = await endpoint.getOpenLayersTileGrid(layer.name) + const resourceUrl = layer.resourceLinks[0] + const dimensions = endpoint.getDefaultDimensions(layer.name) + olLayer.setSource( + new WMTS({ + layer: layer.name, + style: layer.defaultStyle, + matrixSet: matrixSet.identifier, + format: resourceUrl.format, + url: resourceUrl.url, + requestEncoding: resourceUrl.encoding, + tileGrid, + projection: matrixSet.crs, + dimensions, + }) + ) }) - case MapContextLayerTypeEnum.WFS: - return new VectorLayer({ - source: new VectorSource({ - format: new GeoJSON(), - url: function (extent) { - const urlObj = new URL( - removeSearchParams(layerModel.url, [ - 'service', - 'version', - 'request', - ]) - ) - urlObj.searchParams.set('service', 'WFS') - urlObj.searchParams.set('version', '1.1.0') - urlObj.searchParams.set('request', 'GetFeature') - urlObj.searchParams.set('outputFormat', 'application/json') - urlObj.searchParams.set('typename', layerModel.name) - urlObj.searchParams.set('srsname', 'EPSG:3857') - urlObj.searchParams.set('bbox', `${extent.join(',')},EPSG:3857`) - urlObj.searchParams.set( - 'maxFeatures', - WFS_MAX_FEATURES.toString() - ) - return urlObj.toString() - }, - strategy: bboxStrategy, - }), + return olLayer + } + case MapContextLayerTypeEnum.WFS: { + const olLayer = new VectorLayer({ style, }) + new WfsEndpoint(layerModel.url).isReady().then((endpoint) => { + const featureType = + endpoint.getSingleFeatureTypeName() ?? layerModel.name + olLayer.setSource( + new VectorSource({ + format: new GeoJSON(), + url: function (extent: [number, number, number, number]) { + return endpoint.getFeatureUrl(featureType, { + maxFeatures: WFS_MAX_FEATURES, + asJson: true, + outputCrs: 'EPSG:3857', + extent, + extentCrs: 'EPSG:3857', + }) + }, + strategy: bboxStrategy, + }) + ) + }) + return olLayer + } case MapContextLayerTypeEnum.GEOJSON: { if ('url' in layerModel) { return new VectorLayer({ diff --git a/libs/feature/map/src/lib/utils/map-utils.service.spec.ts b/libs/feature/map/src/lib/utils/map-utils.service.spec.ts index 72419148ae..50afc5f787 100644 --- a/libs/feature/map/src/lib/utils/map-utils.service.spec.ts +++ b/libs/feature/map/src/lib/utils/map-utils.service.spec.ts @@ -46,17 +46,17 @@ jest.mock('@camptocamp/ogc-client', () => ({ boundingBoxes = {} } else if (name.includes('4326')) { boundingBoxes = { - 'EPSG:4326': ['1', '2.6', '3.3', '4.2'], - 'CRS:84': ['2.3', '50.6', '2.8', '50.9'], + 'EPSG:4326': [1, 2.6, 3.3, 4.2], + 'CRS:84': [2.3, 50.6, 2.8, 50.9], } } else if (name.includes('2154')) { boundingBoxes = { - 'EPSG:2154': ['650796.4', '7060330.6', '690891.3', '7090402.2'], + 'EPSG:2154': [650796.4, 7060330.6, 690891.3, 7090402.2], } } else { boundingBoxes = { - 'CRS:84': ['2.3', '50.6', '2.8', '50.9'], - 'EPSG:2154': ['650796.4', '7060330.6', '690891.3', '7090402.2'], + 'CRS:84': [2.3, 50.6, 2.8, 50.9], + 'EPSG:2154': [650796.4, 7060330.6, 690891.3, 7090402.2], } } return { @@ -406,317 +406,4 @@ describe('MapUtilsService', () => { expect(dragPanCondition.bind(interaction)(event)).toBe(false) }) }) - - const SAMPLE_WMTS_LINK = { - name: 'GEOGRAPHICALGRIDSYSTEMS.ETATMAJOR10', - url: new URL('http://my.server.org/wmts'), - type: 'service', - accessServiceProtocol: 'wmts', - } as DatasetServiceDistribution - const SAMPLE_WMTS_CAPABILITIES = ` - - - - - - - - - KVP - - - - - - - - - - Carte de l'état-major - environs de Paris (1818 - 1824) - Carte des environs de Paris au 1 : 10 000 établie entre 1818 et 1824. - - 1.82682 48.3847 - 2.79738 49.5142 - - GEOGRAPHICALGRIDSYSTEMS.ETATMAJOR10 - image/jpeg - - - PM - - - 6 - 21 - 22 - 32 - 32 - - - 7 - 43 - 44 - 64 - 64 - - - 8 - 87 - 88 - 129 - 129 - - - - - - PM - EPSG:3857 - - 0 - 559082264.0287178958533332 - - -20037508.3427892476320267 20037508.3427892476320267 - - 256 - 256 - 1 - 1 - - - 1 - 279541132.0143588959472254 - - -20037508.3427892476320267 20037508.3427892476320267 - - 256 - 256 - 2 - 2 - - - 2 - 139770566.0071793960087234 - - -20037508.3427892476320267 20037508.3427892476320267 - - 256 - 256 - 4 - 4 - - - 3 - 69885283.0035897239868063 - - -20037508.3427892476320267 20037508.3427892476320267 - - 256 - 256 - 8 - 8 - - - -` - - describe('#getWmtsOptionsFromCapabilities', () => { - let originalFetch - beforeEach(() => { - originalFetch = window.fetch - }) - afterEach(() => { - window.fetch = originalFetch - }) - describe('nominal', () => { - let wmtsLayer: MapContextLayerWmtsModel - beforeEach(async () => { - ;(window as any).fetch = jest.fn(() => - Promise.resolve({ - ok: true, - status: 200, - text: () => Promise.resolve(SAMPLE_WMTS_CAPABILITIES), - }) - ) - wmtsLayer = await firstValueFrom( - service.getWmtsLayerFromCapabilities(SAMPLE_WMTS_LINK) - ) - }) - it('appends query params to the URL', () => { - expect(window.fetch).toHaveBeenCalledWith( - 'http://my.server.org/wmts?SERVICE=WMTS&REQUEST=GetCapabilities' - ) - }) - it('returns appropriate WMTS options', () => { - expect(wmtsLayer).toMatchObject({ - type: 'wmts', - options: { - format: 'image/jpeg', - layer: 'GEOGRAPHICALGRIDSYSTEMS.ETATMAJOR10', - matrixSet: 'PM', - requestEncoding: 'KVP', - style: 'normal', - urls: ['https://wxs.ign.fr/cartes/geoportail/wmts?'], - }, - }) - }) - describe('layer extent', () => { - describe('when the WGS84BoundingBox is defined', () => { - it('set the WGS84BoundingBox', () => { - expect(wmtsLayer.extent).toEqual([ - 1.82682, 48.3847, 2.79738, 49.5142, - ]) - }) - }) - describe('when the WGS84BoundingBox is not defined', () => { - beforeEach(async () => { - ;(window as any).fetch = jest.fn(() => - Promise.resolve({ - ok: true, - status: 200, - text: () => - Promise.resolve( - SAMPLE_WMTS_CAPABILITIES.replace( - /WGS84BoundingBox/g, - 'NoWGS84BoundingBox' - ) - ), - }) - ) - wmtsLayer = await firstValueFrom( - service.getWmtsLayerFromCapabilities(SAMPLE_WMTS_LINK) - ) - }) - - it('set the WGS84BoundingBox', () => { - expect(wmtsLayer.extent).toBeUndefined() - }) - }) - }) - }) - describe('http error', () => { - let error - beforeEach(async () => { - ;(window as any).fetch = jest.fn(() => - Promise.resolve({ - ok: false, - status: 403, - text: () => ` - - Le service est inconnu pour ce serveur. - -`, - }) - ) - try { - await firstValueFrom( - service.getWmtsLayerFromCapabilities(SAMPLE_WMTS_LINK) - ) - } catch (e) { - error = e - } - }) - it('throws an explicit error', () => { - expect(error).toBeInstanceOf(Error) - expect(error.message).toMatch('request failed') - }) - }) - describe('parsing error', () => { - let error - beforeEach(async () => { - ;(window as any).fetch = jest.fn(() => - Promise.resolve({ - ok: true, - status: 200, - text: () => - Promise.resolve( - '{ "response": "This is probably not what you expected!" }' - ), - }) - ) - try { - await firstValueFrom( - service.getWmtsLayerFromCapabilities(SAMPLE_WMTS_LINK) - ) - } catch (e) { - error = e - } - }) - it('throws an explicit error', () => { - expect(error).toBeInstanceOf(Error) - expect(error.message).toMatch('parsing failed') - }) - }) - }) - - describe('#getRecordExtent', () => { - it('returns the extent of the record', () => { - const record = { - spatialExtents: [ - { - description: 'Rheinfelden', - geometry: { - type: 'Polygon', - coordinates: [ - [ - [7.7638, 47.543], - [7.7637, 47.543], - [7.7636, 47.543], - [7.7635, 47.543], - [7.7633, 47.5429], - [7.763, 47.5429], - [7.7638, 47.543], - ], - ], - }, - }, - { - description: 'Kaiseraugst', - geometry: { - type: 'Polygon', - coordinates: [ - [ - [7.764, 47.5429], - [7.7641, 47.5423], - [7.7643, 47.5421], - [7.7645, 47.5415], - [7.7646, 47.5411], - [7.7646, 47.5405], - [7.7645, 47.5398], - [7.7634, 47.5402], - [7.7621, 47.5401], - [7.7623, 47.5396], - [7.764, 47.5429], - ], - ], - }, - }, - { - description: 'Möhlin', - geometry: { - type: 'Polygon', - coordinates: [ - [ - [7.8335, 47.5357], - [7.8319, 47.5358], - [7.831, 47.536], - [7.8301, 47.5363], - [7.829, 47.5364], - [7.8335, 47.5357], - ], - ], - }, - }, - ], - } as Partial - const extent = service.getRecordExtent(record) - expect(extent).toEqual([7.7621, 47.5357, 7.8335, 47.543]) - }) - }) }) diff --git a/libs/feature/map/src/lib/utils/map-utils.service.ts b/libs/feature/map/src/lib/utils/map-utils.service.ts index 743d90d445..6e388204ea 100644 --- a/libs/feature/map/src/lib/utils/map-utils.service.ts +++ b/libs/feature/map/src/lib/utils/map-utils.service.ts @@ -12,7 +12,6 @@ import Source from 'ol/source/Source' import ImageWMS from 'ol/source/ImageWMS' import TileWMS from 'ol/source/TileWMS' import VectorSource from 'ol/source/Vector' -import { optionsFromCapabilities } from 'ol/source/WMTS' import { defaults, DragPan, Interaction, MouseWheelZoom } from 'ol/interaction' import { mouseOnly, @@ -20,23 +19,17 @@ import { platformModifierKeyOnly, primaryAction, } from 'ol/events/condition' -import WMTSCapabilities from 'ol/format/WMTSCapabilities' -import { from, Observable } from 'rxjs' +import { Observable } from 'rxjs' import { map } from 'rxjs/operators' import { MapContextLayerModel, - MapContextLayerTypeEnum, MapContextLayerWmsModel, - MapContextLayerWmtsModel, } from '../map-context/map-context.model' import Collection from 'ol/Collection' import MapBrowserEvent from 'ol/MapBrowserEvent' -import { - CatalogRecord, - DatasetDistribution, -} from '@geonetwork-ui/common/domain/model/record' +import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { ProxyService } from '@geonetwork-ui/util/shared' -import { WmsEndpoint } from '@camptocamp/ogc-client' +import { WmsEndpoint, WmtsEndpoint } from '@camptocamp/ogc-client' import { LONLAT_CRS_CODES } from '../constant/projections' import { fromEPSGCode, register } from 'ol/proj/proj4' import proj4 from 'proj4/dist/proj4' @@ -152,11 +145,14 @@ export class MapUtilsService { } else if (layer && layer.type === 'wms') { latLonExtent = await this.getWmsLayerExtent(layer) } else if (layer && layer.type === 'wmts') { - if (layer.extent) { - latLonExtent = layer.extent - } else { - return layer.options.tileGrid.getExtent() - } + // TODO: isolate this in utils service + latLonExtent = await new WmtsEndpoint(layer.url) + .isReady() + .then((endpoint) => { + const layerName = endpoint.getSingleLayerName() ?? layer.name + const wmtsLayer = endpoint.getLayerByName(layerName) + return wmtsLayer.latLonBoundingBox + }) } else { return null } @@ -180,59 +176,19 @@ export class MapUtilsService { LONLAT_CRS_CODES.includes(crs) ) if (lonLatCRS) { - return boundingBoxes[lonLatCRS].map(parseFloat) + return boundingBoxes[lonLatCRS] } else { const availableEPSGCode = Object.keys(boundingBoxes)[0] register(proj4) const proj = await fromEPSGCode(availableEPSGCode) - const bboxWithFiniteNumbers = - boundingBoxes[availableEPSGCode].map(parseFloat) - return transformExtent(bboxWithFiniteNumbers, proj, 'EPSG:4326') + return transformExtent( + boundingBoxes[availableEPSGCode], + proj, + 'EPSG:4326' + ) } } - getWmtsLayerFromCapabilities( - link: DatasetDistribution - ): Observable { - const getCapabilitiesUrl = new URL(link.url, window.location.toString()) - getCapabilitiesUrl.searchParams.set('SERVICE', 'WMTS') - getCapabilitiesUrl.searchParams.set('REQUEST', 'GetCapabilities') - return from( - fetch(getCapabilitiesUrl.toString()) - .then(async function (response) { - if (!response.ok) { - throw new Error(`WMTS GetCapabilities HTTP request failed with code ${ - response.status - } and body: -${await response.text()}`) - } - return response.text() - }) - .then(function (text) { - try { - const result = new WMTSCapabilities().read(text) - const options = optionsFromCapabilities(result, { - layer: link.name, - matrixSet: 'EPSG:3857', - }) - const layerCap = result?.Contents?.Layer.find( - (layer) => layer.Identifier === link.name - ) - return { - options, - type: MapContextLayerTypeEnum.WMTS as 'wmts', - ...(layerCap?.WGS84BoundingBox - ? { extent: layerCap.WGS84BoundingBox } - : {}), - } - } catch (e: any) { - throw new Error(`WMTS GetCapabilities parsing failed: -${e.stack || e.message || e}`) - } - }) - ) - } - prioritizePageScroll(interactions: Collection) { interactions.clear() interactions.extend( diff --git a/libs/feature/record/src/lib/map-view/map-view.component.spec.ts b/libs/feature/record/src/lib/map-view/map-view.component.spec.ts index 89288877dc..90167937f7 100644 --- a/libs/feature/record/src/lib/map-view/map-view.component.spec.ts +++ b/libs/feature/record/src/lib/map-view/map-view.component.spec.ts @@ -451,8 +451,9 @@ describe('MapViewComponent', () => { expect(mapComponent.context).toEqual({ layers: [ { + name: 'orthophoto', type: 'wmts', - options: expect.any(Object), + url: 'http://abcd.com/wmts', }, ], view: expect.any(Object), diff --git a/libs/feature/record/src/lib/map-view/map-view.component.ts b/libs/feature/record/src/lib/map-view/map-view.component.ts index 4bd4df379f..5cc2a7f295 100644 --- a/libs/feature/record/src/lib/map-view/map-view.component.ts +++ b/libs/feature/record/src/lib/map-view/map-view.component.ts @@ -188,7 +188,11 @@ export class MapViewComponent implements OnInit, OnDestroy { link.type === 'service' && link.accessServiceProtocol === 'wmts' ) { - return this.mapUtils.getWmtsLayerFromCapabilities(link) + return of({ + url: link.url.toString(), + type: MapContextLayerTypeEnum.WMTS, + name: link.name, + }) } else if ( (link.type === 'service' && (link.accessServiceProtocol === 'wfs' || diff --git a/libs/util/data-fetcher/src/lib/readers/csv.spec.ts b/libs/util/data-fetcher/src/lib/readers/csv.spec.ts index d182b0bb07..c8f74272f1 100644 --- a/libs/util/data-fetcher/src/lib/readers/csv.spec.ts +++ b/libs/util/data-fetcher/src/lib/readers/csv.spec.ts @@ -7,6 +7,14 @@ afterEach(() => { jest.clearAllMocks() }) +//todo: fix this test, to run without mocking useCache +jest.mock('@camptocamp/ogc-client', () => ({ + useCache: jest.fn(async (factory) => + JSON.parse(JSON.stringify(await factory())) + ), + sharedFetch: jest.fn((url) => global.fetch(url)), +})) + describe('CSV parsing', () => { describe('parseCsv', () => { describe('valid CSV with id', () => { diff --git a/libs/util/data-fetcher/src/lib/readers/excel.spec.ts b/libs/util/data-fetcher/src/lib/readers/excel.spec.ts index f2649022ae..b2830bdd56 100644 --- a/libs/util/data-fetcher/src/lib/readers/excel.spec.ts +++ b/libs/util/data-fetcher/src/lib/readers/excel.spec.ts @@ -12,6 +12,14 @@ const sampleXls = fs.readFileSync( null ) +//todo: fix this test, to run without mocking useCache +jest.mock('@camptocamp/ogc-client', () => ({ + useCache: jest.fn(async (factory) => + JSON.parse(JSON.stringify(await factory())) + ), + sharedFetch: jest.fn((url) => global.fetch(url)), +})) + describe('Excel parsing', () => { describe('parseExcel', () => { describe('.xslx file', () => { diff --git a/libs/util/data-fetcher/src/lib/readers/geojson.spec.ts b/libs/util/data-fetcher/src/lib/readers/geojson.spec.ts index 4122dd1b9b..63287e7be7 100644 --- a/libs/util/data-fetcher/src/lib/readers/geojson.spec.ts +++ b/libs/util/data-fetcher/src/lib/readers/geojson.spec.ts @@ -3,6 +3,14 @@ import fetchMock from 'fetch-mock-jest' import path from 'path' import fs from 'fs/promises' +//todo: fix this test, to run without mocking useCache +jest.mock('@camptocamp/ogc-client', () => ({ + useCache: jest.fn(async (factory) => + JSON.parse(JSON.stringify(await factory())) + ), + sharedFetch: jest.fn((url) => global.fetch(url)), +})) + describe('geojson parsing', () => { describe('parseGeojson', () => { describe('Valid Geojson (array of features)', () => { diff --git a/libs/util/data-fetcher/src/lib/readers/gml.spec.ts b/libs/util/data-fetcher/src/lib/readers/gml.spec.ts index e8d0f09948..ed5076c44e 100644 --- a/libs/util/data-fetcher/src/lib/readers/gml.spec.ts +++ b/libs/util/data-fetcher/src/lib/readers/gml.spec.ts @@ -6,6 +6,14 @@ import fetchMock from 'fetch-mock-jest' import path from 'path' import fs from 'fs/promises' +//todo: fix this test, to run without mocking useCache +jest.mock('@camptocamp/ogc-client', () => ({ + useCache: jest.fn(async (factory) => + JSON.parse(JSON.stringify(await factory())) + ), + sharedFetch: jest.fn((url) => global.fetch(url)), +})) + const singleFeatureValidGml = ` ({ + useCache: jest.fn(async (factory) => + JSON.parse(JSON.stringify(await factory())) + ), + sharedFetch: jest.fn((url) => global.fetch(url)), +})) + describe('json parsing', () => { describe('parseJson', () => { describe('valid JSON with id', () => { diff --git a/package-lock.json b/package-lock.json index 9e4aca1a0e..195668fdca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@angular/router": "16.1.7", "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@camptocamp/ogc-client": "^0.4.0", + "@camptocamp/ogc-client": "^1.1.0-RC.3", "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", @@ -3651,11 +3651,31 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/@camptocamp/ogc-client": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-0.4.0.tgz", - "integrity": "sha512-fGLkg4xyZQg4xbZx8E8HqACfZJZ4ijcpBDgwYnDzCAX+AjnAhqUqs/jwtR7i9xgoOLcjAftPsQVZWVcDLufytw==", + "version": "1.1.0-RC.3", + "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.1.0-RC.3.tgz", + "integrity": "sha512-XZJwp0vxTQGtJD3t4GdTHJDLTidlPmv0sBvXskEt0A0cmrdaGUgBqr8KPeDfhjZfq99WFcXv/Gb3+hQXA0+LmQ==", "dependencies": { - "@rgrove/parse-xml": "^4.0.1" + "@rgrove/parse-xml": "^4.1.0" + }, + "peerDependencies": { + "ol": ">5.x", + "proj4": ">2.8" + }, + "peerDependenciesMeta": { + "ol": { + "optional": true + }, + "proj4": { + "optional": true + } + } + }, + "node_modules/@camptocamp/ogc-client/node_modules/@rgrove/parse-xml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@rgrove/parse-xml/-/parse-xml-4.1.0.tgz", + "integrity": "sha512-pBiltENdy8SfI0AeR1e5TRpS9/9Gl0eiOEt6ful2jQfzsgvZYWqsKiBWaOCLdocQuk0wS7KOHI37n0C1pnKqTw==", + "engines": { + "node": ">=14.0.0" } }, "node_modules/@colors/colors": { diff --git a/package.json b/package.json index d516bf2623..bf37f4ae1b 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@angular/router": "16.1.7", "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@camptocamp/ogc-client": "^0.4.0", + "@camptocamp/ogc-client": "^1.1.0-RC.3", "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", diff --git a/package/package.json b/package/package.json index cf48675b41..40675b1994 100644 --- a/package/package.json +++ b/package/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@camptocamp/ogc-client": "^0.4.0", + "@camptocamp/ogc-client": "^1.1.0-RC.3", "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", diff --git a/translations/de.json b/translations/de.json index f8324d3d2e..a1fbcf03bc 100644 --- a/translations/de.json +++ b/translations/de.json @@ -177,6 +177,7 @@ "map.add.layer": "Eine Ebene hinzufügen", "map.add.layer.catalog": "Aus dem Katalog", "map.add.layer.file": "Aus einer Datei", + "map.add.layer.ogc.api": "", "map.add.layer.wfs": "Aus WFS", "map.add.layer.wms": "Aus WMS", "map.addFromFile.placeholder": "Klicke hier oder ziehe eine Datei herein", @@ -188,6 +189,7 @@ "map.loading.data": "Kartendaten werden geladen...", "map.loading.service": "Dienst wird geladen...", "map.navigation.message": "Bitte verwenden Sie STRG + Maus (oder zwei Finger auf einem Mobilgerät), um die Karte zu navigieren", + "map.ogc.urlInput.hint": "", "map.select.layer": "Datenquelle", "map.wfs.urlInput.hint": "Geben Sie die WFS URL ein", "map.wms.urlInput.hint": "Geben Sie die WMS URL ein", diff --git a/translations/en.json b/translations/en.json index dd45df888c..c43d66ee41 100644 --- a/translations/en.json +++ b/translations/en.json @@ -177,6 +177,7 @@ "map.add.layer": "Add a layer", "map.add.layer.catalog": "From the catalog", "map.add.layer.file": "From a file", + "map.add.layer.ogc.api": "From OGC API", "map.add.layer.wfs": "From WFS", "map.add.layer.wms": "From WMS", "map.addFromFile.placeholder": "Click or drop a file here", @@ -188,6 +189,7 @@ "map.loading.data": "Loading map data...", "map.loading.service": "Loading service...", "map.navigation.message": "Please use CTRL + mouse (or two fingers on mobile) to navigate the map", + "map.ogc.urlInput.hint": "Enter OGC API service URL", "map.select.layer": "Data source", "map.wfs.urlInput.hint": "Enter WFS service URL", "map.wms.urlInput.hint": "Enter WMS service URL", diff --git a/translations/es.json b/translations/es.json index 9f82e0ab23..8552c95c54 100644 --- a/translations/es.json +++ b/translations/es.json @@ -177,6 +177,7 @@ "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", + "map.add.layer.ogc.api": "", "map.add.layer.wfs": "", "map.add.layer.wms": "", "map.addFromFile.placeholder": "", @@ -188,6 +189,7 @@ "map.loading.data": "", "map.loading.service": "", "map.navigation.message": "", + "map.ogc.urlInput.hint": "", "map.select.layer": "", "map.wfs.urlInput.hint": "", "map.wms.urlInput.hint": "", diff --git a/translations/fr.json b/translations/fr.json index 799605a027..a0e7906c03 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -177,6 +177,7 @@ "map.add.layer": "Ajouter une couche", "map.add.layer.catalog": "Du catalogue", "map.add.layer.file": "À partir d'un fichier", + "map.add.layer.ogc.api": "", "map.add.layer.wfs": "Depuis un service WFS", "map.add.layer.wms": "Depuis un service WMS", "map.addFromFile.placeholder": "Cliquez ou déposez un fichier ici", @@ -188,6 +189,7 @@ "map.loading.data": "Chargement des données...", "map.loading.service": "Chargement du service...", "map.navigation.message": "Veuillez utiliser CTRL + souris (ou deux doigts sur mobile) pour naviguer sur la carte", + "map.ogc.urlInput.hint": "", "map.select.layer": "Source de données", "map.wfs.urlInput.hint": "Entrez l'URL du service WFS", "map.wms.urlInput.hint": "Entrez l'URL du service WMS", diff --git a/translations/it.json b/translations/it.json index 9b03fabaca..fd48ddb71a 100644 --- a/translations/it.json +++ b/translations/it.json @@ -177,6 +177,7 @@ "map.add.layer": "Aggiungere un layer", "map.add.layer.catalog": "Dal catalogo", "map.add.layer.file": "Da un file", + "map.add.layer.ogc.api": "", "map.add.layer.wfs": "Da un WFS", "map.add.layer.wms": "Da un WMS", "map.addFromFile.placeholder": "Clicca o trascina un file qui", @@ -188,6 +189,7 @@ "map.loading.data": "Caricamento dati...", "map.loading.service": "Caricamento del servizio...", "map.navigation.message": "Si prega di utilizzare CTRL + mouse (o due dita su mobile) per navigare sulla mappa", + "map.ogc.urlInput.hint": "", "map.select.layer": "Sorgente dati", "map.wfs.urlInput.hint": "Inserisci URL del servizio WFS", "map.wms.urlInput.hint": "Inserisci URL del servizio WMS", diff --git a/translations/nl.json b/translations/nl.json index 64ee837518..449bb5d88e 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -177,6 +177,7 @@ "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", + "map.add.layer.ogc.api": "", "map.add.layer.wfs": "", "map.add.layer.wms": "", "map.addFromFile.placeholder": "", @@ -188,6 +189,7 @@ "map.loading.data": "", "map.loading.service": "", "map.navigation.message": "", + "map.ogc.urlInput.hint": "", "map.select.layer": "", "map.wfs.urlInput.hint": "", "map.wms.urlInput.hint": "", diff --git a/translations/pt.json b/translations/pt.json index 86fe64024b..f0c130712c 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -177,6 +177,7 @@ "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", + "map.add.layer.ogc.api": "", "map.add.layer.wfs": "", "map.add.layer.wms": "", "map.addFromFile.placeholder": "", @@ -188,6 +189,7 @@ "map.loading.data": "", "map.loading.service": "", "map.navigation.message": "", + "map.ogc.urlInput.hint": "", "map.select.layer": "", "map.wfs.urlInput.hint": "", "map.wms.urlInput.hint": "", diff --git a/translations/sk.json b/translations/sk.json index db63a71d01..a5c07120a2 100644 --- a/translations/sk.json +++ b/translations/sk.json @@ -177,6 +177,7 @@ "map.add.layer": "Pridať vrstvu", "map.add.layer.catalog": "Z katalógu", "map.add.layer.file": "Zo súboru", + "map.add.layer.ogc.api": "", "map.add.layer.wfs": "Z WFS", "map.add.layer.wms": "Z WMS", "map.addFromFile.placeholder": "Kliknite na tlačidlo alebo sem vložte súbor", @@ -188,6 +189,7 @@ "map.loading.data": "Načítavanie dát mapy...", "map.loading.service": "Načítavanie služieb...", "map.navigation.message": "Použite prosím CTRL + myš (alebo dva prsty na mobilnom zariadení) na navigáciu po mape", + "map.ogc.urlInput.hint": "", "map.select.layer": "Zdroj dát", "map.wfs.urlInput.hint": "Zadajte URL adresu služby WFS", "map.wms.urlInput.hint": "Zadajte URL adresu služby WMS",