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 index e69de29bb2..ac88d0d289 100644 --- 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 @@ -0,0 +1,7 @@ +.dropdown-content { + display: none; +} + +.relative:hover .dropdown-content { + display: block; +} 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 index 47d5fed454..416bb8288e 100644 --- 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 @@ -4,8 +4,7 @@ (valueChange)="urlChange.next($event)" [hint]="'map.ogc.urlInput.hint' | translate" class="w-96" - > - + >
@@ -16,21 +15,36 @@

map.loading.service

-
-

map.layers.available

- -
-

- {{ layer }} -

- map.layer.add +
+
+

+ {{ layer.name }} +

+
+ + + 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 index ab58f79785..8979e6e2b7 100644 --- 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 @@ -1,6 +1,5 @@ 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' @@ -9,6 +8,9 @@ jest.mock('@camptocamp/ogc-client', () => ({ OgcApiEndpoint: class { constructor(private url) {} isReady() { + if (this.url === 'http://example.com/ogc') { + return Promise.resolve(this) + } if (this.url.indexOf('error') > -1) { return Promise.reject(new Error('Something went wrong')) } @@ -19,17 +21,57 @@ jest.mock('@camptocamp/ogc-client', () => ({ } return Promise.resolve(this) } - get featureCollections() { + get allCollections() { + if (this.url === 'http://example.com/ogc') { + return Promise.resolve([ + { + name: 'NaturalEarth:physical:ne_10m_lakes_pluvial', + hasVectorTiles: true, + hasMapTiles: true, + }, + { + name: 'NaturalEarth:physical:ne_10m_land_ocean_seams', + hasVectorTiles: true, + hasMapTiles: true, + }, + ]) + } if (this.url.includes('error')) { return Promise.reject(new Error('Simulated loading error')) } - return Promise.resolve(['layer1', 'layer2', 'layer3']) + return Promise.resolve([ + { + name: 'NaturalEarth:physical:ne_10m_lakes_pluvial', + hasVectorTiles: true, + hasMapTiles: true, + }, + { + name: 'NaturalEarth:physical:ne_10m_land_ocean_seams', + hasVectorTiles: true, + hasMapTiles: true, + }, + ]) } getCollectionItemsUrl(collectionId) { + if (this.url === 'http://example.com/ogc') { + return Promise.resolve( + `http://example.com/collections/${collectionId}/items` + ) + } return Promise.resolve( `http://example.com/collections/${collectionId}/items` ) } + getVectorTilesetUrl(collectionId) { + return Promise.resolve( + `http://example.com/collections/${collectionId}/tiles/vector` + ) + } + getMapTilesetUrl(collectionId) { + return Promise.resolve( + `http://example.com/collections/${collectionId}/tiles/map` + ) + } }, })) @@ -68,7 +110,18 @@ describe('AddLayerFromOgcApiComponent', () => { await component.loadLayers() expect(component.errorMessage).toBeFalsy() expect(component.loading).toBe(false) - expect(component.layers).toEqual(['layer1', 'layer2', 'layer3']) + expect(component.layers).toEqual([ + { + name: 'NaturalEarth:physical:ne_10m_lakes_pluvial', + hasVectorTiles: true, + hasMapTiles: true, + }, + { + name: 'NaturalEarth:physical:ne_10m_land_ocean_seams', + hasVectorTiles: true, + hasMapTiles: true, + }, + ]) }) it('should handle errors while loading layers', async () => { @@ -79,4 +132,40 @@ describe('AddLayerFromOgcApiComponent', () => { expect(component.layers.length).toBe(0) }) }) + + describe('Add Collection', () => { + it('should add feature type collection to map', async () => { + const layerAddedSpy = jest.spyOn(component.layerAdded, 'emit') + await component.addLayer('layer1', 'features') + expect(layerAddedSpy).toHaveBeenCalledWith({ + name: 'layer1', + url: 'http://example.com/collections/layer1/items', + type: MapContextLayerTypeEnum.OGCAPI, + layerType: 'features', + title: 'layer1', + }) + }) + it('should add vector tile collection to map', async () => { + const layerAddedSpy = jest.spyOn(component.layerAdded, 'emit') + await component.addLayer('layer1', 'vectorTiles') + expect(layerAddedSpy).toHaveBeenCalledWith({ + name: 'layer1', + url: 'http://example.com/collections/layer1/tiles/vector', + type: MapContextLayerTypeEnum.OGCAPI, + layerType: 'vectorTiles', + title: 'layer1', + }) + }) + it('should add map tile collection to map', async () => { + const layerAddedSpy = jest.spyOn(component.layerAdded, 'emit') + await component.addLayer('layer1', 'mapTiles') + expect(layerAddedSpy).toHaveBeenCalledWith({ + name: 'layer1', + url: 'http://example.com/collections/layer1/tiles/map', + type: MapContextLayerTypeEnum.OGCAPI, + layerType: 'mapTiles', + title: 'layer1', + }) + }) + }) }) 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 index 6010111cd3..2ea471ce28 100644 --- 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 @@ -14,7 +14,7 @@ import { MapContextLayerTypeEnum, } from '../map-context/map-context.model' import { TranslateModule } from '@ngx-translate/core' -import { UiInputsModule } from '@geonetwork-ui/ui/inputs' +import { DropdownChoice, UiInputsModule } from '@geonetwork-ui/ui/inputs' import { CommonModule } from '@angular/common' import { MapLayer } from '../+state/map.models' @@ -30,18 +30,16 @@ export class AddLayerFromOgcApiComponent implements OnInit { @Output() layerAdded = new EventEmitter() urlChange = new Subject() - layerUrl = '' loading = false - layers: string[] = [] - ogcEndpoint: OgcApiEndpoint = null + layers: any[] = [] errorMessage: string | null = null + selectedLayerTypes: { [key: string]: DropdownChoice['value'] } = {} constructor(private changeDetectorRef: ChangeDetectorRef) {} ngOnInit() { this.urlChange.pipe(debounceTime(700)).subscribe(() => { this.loadLayers() - this.changeDetectorRef.detectChanges() // manually trigger change detection }) } @@ -49,14 +47,13 @@ export class AddLayerFromOgcApiComponent implements OnInit { this.errorMessage = null try { this.loading = true - if (this.ogcUrl.trim() === '') { + 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 + const ogcEndpoint = await new OgcApiEndpoint(this.ogcUrl) + this.layers = await ogcEndpoint.allCollections + this.setDefaultLayerTypes() } catch (error) { const err = error as Error this.layers = [] @@ -67,14 +64,72 @@ export class AddLayerFromOgcApiComponent implements OnInit { } } - async addLayer(layer: string) { - this.layerUrl = await this.ogcEndpoint.getCollectionItemsUrl(layer) + setDefaultLayerTypes() { + this.layers.forEach((layer) => { + const choices = this.getLayerChoices(layer) + if (choices.length > 0) { + this.selectedLayerTypes[layer.name] = choices[0].value + } + }) + } - const layerToAdd: MapContextLayerModel = { - name: layer, - url: this.layerUrl, - type: MapContextLayerTypeEnum.OGCAPI, + getLayerChoices(layer: any) { + const choices = [] + if (layer.hasRecords) { + choices.push({ label: 'Records', value: 'record' }) + } + if (layer.hasFeatures) { + choices.push({ label: 'Features', value: 'features' }) + } + if (layer.hasVectorTiles) { + choices.push({ label: 'Vector Tiles', value: 'vectorTiles' }) + } + if (layer.hasMapTiles) { + choices.push({ label: 'Map Tiles', value: 'mapTiles' }) + } + return choices + } + + shouldDisplayLayer(layer: any) { + return ( + layer.hasRecords || + layer.hasFeatures || + layer.hasVectorTiles || + layer.hasMapTiles + ) + } + + onLayerTypeSelect(layerName: string, selectedType: any) { + this.selectedLayerTypes[layerName] = selectedType + ? selectedType + : this.getLayerChoices(layerName)[0]?.value + } + + async addLayer(layer: string, layerType: any) { + try { + const ogcEndpoint = await new OgcApiEndpoint(this.ogcUrl) + let layerUrl: string + + if (layerType === 'vectorTiles') { + layerUrl = await ogcEndpoint.getVectorTilesetUrl(layer) + } else if (layerType === 'mapTiles') { + layerUrl = await ogcEndpoint.getMapTilesetUrl(layer) + } else { + layerUrl = await ogcEndpoint.getCollectionItemsUrl(layer, { + outputFormat: 'json', + }) + } + + const layerToAdd: MapContextLayerModel = { + name: layer, + url: layerUrl, + type: MapContextLayerTypeEnum.OGCAPI, + layerType: layerType, + } + this.layerAdded.emit({ ...layerToAdd, title: layer }) + } catch (error) { + const err = error as Error + console.error('Error adding layer:', err.message) } - this.layerAdded.emit({ ...layerToAdd, title: 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 12f07631ee..63d7ed833d 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 @@ -38,6 +38,7 @@ export interface MapContextLayerOgcapiModel { type: 'ogcapi' url: string name: string + layerType: 'feature' | 'vectorTiles' | 'mapTiles' | 'record' } interface LayerXyzModel { 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 5e723badbe..8b63e20b48 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 @@ -25,6 +25,10 @@ import WMTS from 'ol/source/WMTS' import { Geometry } from 'ol/geom' import Feature from 'ol/Feature' import { WfsEndpoint, WmtsEndpoint } from '@camptocamp/ogc-client' +import OGCVectorTile from 'ol/source/OGCVectorTile.js' +import { MVT } from 'ol/format' +import VectorTileLayer from 'ol/layer/VectorTile' +import OGCMapTile from 'ol/source/OGCMapTile.js' export const DEFAULT_BASELAYER_CONTEXT: MapContextLayerXyzModel = { type: MapContextLayerTypeEnum.XYZ, @@ -78,14 +82,28 @@ export class MapContextService { const style = this.styleService.styles.default switch (type) { case MapContextLayerTypeEnum.OGCAPI: - return new VectorLayer({ - source: new VectorSource({ - format: new GeoJSON(), - url: layerModel.url, - }), - style, - }) - + if (layerModel.layerType === 'vectorTiles') { + return new VectorTileLayer({ + source: new OGCVectorTile({ + url: layerModel.url, + format: new MVT(), + }), + }) + } else if (layerModel.layerType === 'mapTiles') { + return new TileLayer({ + source: new OGCMapTile({ + url: layerModel.url, + }), + }) + } else { + return new VectorLayer({ + source: new VectorSource({ + format: new GeoJSON(), + url: layerModel.url, + }), + style, + }) + } case MapContextLayerTypeEnum.XYZ: return new TileLayer({ source: new XYZ({ diff --git a/package-lock.json b/package-lock.json index 31dbb3b45a..39816ce869 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": "^1.1.0", + "@camptocamp/ogc-client": "^1.1.1-dev.a0aadb6", "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", @@ -3652,9 +3652,9 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/@camptocamp/ogc-client": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.1.0.tgz", - "integrity": "sha512-+Vj4G1D6YNxqRsKtdCA6fWHlFjNJxdK8xRbnXlgJwfRNtFxK78qkPeAuN82hxjgZrEmAOQzPZWgELDAjDq2UAQ==", + "version": "1.1.1-dev.a0aadb6", + "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.1.1-dev.a0aadb6.tgz", + "integrity": "sha512-wQsCe7GHYoUEr71vO8ov74GoNrHbCS+cfvimJ055OTHcEOIjrFC3Hs88P0WmgXmdUuGoFZLpJ6EF1eeYisGL+Q==", "dependencies": { "@rgrove/parse-xml": "^4.1.0" }, @@ -17891,9 +17891,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", "dependencies": { "jake": "^10.8.5" }, diff --git a/package.json b/package.json index 49058af098..e01ac3607e 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": "^1.1.0", + "@camptocamp/ogc-client": "^1.1.1-dev.a0aadb6", "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1",