Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Map-Viewer] Add layer from OGC API service #848

Merged
merged 6 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/guide/custom-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion jest.preset.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)$': [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<div class="flex items-center mb-5">
<gn-ui-text-input
[(value)]="ogcUrl"
(valueChange)="urlChange.next($event)"
[hint]="'map.ogc.urlInput.hint' | translate"
class="w-96"
>
</gn-ui-text-input>
</div>

<div *ngIf="errorMessage" class="text-red-500 mt-2">
{{ errorMessage }}
</div>

<div *ngIf="loading">
<p class="loading-message" translate>map.loading.service</p>
</div>

<div *ngIf="!loading && layers.length > 0">
<h2 class="font-bold" translate>map.layers.available</h2>
<ng-container *ngFor="let layer of layers">
<div class="flex items-center justify-between my-2 layer-item-tree">
<p class="max-w-xs overflow-hidden overflow-ellipsis whitespace-nowrap">
{{ layer }}
</p>
<gn-ui-button
class="layer-add-btn"
type="primary"
(buttonClick)="addLayer(layer)"
extraClass="text-sm !px-2 !py-1"
translate
><span translate> map.layer.add </span></gn-ui-button
>
</div>
</ng-container>
</div>
Original file line number Diff line number Diff line change
@@ -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<AddLayerFromOgcApiComponent>

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)
})
})
})
Original file line number Diff line number Diff line change
@@ -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'],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we could make this a standalone component? that would push things towards the right direction :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done !!

standalone: true,
imports: [CommonModule, TranslateModule, UiInputsModule],
})
export class AddLayerFromOgcApiComponent implements OnInit {
@Input() ogcUrl: string
@Output() layerAdded = new EventEmitter<MapLayer>()

urlChange = new Subject<string>()
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 })
}
}
2 changes: 2 additions & 0 deletions libs/feature/map/src/lib/feature-map.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -59,6 +60,7 @@ import { GEOCODING_PROVIDER, GeocodingProvider } from './geocoding.service'
EffectsModule.forFeature([MapEffects]),
UiElementsModule,
UiInputsModule,
AddLayerFromOgcApiComponent,
],
providers: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
<gn-ui-add-layer-from-wfs></gn-ui-add-layer-from-wfs>
</div>
</mat-tab>
<mat-tab [label]="'map.add.layer.ogc.api' | translate" bodyClass="h-full">
<div class="p-3">
<gn-ui-add-layer-from-ogc-api
[ogcUrl]="ogcUrl"
(layerAdded)="addLayer($event)"
></gn-ui-add-layer-from-ogc-api>
</div>
</mat-tab>
<mat-tab [label]="'map.add.layer.file' | translate" bodyClass="h-full">
<div class="p-3">
<gn-ui-add-layer-from-file></gn-ui-add-layer-from-file>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
13 changes: 10 additions & 3 deletions libs/feature/map/src/lib/map-context/map-context.model.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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',
WMS = 'wms',
WMTS = 'wmts',
WFS = 'wfs',
GEOJSON = 'geojson',
OGCAPI = 'ogcapi',
}

export interface MapContextModel {
Expand All @@ -24,8 +24,8 @@ export interface MapContextLayerWmsModel {

export interface MapContextLayerWmtsModel {
type: 'wmts'
options: Options
extent?: Extent
url: string
name: string
}

interface MapContextLayerWfsModel {
Expand All @@ -34,6 +34,12 @@ interface MapContextLayerWfsModel {
name: string
}

export interface MapContextLayerOgcapiModel {
type: 'ogcapi'
url: string
name: string
}

interface LayerXyzModel {
type: 'xyz'
name?: string
Expand Down Expand Up @@ -71,6 +77,7 @@ export type MapContextLayerModel =
| MapContextLayerWfsModel
| MapContextLayerXyzModel
| MapContextLayerGeojsonModel
| MapContextLayerOgcapiModel

export interface MapContextViewModel {
center?: Coordinate // expressed in long/lat (EPSG:4326)
Expand Down
46 changes: 44 additions & 2 deletions libs/feature/map/src/lib/map-context/map-context.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -322,7 +364,7 @@ describe('MapContextService', () => {
const layerWMSUrl = (map.getLayers().item(1) as TileLayer<TileWMS>)
.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 = (
Expand Down
Loading
Loading