From 2a73b730ad6057c2c6aa372db173b080b9095b16 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 13 Sep 2024 18:12:04 +0200 Subject: [PATCH 01/12] test(map): add map fixtures --- libs/common/fixtures/src/index.ts | 2 + libs/common/fixtures/src/lib/map/index.ts | 1 + .../src/lib/map/map-context.fixtures.ts | 53 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 libs/common/fixtures/src/lib/map/index.ts create mode 100644 libs/common/fixtures/src/lib/map/map-context.fixtures.ts diff --git a/libs/common/fixtures/src/index.ts b/libs/common/fixtures/src/index.ts index 62748e86bc..71987343f8 100644 --- a/libs/common/fixtures/src/index.ts +++ b/libs/common/fixtures/src/index.ts @@ -13,3 +13,5 @@ export * from './lib/user.fixtures' export * from './lib/user-feedbacks.fixtures' export * from './lib/editor' + +export * from './lib/map' diff --git a/libs/common/fixtures/src/lib/map/index.ts b/libs/common/fixtures/src/lib/map/index.ts new file mode 100644 index 0000000000..8b0a93f2ec --- /dev/null +++ b/libs/common/fixtures/src/lib/map/index.ts @@ -0,0 +1 @@ +export * from './map-context.fixtures' diff --git a/libs/common/fixtures/src/lib/map/map-context.fixtures.ts b/libs/common/fixtures/src/lib/map/map-context.fixtures.ts new file mode 100644 index 0000000000..271a5f102c --- /dev/null +++ b/libs/common/fixtures/src/lib/map/map-context.fixtures.ts @@ -0,0 +1,53 @@ +import { polygonFeatureCollectionFixture } from '../geojson.fixtures' +import { Extent } from 'ol/extent' +import type { + MapContext, + MapContextLayer, + MapContextView, +} from '@geospatial-sdk/core' + +export const mapCtxLayerXyzFixture = (): MapContextLayer => ({ + type: 'xyz', + url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png', + attributions: '', +}) + +export const mapCtxLayerWmsFixture = (): MapContextLayer => ({ + type: 'wms', + url: 'https://www.geograndest.fr/geoserver/region-grand-est/ows?REQUEST=GetCapabilities&SERVICE=WMS', + name: 'commune_actuelle_3857', +}) + +export const mapCtxLayerWfsFixture = (): MapContextLayer => ({ + type: 'wfs', + url: 'https://www.geograndest.fr/geoserver/region-grand-est/ows?REQUEST=GetCapabilities&SERVICE=WFS&VERSION=1.1.0', + featureType: 'ms:commune_actuelle_3857', +}) + +export const mapCtxLayerGeojsonFixture = (): MapContextLayer => ({ + type: 'geojson', + data: polygonFeatureCollectionFixture(), +}) + +export const mapCtxLayerGeojsonRemoteFixture = (): MapContextLayer => ({ + type: 'geojson', + url: 'https://my.host.com/data/regions.json', +}) + +export const mapCtxViewFixture = (): MapContextView => ({ + center: [7.75, 48.6], + zoom: 9, +}) + +export const mapCtxFixture = (): MapContext => ({ + layers: [ + mapCtxLayerXyzFixture(), + mapCtxLayerWmsFixture(), + mapCtxLayerGeojsonFixture(), + ], + view: mapCtxViewFixture(), +}) + +export const mapCtxExtentFixture = (): Extent => [ + 171083.69713494915, 6246047.945419401, 476970.39956295764, 6631079.362882684, +] From b7034b33ff19044c3a2c4cd56098c4f8edb80134 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Sun, 15 Sep 2024 18:32:06 +0200 Subject: [PATCH 02/12] feat(ui): introduce new map container component, rework others --- .../src/e2e/datasetDetailPage.cy.ts | 4 +- apps/demo/.storybook/styles.css | 1 + libs/ui/map/src/index.ts | 5 +- .../feature-detail.component.html | 2 +- .../feature-detail.component.spec.ts | 28 ++- .../feature-detail.component.ts | 13 +- .../map-container.component.css} | 0 .../map-container.component.html} | 0 .../map-container.component.spec.ts | 228 ++++++++++++++++++ .../map-container.component.stories.ts | 43 ++++ .../map-container/map-container.component.ts | 203 ++++++++++++++++ .../map-container/map-settings.token.ts | 23 ++ .../lib/components/map/map.component.spec.ts | 109 --------- .../src/lib/components/map/map.component.ts | 57 ----- libs/ui/map/src/lib/map-utils.spec.ts | 102 ++++++++ libs/ui/map/src/lib/map-utils.ts | 54 +++++ libs/ui/map/src/lib/ui-map.module.ts | 19 -- libs/ui/map/src/test-setup.ts | 10 + 18 files changed, 694 insertions(+), 207 deletions(-) rename libs/ui/map/src/lib/components/{map/map.component.css => map-container/map-container.component.css} (100%) rename libs/ui/map/src/lib/components/{map/map.component.html => map-container/map-container.component.html} (100%) create mode 100644 libs/ui/map/src/lib/components/map-container/map-container.component.spec.ts create mode 100644 libs/ui/map/src/lib/components/map-container/map-container.component.stories.ts create mode 100644 libs/ui/map/src/lib/components/map-container/map-container.component.ts create mode 100644 libs/ui/map/src/lib/components/map-container/map-settings.token.ts delete mode 100644 libs/ui/map/src/lib/components/map/map.component.spec.ts delete mode 100644 libs/ui/map/src/lib/components/map/map.component.ts create mode 100644 libs/ui/map/src/lib/map-utils.spec.ts create mode 100644 libs/ui/map/src/lib/map-utils.ts delete mode 100644 libs/ui/map/src/lib/ui-map.module.ts diff --git a/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts b/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts index 7a5efc0ecd..1f4e94fe3e 100644 --- a/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts +++ b/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts @@ -332,7 +332,9 @@ describe('dataset pages', () => { .should('have.length.gt', 1) }) it('should display the map', () => { - cy.get('@previewSection').find('gn-ui-map').should('be.visible') + cy.get('@previewSection') + .find('gn-ui-map-container') + .should('be.visible') }) it('should display the table', () => { cy.get('@previewSection') diff --git a/apps/demo/.storybook/styles.css b/apps/demo/.storybook/styles.css index e69de29bb2..74a41318e3 100644 --- a/apps/demo/.storybook/styles.css +++ b/apps/demo/.storybook/styles.css @@ -0,0 +1 @@ +@import 'ol/ol.css'; diff --git a/libs/ui/map/src/index.ts b/libs/ui/map/src/index.ts index f486aa9316..fb10d8a265 100644 --- a/libs/ui/map/src/index.ts +++ b/libs/ui/map/src/index.ts @@ -1,3 +1,4 @@ -export * from './lib/ui-map.module' -export * from './lib/components/map/map.component' +export * from './lib/components/map-container/map-container.component' +export * from './lib/components/map-container/map-settings.token' export * from './lib/components/feature-detail/feature-detail.component' +export * from './lib/map-utils' diff --git a/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.html b/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.html index d27bf16dd4..3df923dd31 100644 --- a/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.html +++ b/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.html @@ -1,6 +1,6 @@
{{ propName }}
-
{{ feature.get(propName) }}
+
{{ feature.properties[propName] }}
diff --git a/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.spec.ts b/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.spec.ts index c6d7756c0a..7b6a72b11b 100644 --- a/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.spec.ts +++ b/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.spec.ts @@ -1,21 +1,20 @@ import { ChangeDetectionStrategy, DebugElement } from '@angular/core' import { ComponentFixture, TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' -import { openLayerFeatureFixture } from '@geonetwork-ui/common/fixtures' -import { Feature } from 'ol' -import { Geometry } from 'ol/geom' - import { FeatureDetailComponent } from './feature-detail.component' +import { MockBuilder } from 'ng-mocks' describe('FeatureDetailComponent', () => { let component: FeatureDetailComponent let fixture: ComponentFixture let de: DebugElement + beforeEach(() => { + return MockBuilder(FeatureDetailComponent) + }) + beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [FeatureDetailComponent], - }) + await TestBed.configureTestingModule({}) .overrideComponent(FeatureDetailComponent, { set: { changeDetection: ChangeDetectionStrategy.Default }, }) @@ -36,12 +35,15 @@ describe('FeatureDetailComponent', () => { }) }) describe('when a feature is given', () => { - let feature beforeEach(() => { - feature = new Feature() - feature.set('id', 123) - feature.set('name', 'ol_feature') - component.feature = openLayerFeatureFixture() + component.feature = { + type: 'Feature', + properties: { + id: 123, + name: 'ol_feature', + }, + geometry: null, + } fixture.detectChanges() }) it('displays the info', () => { @@ -53,7 +55,7 @@ describe('FeatureDetailComponent', () => { expect(props.length).toBe(2) }) it('ignore geometry columns', () => { - feature.set('geometry', new Geometry()) + component.feature['geometry'] = { type: 'Point', coordinates: [0, 0] } const props = de.queryAll(By.css('.property')) expect(props.length).toBe(2) }) diff --git a/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.ts b/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.ts index 5d16e92ae0..ac9c8b885f 100644 --- a/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.ts +++ b/libs/ui/map/src/lib/components/feature-detail/feature-detail.component.ts @@ -1,6 +1,6 @@ -import { Component, ChangeDetectionStrategy, Input } from '@angular/core' -import Feature from 'ol/Feature' -import { Geometry } from 'ol/geom' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { CommonModule } from '@angular/common' +import type { Feature } from 'geojson' const geometryKeys = ['geometry', 'the_geom'] @@ -9,12 +9,15 @@ const geometryKeys = ['geometry', 'the_geom'] templateUrl: './feature-detail.component.html', styleUrls: ['./feature-detail.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule], }) export class FeatureDetailComponent { - @Input() feature: Feature + @Input() feature: Feature get properties() { - return Object.keys(this.feature.getProperties()).filter( + if (!this.feature) return [] + return Object.keys(this.feature.properties).filter( (prop) => !geometryKeys.includes(prop) ) } diff --git a/libs/ui/map/src/lib/components/map/map.component.css b/libs/ui/map/src/lib/components/map-container/map-container.component.css similarity index 100% rename from libs/ui/map/src/lib/components/map/map.component.css rename to libs/ui/map/src/lib/components/map-container/map-container.component.css diff --git a/libs/ui/map/src/lib/components/map/map.component.html b/libs/ui/map/src/lib/components/map-container/map-container.component.html similarity index 100% rename from libs/ui/map/src/lib/components/map/map.component.html rename to libs/ui/map/src/lib/components/map-container/map-container.component.html diff --git a/libs/ui/map/src/lib/components/map-container/map-container.component.spec.ts b/libs/ui/map/src/lib/components/map-container/map-container.component.spec.ts new file mode 100644 index 0000000000..5ab227acdc --- /dev/null +++ b/libs/ui/map/src/lib/components/map-container/map-container.component.spec.ts @@ -0,0 +1,228 @@ +import { + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing' +import { MockBuilder } from 'ng-mocks' +import { + mapCtxFixture, + mapCtxLayerWmsFixture, + mapCtxLayerXyzFixture, +} from '@geonetwork-ui/common/fixtures' +import { applyContextDiffToMap } from '@geospatial-sdk/openlayers' +import { MapContainerComponent } from './map-container.component' +import { computeMapContextDiff } from '@geospatial-sdk/core' + +jest.mock('@geospatial-sdk/core', () => ({ + computeMapContextDiff: jest.fn(() => ({ + 'this is': 'a diff', + })), +})) + +jest.mock('@geospatial-sdk/openlayers', () => ({ + applyContextDiffToMap: jest.fn(), + createMapFromContext: jest.fn(() => Promise.resolve(new OpenLayersMapMock())), + listen: jest.fn(), +})) + +let mapmutedCallback +let movestartCallback +let singleclickCallback +class OpenLayersMapMock { + _size = undefined + setTarget = jest.fn() + updateSize() { + this._size = [100, 100] + } + getSize() { + return this._size + } + on(type, callback) { + if (type === 'mapmuted') { + mapmutedCallback = callback + } + if (type === 'movestart') { + movestartCallback = callback + } + if (type === 'singleclick') { + singleclickCallback = callback + } + } + off() { + // do nothing! + } +} + +const defaultBaseMap = { + attributions: + '©
OpenStreetMap contributors, © Carto', + type: 'xyz', + url: 'https://{a-c}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', +} + +describe('MapContainerComponent', () => { + let component: MapContainerComponent + let fixture: ComponentFixture + + beforeEach(() => { + jest.clearAllMocks() + }) + + beforeEach(() => { + return MockBuilder(MapContainerComponent) + }) + + beforeEach(async () => { + await TestBed.configureTestingModule({}).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(MapContainerComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('creates', () => { + expect(component).toBeTruthy() + }) + + describe('#processContext', () => { + it('returns a default context if null provided', () => { + expect(component.processContext(null)).toEqual({ + layers: [defaultBaseMap], + view: { + center: [0, 15], + zoom: 2, + }, + }) + }) + it('adds base layers to context', () => { + const context = { + layers: [mapCtxLayerWmsFixture()], + view: null, + } + expect(component.processContext(context)).toEqual({ + layers: [defaultBaseMap, mapCtxLayerWmsFixture()], + view: { + center: [0, 15], + zoom: 2, + }, + }) + }) + it('uses provided basemaps if any', () => { + component['basemapLayers'] = [mapCtxLayerXyzFixture()] + const context = { layers: [], view: null } + expect(component.processContext(context)).toEqual({ + layers: [defaultBaseMap, mapCtxLayerXyzFixture()], + view: { + center: [0, 15], + zoom: 2, + }, + }) + }) + it('does not use the default base layer if specified', () => { + component['doNotUseDefaultBasemap'] = true + const context = { layers: [mapCtxLayerXyzFixture()], view: null } + expect(component.processContext(context)).toEqual({ + layers: [mapCtxLayerXyzFixture()], + view: { + center: [0, 15], + zoom: 2, + }, + }) + }) + it('applies map constraints if any', () => { + component['mapViewConstraints'] = { + maxZoom: 18, + maxExtent: [10, 20, 30, 40], + } + const context = { layers: [mapCtxLayerXyzFixture()], view: null } + expect(component.processContext(context)).toEqual({ + layers: [defaultBaseMap, mapCtxLayerXyzFixture()], + view: { + center: [0, 15], + zoom: 2, + maxExtent: [10, 20, 30, 40], + maxZoom: 18, + }, + }) + }) + }) + + describe('#afterViewInit', () => { + beforeEach(async () => { + await component.ngAfterViewInit() + }) + it('creates a map', () => { + expect(component.olMap).toBeInstanceOf(OpenLayersMapMock) + }) + describe('display message that map navigation has been muted', () => { + let messageDisplayed + beforeEach(() => { + messageDisplayed = null + component.displayMessage$.subscribe( + (value) => (messageDisplayed = value) + ) + }) + it('mapmuted event displays message after 300ms (delay for eventually hiding message)', fakeAsync(() => { + mapmutedCallback() + tick(400) + expect(messageDisplayed).toEqual(true) + discardPeriodicTasks() + })) + it('message goes away after 2s', fakeAsync(() => { + mapmutedCallback() + tick(2500) + expect(messageDisplayed).toEqual(false) + discardPeriodicTasks() + })) + it('message does not display if map fires movestart event', fakeAsync(() => { + movestartCallback() + tick(300) + expect(messageDisplayed).toEqual(false) + discardPeriodicTasks() + })) + it('message does not display if map fires singleclick event', fakeAsync(() => { + singleclickCallback() + tick(300) + expect(messageDisplayed).toEqual(false) + discardPeriodicTasks() + })) + }) + }) + + describe('#ngOnChanges', () => { + beforeEach(async () => { + await component.ngAfterViewInit() + }) + it('updates the map with the new context', async () => { + const newContext = { + ...mapCtxFixture(), + layers: [mapCtxLayerWmsFixture()], + } + await component.ngOnChanges({ + context: { + currentValue: mapCtxFixture(), + previousValue: newContext, + firstChange: false, + isFirstChange: () => false, + }, + }) + expect(computeMapContextDiff).toHaveBeenCalledWith( + { + layers: [defaultBaseMap, ...mapCtxFixture().layers], + view: mapCtxFixture().view, + }, + { + layers: [defaultBaseMap, mapCtxLayerWmsFixture()], + view: mapCtxFixture().view, + } + ) + expect(applyContextDiffToMap).toHaveBeenCalledWith(component.olMap, { + 'this is': 'a diff', + }) + }) + }) +}) diff --git a/libs/ui/map/src/lib/components/map-container/map-container.component.stories.ts b/libs/ui/map/src/lib/components/map-container/map-container.component.stories.ts new file mode 100644 index 0000000000..767e40ff80 --- /dev/null +++ b/libs/ui/map/src/lib/components/map-container/map-container.component.stories.ts @@ -0,0 +1,43 @@ +import { + applicationConfig, + componentWrapperDecorator, + Meta, + StoryObj, +} from '@storybook/angular' +import { importProvidersFrom } from '@angular/core' +import { MapContainerComponent } from './map-container.component' +import { TranslateModule } from '@ngx-translate/core' +import { mapCtxFixture } from '@geonetwork-ui/common/fixtures' + +export default { + title: 'Map/Map Container', + component: MapContainerComponent, + decorators: [ + applicationConfig({ + providers: [importProvidersFrom(TranslateModule.forRoot())], + }), + componentWrapperDecorator( + (story) => ` +
+ ${story} +
` + ), + ], + argTypes: { + featuresClicked: { + action: 'featuresClicked', + }, + featuresHover: { + action: 'featuresHover', + }, + mapClick: { + action: 'mapClick', + }, + }, +} as Meta + +export const Primary: StoryObj = { + args: { + context: mapCtxFixture(), + }, +} diff --git a/libs/ui/map/src/lib/components/map-container/map-container.component.ts b/libs/ui/map/src/lib/components/map-container/map-container.component.ts new file mode 100644 index 0000000000..4f7a11afd9 --- /dev/null +++ b/libs/ui/map/src/lib/components/map-container/map-container.component.ts @@ -0,0 +1,203 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Inject, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, +} from '@angular/core' +import { fromEvent, merge, Observable, of, timer } from 'rxjs' +import { delay, map, startWith, switchMap } from 'rxjs/operators' +import { CommonModule } from '@angular/common' +import { MatIconModule } from '@angular/material/icon' +import { TranslateModule } from '@ngx-translate/core' +import { + computeMapContextDiff, + Extent, + FeaturesClickEvent, + FeaturesClickEventType, + FeaturesHoverEvent, + FeaturesHoverEventType, + MapClickEvent, + MapClickEventType, + MapContext, + MapContextLayer, + MapContextLayerXyz, + MapContextView, +} from '@geospatial-sdk/core' +import { + applyContextDiffToMap, + createMapFromContext, + listen, +} from '@geospatial-sdk/openlayers' +import type OlMap from 'ol/Map' +import type { Feature } from 'geojson' +import { + BASEMAP_LAYERS, + DO_NOT_USE_DEFAULT_BASEMAP, + MAP_VIEW_CONSTRAINTS, +} from './map-settings.token' + +const DEFAULT_BASEMAP_LAYER: MapContextLayerXyz = { + type: 'xyz', + url: `https://{a-c}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png`, + attributions: `© OpenStreetMap contributors, © Carto`, +} + +const DEFAULT_VIEW: MapContextView = { + center: [0, 15], + zoom: 2, +} + +@Component({ + selector: 'gn-ui-map-container', + templateUrl: './map-container.component.html', + styleUrls: ['./map-container.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, MatIconModule, TranslateModule], +}) +export class MapContainerComponent implements AfterViewInit, OnChanges { + @Input() context: MapContext | null + + // these events only get registered on the map if they are used + _featuresClick: EventEmitter + @Output() get featuresClick() { + if (!this._featuresClick) { + this.openlayersMap.then((olMap) => { + listen( + olMap, + FeaturesClickEventType, + ({ features }: FeaturesClickEvent) => + this._featuresClick.emit(features) + ) + }) + this._featuresClick = new EventEmitter() + } + return this._featuresClick + } + _featuresHover: EventEmitter + @Output() get featuresHover() { + if (!this._featuresHover) { + this.openlayersMap.then((olMap) => { + listen( + olMap, + FeaturesHoverEventType, + ({ features }: FeaturesHoverEvent) => + this._featuresHover.emit(features) + ) + }) + this._featuresHover = new EventEmitter() + } + return this._featuresHover + } + _mapClick: EventEmitter<[number, number]> + @Output() get mapClick() { + if (!this._mapClick) { + this.openlayersMap.then((olMap) => { + listen(olMap, MapClickEventType, ({ coordinate }: MapClickEvent) => + this._mapClick.emit(coordinate) + ) + }) + this._mapClick = new EventEmitter<[number, number]>() + } + return this._mapClick + } + + @ViewChild('map') container: ElementRef + displayMessage$: Observable + olMap: OlMap + + constructor( + @Inject(DO_NOT_USE_DEFAULT_BASEMAP) private doNotUseDefaultBasemap: boolean, + @Inject(BASEMAP_LAYERS) private basemapLayers: MapContextLayer[], + @Inject(MAP_VIEW_CONSTRAINTS) + private mapViewConstraints: { + maxZoom?: number + maxExtent?: Extent + } + ) {} + + private olMapResolver + openlayersMap = new Promise((resolve) => { + this.olMapResolver = resolve + }) + + async ngAfterViewInit() { + this.olMap = await createMapFromContext( + this.processContext(this.context), + this.container.nativeElement + ) + this.displayMessage$ = merge( + fromEvent(this.olMap, 'mapmuted').pipe(map(() => true)), + fromEvent(this.olMap, 'movestart').pipe(map(() => false)), + fromEvent(this.olMap, 'singleclick').pipe(map(() => false)) + ).pipe( + switchMap((muted) => + muted + ? timer(2000).pipe( + map(() => false), + startWith(true), + delay(400) + ) + : of(false) + ) + ) + this.olMapResolver(this.olMap) + } + + async ngOnChanges(changes: SimpleChanges) { + if ('context' in changes && !changes['context'].isFirstChange()) { + const diff = computeMapContextDiff( + this.processContext(changes['context'].currentValue), + this.processContext(changes['context'].previousValue) + ) + await applyContextDiffToMap(this.olMap, diff) + } + } + + // This will apply basemap layers & view constraints + processContext(context: MapContext): MapContext { + const processed = context + ? { ...context, view: context.view ?? DEFAULT_VIEW } + : { layers: [], view: DEFAULT_VIEW } + if (this.basemapLayers.length) { + processed.layers = [...this.basemapLayers, ...processed.layers] + } + if (!this.doNotUseDefaultBasemap) { + processed.layers = [DEFAULT_BASEMAP_LAYER, ...processed.layers] + } + if (this.mapViewConstraints.maxZoom) { + processed.view = { + maxZoom: this.mapViewConstraints.maxZoom, + ...processed.view, + } + } + if (this.mapViewConstraints.maxExtent) { + processed.view = { + maxExtent: this.mapViewConstraints.maxExtent, + ...processed.view, + } + } + if ( + processed.view && + !('zoom' in processed.view) && + !('center' in processed.view) + ) { + if (this.mapViewConstraints.maxExtent) { + processed.view = { + extent: this.mapViewConstraints.maxExtent, + ...processed.view, + } + } else { + processed.view = { ...DEFAULT_VIEW, ...processed.view } + } + } + return processed + } +} diff --git a/libs/ui/map/src/lib/components/map-container/map-settings.token.ts b/libs/ui/map/src/lib/components/map-container/map-settings.token.ts new file mode 100644 index 0000000000..aecb90f6b5 --- /dev/null +++ b/libs/ui/map/src/lib/components/map-container/map-settings.token.ts @@ -0,0 +1,23 @@ +import { InjectionToken } from '@angular/core' +import { Extent, MapContextLayer } from '@geospatial-sdk/core' + +export const DO_NOT_USE_DEFAULT_BASEMAP = new InjectionToken( + 'doNotUseDefaultBasemap', + { factory: () => false } +) +export const BASEMAP_LAYERS = new InjectionToken( + 'basemapLayers', + { factory: () => [] } +) +export const MAP_VIEW_CONSTRAINTS = new InjectionToken<{ + maxZoom?: number + maxExtent?: Extent +}>('mapViewConstraints', { + factory: () => ({}), +}) +export const VECTOR_STYLE_DEFAULT = new InjectionToken('vectorStyleDefault', { + factory: () => ({ + fill: { color: 'rgba(255, 255, 255, 0.2)' }, + stroke: { color: '#ffcc33', width: 2 }, + }), +}) diff --git a/libs/ui/map/src/lib/components/map/map.component.spec.ts b/libs/ui/map/src/lib/components/map/map.component.spec.ts deleted file mode 100644 index bfd922a5ca..0000000000 --- a/libs/ui/map/src/lib/components/map/map.component.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - ComponentFixture, - discardPeriodicTasks, - fakeAsync, - TestBed, - tick, -} from '@angular/core/testing' -import { MapComponent } from './map.component' -import { MatIconModule } from '@angular/material/icon' - -class ResizeObserverMock { - observe = jest.fn() - unobserve = jest.fn() -} - -;(window as any).ResizeObserver = ResizeObserverMock - -let mapmutedCallback -let movestartCallback -let singleclickCallback -class OpenLayersMapMock { - _size = undefined - setTarget = jest.fn() - updateSize() { - this._size = [100, 100] - } - getSize() { - return this._size - } - on(type, callback) { - if (type === 'mapmuted') { - mapmutedCallback = callback - } - if (type === 'movestart') { - movestartCallback = callback - } - if (type === 'singleclick') { - singleclickCallback = callback - } - } - off() { - // do nothing! - } -} - -describe('MapComponent', () => { - let component: MapComponent - let fixture: ComponentFixture - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatIconModule], - declarations: [MapComponent], - }).compileComponents() - }) - - beforeEach(() => { - fixture = TestBed.createComponent(MapComponent) - component = fixture.componentInstance - component.map = new OpenLayersMapMock() as any - fixture.detectChanges() - }) - - it('creates', () => { - expect(component).toBeTruthy() - }) - - describe('#afterViewInit', () => { - it('sets div element for map', () => { - expect(component.map.setTarget).toHaveBeenCalled() - }) - it('observes div element of map to update map size', () => { - expect(component.resizeObserver.observe).toHaveBeenCalled() - }) - describe('display message that map navigation has been muted', () => { - let messageDisplayed - beforeEach(() => { - messageDisplayed = null - component.displayMessage$.subscribe( - (value) => (messageDisplayed = value) - ) - }) - it('mapmuted event displays message after 300ms (delay for eventually hiding message)', fakeAsync(() => { - mapmutedCallback() - tick(400) - expect(messageDisplayed).toEqual(true) - discardPeriodicTasks() - })) - it('message goes away after 2s', fakeAsync(() => { - mapmutedCallback() - tick(2500) - expect(messageDisplayed).toEqual(false) - discardPeriodicTasks() - })) - it('message does not display if map fires movestart event', fakeAsync(() => { - movestartCallback() - tick(300) - expect(messageDisplayed).toEqual(false) - discardPeriodicTasks() - })) - it('message does not display if map fires singleclick event', fakeAsync(() => { - singleclickCallback() - tick(300) - expect(messageDisplayed).toEqual(false) - discardPeriodicTasks() - })) - }) - }) -}) diff --git a/libs/ui/map/src/lib/components/map/map.component.ts b/libs/ui/map/src/lib/components/map/map.component.ts deleted file mode 100644 index 4306d43f65..0000000000 --- a/libs/ui/map/src/lib/components/map/map.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - ElementRef, - Input, - OnInit, - ViewChild, -} from '@angular/core' -import Map from 'ol/Map' -import { fromEvent, merge, Observable, of, timer } from 'rxjs' -import { delay, map, startWith, switchMap } from 'rxjs/operators' - -@Component({ - selector: 'gn-ui-map', - templateUrl: './map.component.html', - styleUrls: ['./map.component.css'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MapComponent implements OnInit, AfterViewInit { - @Input() map: Map - @ViewChild('map') container: ElementRef - resizeObserver = new ResizeObserver(() => { - this.map.updateSize() - this.resizeObserver.unobserve(this.container.nativeElement) - }) - mapMuted$: Observable - cancelMapmuted$: Observable - displayMessage$: Observable - - constructor(private _element: ElementRef) {} - - ngOnInit() { - // this will show the message when a 'mapmuted' event is received and hide it a few seconds later - // 'movestart' and 'singleclick' will cancel displaying the message in particular for two finger interactions on mobile - this.displayMessage$ = merge( - fromEvent(this.map, 'mapmuted').pipe(map(() => true)), - fromEvent(this.map, 'movestart').pipe(map(() => false)), - fromEvent(this.map, 'singleclick').pipe(map(() => false)) - ).pipe( - switchMap((muted) => - muted - ? timer(2000).pipe( - map(() => false), - startWith(true), - delay(400) - ) - : of(false) - ) - ) - } - - ngAfterViewInit() { - this.map.setTarget(this.container.nativeElement) - this.resizeObserver.observe(this.container.nativeElement) - } -} diff --git a/libs/ui/map/src/lib/map-utils.spec.ts b/libs/ui/map/src/lib/map-utils.spec.ts new file mode 100644 index 0000000000..b47dce555e --- /dev/null +++ b/libs/ui/map/src/lib/map-utils.spec.ts @@ -0,0 +1,102 @@ +import { + defaults, + DragPan, + DragRotate, + MouseWheelZoom, + PinchRotate, +} from 'ol/interaction' +import Map from 'ol/Map' +import MapBrowserEvent from 'ol/MapBrowserEvent' +import { + dragPanCondition, + mouseWheelZoomCondition, + prioritizePageScroll, +} from './map-utils' + +class ResizeObserverMock { + observe = jest.fn() + unobserve = jest.fn() + disconnect = jest.fn() +} +;(window as any).ResizeObserver = ResizeObserverMock + +describe('map utils', () => { + describe('dragPanCondition', () => { + let interaction: DragPan + beforeEach(() => { + interaction = new DragPan() + const map = new Map({}) + map.addInteraction(interaction) + }) + + it('returns true for a left click without modifier key', () => { + const nativeEvent = { + type: 'pointer', + pointerType: 'mouse', + isPrimary: true, + button: 0, + } + const event = new MapBrowserEvent( + 'pointer', + interaction.getMap(), + nativeEvent as PointerEvent + ) + + expect(dragPanCondition.bind(interaction)(event)).toBe(true) + }) + it('returns false for a left click with modifier key', () => { + const nativeEvent = { + type: 'pointer', + pointerType: 'mouse', + isPrimary: true, + button: 0, + shiftKey: true, + } + const event = new MapBrowserEvent( + 'pointer', + interaction.getMap(), + nativeEvent as PointerEvent + ) + + expect(dragPanCondition.bind(interaction)(event)).toBe(false) + }) + }) + describe('prioritizePageScroll', () => { + const interactions = defaults() + let dragRotate + let pinchRotate + beforeEach(() => { + prioritizePageScroll(interactions) + }) + it('adds condition to DragPan', () => { + const dragPan = interactions + .getArray() + .find((interaction) => interaction instanceof DragPan) + expect(dragPan.condition_).toEqual(dragPanCondition) + }) + it('adds condition to MouseWheelZoom', () => { + const mouseWheelZoom = interactions + .getArray() + .find((interaction) => interaction instanceof MouseWheelZoom) + expect(mouseWheelZoom.condition_).toEqual(mouseWheelZoomCondition) + }) + describe('interactions', () => { + beforeEach(() => { + interactions.forEach((interaction) => { + if (interaction instanceof DragRotate) { + dragRotate = interaction + } + if (interaction instanceof PinchRotate) { + pinchRotate = interaction + } + }) + }) + it('with no DragRotate interaction', () => { + expect(dragRotate).toBeFalsy() + }) + it('with no PinchRotate interaction', () => { + expect(pinchRotate).toBeFalsy() + }) + }) + }) +}) diff --git a/libs/ui/map/src/lib/map-utils.ts b/libs/ui/map/src/lib/map-utils.ts new file mode 100644 index 0000000000..866be4d646 --- /dev/null +++ b/libs/ui/map/src/lib/map-utils.ts @@ -0,0 +1,54 @@ +import Collection from 'ol/Collection' +import { defaults, DragPan, Interaction, MouseWheelZoom } from 'ol/interaction' +import MapBrowserEvent from 'ol/MapBrowserEvent' +import { + mouseOnly, + noModifierKeys, + platformModifierKeyOnly, + primaryAction, +} from 'ol/events/condition' + +export function prioritizePageScroll(interactions: Collection) { + interactions.clear() + interactions.extend( + defaults({ + // remove rotate interactions + altShiftDragRotate: false, + pinchRotate: false, + // replace drag and zoom interactions + dragPan: false, + mouseWheelZoom: false, + }) + .extend([ + new DragPan({ + condition: dragPanCondition, + }), + new MouseWheelZoom({ + condition: mouseWheelZoomCondition, + }), + ]) + .getArray() + ) +} + +export function dragPanCondition( + this: DragPan, + event: MapBrowserEvent +) { + const dragPanCondition = this.getPointerCount() === 2 || mouseOnly(event) + if (!dragPanCondition) { + this.getMap().dispatchEvent('mapmuted') + } + // combine the condition with the default DragPan conditions + return dragPanCondition && noModifierKeys(event) && primaryAction(event) +} + +export function mouseWheelZoomCondition( + this: MouseWheelZoom, + event: MapBrowserEvent +) { + if (!platformModifierKeyOnly(event) && event.type === 'wheel') { + this.getMap().dispatchEvent('mapmuted') + } + return platformModifierKeyOnly(event) +} diff --git a/libs/ui/map/src/lib/ui-map.module.ts b/libs/ui/map/src/lib/ui-map.module.ts deleted file mode 100644 index 19a1ec6f70..0000000000 --- a/libs/ui/map/src/lib/ui-map.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpClientModule } from '@angular/common/http' -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { MapComponent } from './components/map/map.component' -import { FeatureDetailComponent } from './components/feature-detail/feature-detail.component' -import { TranslateModule } from '@ngx-translate/core' -import { MatIconModule } from '@angular/material/icon' - -@NgModule({ - declarations: [MapComponent, FeatureDetailComponent], - imports: [ - CommonModule, - HttpClientModule, - MatIconModule, - TranslateModule.forChild(), - ], - exports: [MapComponent, FeatureDetailComponent], -}) -export class UiMapModule {} diff --git a/libs/ui/map/src/test-setup.ts b/libs/ui/map/src/test-setup.ts index 70e41af1c8..a397e1cfe3 100644 --- a/libs/ui/map/src/test-setup.ts +++ b/libs/ui/map/src/test-setup.ts @@ -6,6 +6,12 @@ import { BrowserDynamicTestingModule, platformBrowserDynamicTesting, } from '@angular/platform-browser-dynamic/testing' +import { ngMocks } from 'ng-mocks' +import { + BASEMAP_LAYERS, + DO_NOT_USE_DEFAULT_BASEMAP, + MAP_VIEW_CONSTRAINTS, +} from './lib/components/map-container/map-settings.token' getTestBed().resetTestEnvironment() getTestBed().initTestEnvironment( @@ -13,3 +19,7 @@ getTestBed().initTestEnvironment( platformBrowserDynamicTesting(), { teardown: { destroyAfterEach: false } } ) + +ngMocks.globalKeep(DO_NOT_USE_DEFAULT_BASEMAP) +ngMocks.globalKeep(BASEMAP_LAYERS) +ngMocks.globalKeep(MAP_VIEW_CONSTRAINTS) From 0c3b2acf1345d809e685c28354372f4362639c0d Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Sun, 15 Sep 2024 17:35:40 +0200 Subject: [PATCH 03/12] refactor(map): remove unused components --- .../src/lib/manager/map-instance.directive.ts | 10 - .../lib/manager/map-manager.service.spec.ts | 31 -- .../src/lib/manager/map-manager.service.ts | 13 - .../map-container/map-container.component.css | 0 .../map-container.component.html | 1 - .../map-container.component.spec.ts | 51 -- .../map-container/map-container.component.ts | 26 - .../component/map-context.component.css | 0 .../component/map-context.component.html | 1 - .../component/map-context.component.spec.ts | 168 ------ .../map-context.component.stories.ts | 61 -- .../component/map-context.component.ts | 57 -- .../lib/map-context/map-context.fixtures.ts | 55 -- .../src/lib/map-context/map-context.model.ts | 95 ---- .../map-context/map-context.service.spec.ts | 525 ------------------ .../lib/map-context/map-context.service.ts | 318 ----------- 16 files changed, 1412 deletions(-) delete mode 100644 libs/feature/map/src/lib/manager/map-instance.directive.ts delete mode 100644 libs/feature/map/src/lib/manager/map-manager.service.spec.ts delete mode 100644 libs/feature/map/src/lib/manager/map-manager.service.ts delete mode 100644 libs/feature/map/src/lib/map-container/map-container.component.css delete mode 100644 libs/feature/map/src/lib/map-container/map-container.component.html delete mode 100644 libs/feature/map/src/lib/map-container/map-container.component.spec.ts delete mode 100644 libs/feature/map/src/lib/map-container/map-container.component.ts delete mode 100644 libs/feature/map/src/lib/map-context/component/map-context.component.css delete mode 100644 libs/feature/map/src/lib/map-context/component/map-context.component.html delete mode 100644 libs/feature/map/src/lib/map-context/component/map-context.component.spec.ts delete mode 100644 libs/feature/map/src/lib/map-context/component/map-context.component.stories.ts delete mode 100644 libs/feature/map/src/lib/map-context/component/map-context.component.ts delete mode 100644 libs/feature/map/src/lib/map-context/map-context.fixtures.ts delete mode 100644 libs/feature/map/src/lib/map-context/map-context.model.ts delete mode 100644 libs/feature/map/src/lib/map-context/map-context.service.spec.ts delete mode 100644 libs/feature/map/src/lib/map-context/map-context.service.ts diff --git a/libs/feature/map/src/lib/manager/map-instance.directive.ts b/libs/feature/map/src/lib/manager/map-instance.directive.ts deleted file mode 100644 index 692ad3bb57..0000000000 --- a/libs/feature/map/src/lib/manager/map-instance.directive.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Directive } from '@angular/core' -import { MapManagerService } from './map-manager.service' - -@Directive({ - selector: '[gnUiMapContainer]', - providers: [MapManagerService], -}) -export class MapInstanceDirective { - constructor(private manager: MapManagerService) {} -} diff --git a/libs/feature/map/src/lib/manager/map-manager.service.spec.ts b/libs/feature/map/src/lib/manager/map-manager.service.spec.ts deleted file mode 100644 index cab187c00e..0000000000 --- a/libs/feature/map/src/lib/manager/map-manager.service.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { TestBed } from '@angular/core/testing' -import { MapUtilsService } from '../utils/map-utils.service' - -import { MapManagerService } from './map-manager.service' - -const mapUtilsServiceMock = { - createEmptyMap: jest.fn(), -} -describe('MapManagerService', () => { - let service: MapManagerService - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - { - provide: MapUtilsService, - useValue: mapUtilsServiceMock, - }, - ], - }) - service = TestBed.inject(MapManagerService) - }) - - it('should be created', () => { - expect(service).toBeTruthy() - }) - - it('instanciate an empty map', () => { - expect(mapUtilsServiceMock.createEmptyMap).toHaveBeenCalled() - }) -}) diff --git a/libs/feature/map/src/lib/manager/map-manager.service.ts b/libs/feature/map/src/lib/manager/map-manager.service.ts deleted file mode 100644 index d84a068909..0000000000 --- a/libs/feature/map/src/lib/manager/map-manager.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@angular/core' -import Map from 'ol/Map' -import { MapUtilsService } from '../utils/map-utils.service' - -@Injectable({ - providedIn: 'root', -}) -export class MapManagerService { - readonly map: Map - constructor(private utils: MapUtilsService) { - this.map = this.utils.createEmptyMap() - } -} diff --git a/libs/feature/map/src/lib/map-container/map-container.component.css b/libs/feature/map/src/lib/map-container/map-container.component.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/libs/feature/map/src/lib/map-container/map-container.component.html b/libs/feature/map/src/lib/map-container/map-container.component.html deleted file mode 100644 index 5fe34f6eb5..0000000000 --- a/libs/feature/map/src/lib/map-container/map-container.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/libs/feature/map/src/lib/map-container/map-container.component.spec.ts b/libs/feature/map/src/lib/map-container/map-container.component.spec.ts deleted file mode 100644 index 74d23e513d..0000000000 --- a/libs/feature/map/src/lib/map-container/map-container.component.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing' -import { MapContainerComponent } from './map-container.component' -import { MapFacade } from '../+state/map.facade' -import { of } from 'rxjs' -import { mapCtxLayerXyzFixture } from '../map-context/map-context.fixtures' -import { readFirst } from '@nx/angular/testing' -import { DEFAULT_BASELAYER_CONTEXT } from '../map-context/map-context.service' -import { NO_ERRORS_SCHEMA } from '@angular/core' - -class MapFacadeMock { - layers$ = of([mapCtxLayerXyzFixture()]) -} - -describe('MapContainerComponent', () => { - let component: MapContainerComponent - let fixture: ComponentFixture - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [MapContainerComponent], - providers: [ - { - provide: MapFacade, - useClass: MapFacadeMock, - }, - ], - schemas: [NO_ERRORS_SCHEMA], - }).compileComponents() - - fixture = TestBed.createComponent(MapContainerComponent) - component = fixture.componentInstance - fixture.detectChanges() - }) - - it('should create', () => { - expect(component).toBeTruthy() - }) - - describe('context$', () => { - it('emits a context containing the layers in the map', async () => { - const context = await readFirst(component.context$) - expect(context).toStrictEqual({ - layers: [DEFAULT_BASELAYER_CONTEXT, mapCtxLayerXyzFixture()], - view: { - center: expect.any(Array), - zoom: expect.any(Number), - }, - }) - }) - }) -}) diff --git a/libs/feature/map/src/lib/map-container/map-container.component.ts b/libs/feature/map/src/lib/map-container/map-container.component.ts deleted file mode 100644 index 8b9faf42db..0000000000 --- a/libs/feature/map/src/lib/map-container/map-container.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core' -import { Observable } from 'rxjs' -import { map } from 'rxjs/operators' -import { MapFacade } from '../+state/map.facade' -import { MapContextModel } from '../map-context/map-context.model' -import { DEFAULT_BASELAYER_CONTEXT } from '../map-context/map-context.service' - -@Component({ - selector: 'gn-ui-map-container', - templateUrl: './map-container.component.html', - styleUrls: ['./map-container.component.css'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MapContainerComponent { - context$: Observable = this.mapFacade.layers$.pipe( - map((layers) => ({ - view: { - center: [4, 42], - zoom: 6, - }, - layers: [DEFAULT_BASELAYER_CONTEXT, ...layers], - })) - ) - - constructor(private mapFacade: MapFacade) {} -} diff --git a/libs/feature/map/src/lib/map-context/component/map-context.component.css b/libs/feature/map/src/lib/map-context/component/map-context.component.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/libs/feature/map/src/lib/map-context/component/map-context.component.html b/libs/feature/map/src/lib/map-context/component/map-context.component.html deleted file mode 100644 index 84fd91619e..0000000000 --- a/libs/feature/map/src/lib/map-context/component/map-context.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/libs/feature/map/src/lib/map-context/component/map-context.component.spec.ts b/libs/feature/map/src/lib/map-context/component/map-context.component.spec.ts deleted file mode 100644 index 9d5e7f3270..0000000000 --- a/libs/feature/map/src/lib/map-context/component/map-context.component.spec.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core' -import { ComponentFixture, TestBed } from '@angular/core/testing' -import { MapManagerService } from '../../manager/map-manager.service' -import { mapCtxFixture } from '../map-context.fixtures' -import { MapContextService } from '../map-context.service' - -import { MapContextComponent } from './map-context.component' -import { mapConfigFixture } from '@geonetwork-ui/util/app-config' -import { HttpClientModule } from '@angular/common/http' -import { MapUtilsService } from '../../utils' - -class MapContextServiceMock { - resetMapFromContext = jest.fn() -} - -class MapUtilsServiceMock { - prioritizePageScroll = jest.fn() -} - -let resizeCallBack -class OpenLayersMapMock { - _size = undefined - once(type, callback) { - if (type === 'change:size') { - resizeCallBack = callback - } - } - updateSize() { - this._size = [100, 100] - } - getSize() { - return this._size - } -} - -class MapManagerMock { - map = new OpenLayersMapMock() -} - -describe('MapContextComponent', () => { - let component: MapContextComponent - let fixture: ComponentFixture - let mapContextService - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [MapContextComponent], - schemas: [NO_ERRORS_SCHEMA], - imports: [HttpClientModule], - providers: [ - { - provide: MapContextService, - useClass: MapContextServiceMock, - }, - { - provide: MapUtilsService, - useClass: MapUtilsServiceMock, - }, - { - provide: MapManagerService, - useClass: MapManagerMock, - }, - ], - }).compileComponents() - mapContextService = TestBed.inject(MapContextService) - }) - - beforeEach(() => { - fixture = TestBed.createComponent(MapContextComponent) - component = fixture.componentInstance - }) - - it('should create', () => { - fixture.detectChanges() - expect(component).toBeTruthy() - }) - - describe('with initial value', () => { - beforeEach(() => { - component.context = mapCtxFixture() - component.mapConfig = mapConfigFixture() - fixture.detectChanges() - component.ngOnChanges({}) - }) - it('reset the map from context', () => { - expect(mapContextService.resetMapFromContext).toHaveBeenCalledWith( - expect.any(OpenLayersMapMock), - mapCtxFixture(), - mapConfigFixture() - ) - }) - }) - - describe('no initial value', () => { - beforeEach(() => { - component.context = null - fixture.detectChanges() - component.ngOnChanges({}) - }) - it('does not reset the map', () => { - expect(mapContextService.resetMapFromContext).not.toHaveBeenCalled() - }) - }) - - describe('no initial value, two values afterwards', () => { - beforeEach(() => { - component.context = null - component.mapConfig = mapConfigFixture() - fixture.detectChanges() - component.ngOnChanges({}) - component.context = { ...mapCtxFixture() } - component.ngOnChanges({}) - component.context = { ...mapCtxFixture() } - component.ngOnChanges({}) - }) - it('reset the map from context twice', () => { - expect(mapContextService.resetMapFromContext).toHaveBeenCalledWith( - expect.any(OpenLayersMapMock), - mapCtxFixture(), - mapConfigFixture() - ) - expect(mapContextService.resetMapFromContext).toHaveBeenCalledTimes(2) - }) - }) - describe('mapContext with extent', () => { - const MAP_CTX_EXTENT = { - ...mapCtxFixture(), - view: { - extent: [-100, -200, 300, 400], - }, - } - - describe('initial context is provided', () => { - describe('before change detection and when map has no size', () => { - beforeEach(() => { - component.context = MAP_CTX_EXTENT - }) - it('does not reset the map', () => { - expect(mapContextService.resetMapFromContext).not.toHaveBeenCalled() - }) - }) - describe('after change detection and when map has no size', () => { - beforeEach(() => { - component.context = MAP_CTX_EXTENT - component.ngOnChanges({ context: MAP_CTX_EXTENT }) - }) - it('does not reset the map', () => { - expect(mapContextService.resetMapFromContext).not.toHaveBeenCalled() - }) - }) - describe('after change detection and when map has a size', () => { - beforeEach(() => { - component.context = MAP_CTX_EXTENT - component.mapConfig = mapConfigFixture() - component.ngOnChanges({ context: MAP_CTX_EXTENT }) - resizeCallBack() - }) - it('resets the map with a view computed from extent', () => { - expect(mapContextService.resetMapFromContext).toHaveBeenCalledWith( - expect.any(OpenLayersMapMock), - MAP_CTX_EXTENT, - mapConfigFixture() - ) - }) - }) - }) - }) -}) diff --git a/libs/feature/map/src/lib/map-context/component/map-context.component.stories.ts b/libs/feature/map/src/lib/map-context/component/map-context.component.stories.ts deleted file mode 100644 index 74d5aad9c6..0000000000 --- a/libs/feature/map/src/lib/map-context/component/map-context.component.stories.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { HttpClientModule } from '@angular/common/http' -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' -import { UiMapModule } from '@geonetwork-ui/ui/map' -import { - applicationConfig, - componentWrapperDecorator, - Meta, - moduleMetadata, - StoryObj, -} from '@storybook/angular' -import { - mapCtxLayerGeojsonFixture, - mapCtxLayerWmsFixture, - mapCtxLayerXyzFixture, -} from '../map-context.fixtures' -import { MapContextComponent } from './map-context.component' -import { importProvidersFrom } from '@angular/core' - -export default { - title: 'Map/MapContext', - component: MapContextComponent, - decorators: [ - moduleMetadata({ - imports: [UiMapModule], - }), - applicationConfig({ - providers: [ - importProvidersFrom(BrowserAnimationsModule), - importProvidersFrom(HttpClientModule), - ], - }), - componentWrapperDecorator( - (story) => `
${story}
` - ), - ], -} as Meta - -type Story = StoryObj -export const WMS: Story = { - args: { - context: { - layers: [mapCtxLayerXyzFixture(), mapCtxLayerWmsFixture()], - view: { - center: [7.75, 48.6], - zoom: 4, - }, - }, - }, -} - -export const GEOJSON: Story = { - args: { - context: { - layers: [mapCtxLayerXyzFixture(), mapCtxLayerGeojsonFixture()], - view: { - center: [7.75, 48.6], - zoom: 4, - }, - }, - }, -} diff --git a/libs/feature/map/src/lib/map-context/component/map-context.component.ts b/libs/feature/map/src/lib/map-context/component/map-context.component.ts deleted file mode 100644 index f96eafdd19..0000000000 --- a/libs/feature/map/src/lib/map-context/component/map-context.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - OnChanges, - Output, -} from '@angular/core' -import { MapUtilsService } from '../../utils/map-utils.service' -import Feature from 'ol/Feature' -import { Geometry } from 'ol/geom' - -import Map from 'ol/Map' -import { FeatureInfoService } from '../../feature-info/feature-info.service' -import { MapManagerService } from '../../manager/map-manager.service' -import { MapContextModel } from '../map-context.model' -import { MapContextService } from '../map-context.service' -import { MapConfig } from '@geonetwork-ui/util/app-config' - -@Component({ - selector: 'gn-ui-map-context', - templateUrl: './map-context.component.html', - styleUrls: ['./map-context.component.css'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MapContextComponent implements OnChanges { - @Input() context: MapContextModel - @Input() mapConfig: MapConfig - @Output() featureClicked = new EventEmitter[]>() - - map: Map - - constructor( - private service: MapContextService, - private featureInfo: FeatureInfoService, - private manager: MapManagerService, - private utils: MapUtilsService - ) { - this.map = manager.map - } - - ngOnChanges() { - if (this.context?.view) { - if (this.context.view.extent && !this.map.getSize()) { - this.map.once('change:size', () => { - this.service.resetMapFromContext( - this.map, - this.context, - this.mapConfig - ) - }) - } else { - this.service.resetMapFromContext(this.map, this.context, this.mapConfig) - } - } - } -} diff --git a/libs/feature/map/src/lib/map-context/map-context.fixtures.ts b/libs/feature/map/src/lib/map-context/map-context.fixtures.ts deleted file mode 100644 index e4e46666e0..0000000000 --- a/libs/feature/map/src/lib/map-context/map-context.fixtures.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { polygonFeatureCollectionFixture } from '@geonetwork-ui/common/fixtures' -import { Extent } from 'ol/extent' -import { - MapContextLayerGeojsonModel, - MapContextLayerModel, - MapContextLayerTypeEnum, - MapContextModel, - MapContextViewModel, -} from '../map-context/map-context.model' - -export const mapCtxLayerXyzFixture = (): MapContextLayerModel => ({ - type: MapContextLayerTypeEnum.XYZ, - url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png', -}) - -export const mapCtxLayerWmsFixture = (): MapContextLayerModel => ({ - type: MapContextLayerTypeEnum.WMS, - url: 'https://www.geograndest.fr/geoserver/region-grand-est/ows?REQUEST=GetCapabilities&SERVICE=WMS', - name: 'commune_actuelle_3857', -}) - -export const mapCtxLayerWfsFixture = (): MapContextLayerModel => ({ - type: MapContextLayerTypeEnum.WFS, - url: 'https://www.geograndest.fr/geoserver/region-grand-est/ows?REQUEST=GetCapabilities&SERVICE=WFS&VERSION=1.1.0', - name: 'ms:commune_actuelle_3857', -}) - -export const mapCtxLayerGeojsonFixture = (): MapContextLayerGeojsonModel => ({ - type: MapContextLayerTypeEnum.GEOJSON, - data: polygonFeatureCollectionFixture(), -}) - -export const mapCtxLayerGeojsonRemoteFixture = - (): MapContextLayerGeojsonModel => ({ - type: MapContextLayerTypeEnum.GEOJSON, - url: 'https://my.host.com/data/regions.json', - }) - -export const mapCtxViewFixture = (): MapContextViewModel => ({ - center: [7.75, 48.6], - zoom: 9, -}) - -export const mapCtxFixture = (): MapContextModel => ({ - layers: [ - mapCtxLayerXyzFixture(), - mapCtxLayerWmsFixture(), - mapCtxLayerGeojsonFixture(), - ], - view: mapCtxViewFixture(), -}) - -export const mapCtxExtentFixture = (): Extent => [ - 171083.69713494915, 6246047.945419401, 476970.39956295764, 6631079.362882684, -] 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 deleted file mode 100644 index 0b138243c8..0000000000 --- a/libs/feature/map/src/lib/map-context/map-context.model.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { FeatureCollection } from 'geojson' -import { Coordinate } from 'ol/coordinate' -import type { Extent } from 'ol/extent' - -export enum MapContextLayerTypeEnum { - XYZ = 'xyz', - WMS = 'wms', - WMTS = 'wmts', - WFS = 'wfs', - GEOJSON = 'geojson', - OGCAPI = 'ogcapi', -} - -export interface MapContextModel { - layers: MapContextLayerModel[] - view?: MapContextViewModel -} - -export interface MapContextLayerWmsModel { - type: 'wms' - url: string - name: string - attributions?: string -} - -export interface MapContextLayerWmtsModel { - type: 'wmts' - url: string - name: string - attributions?: string -} - -interface MapContextLayerWfsModel { - type: 'wfs' - url: string - name: string - attributions?: string -} - -export interface MapContextLayerOgcapiModel { - type: 'ogcapi' - url: string - name: string - layerType: 'feature' | 'vectorTiles' | 'mapTiles' | 'record' - attributions?: string -} - -interface LayerXyzModel { - type: 'xyz' - name?: string - attributions?: string -} -interface LayerXyzModelWithUrl extends LayerXyzModel { - url: string - urls?: never -} -interface LayerXyzModelWithUrls extends LayerXyzModel { - urls: string[] - url?: never -} -export type MapContextLayerXyzModel = - | LayerXyzModelWithUrl - | LayerXyzModelWithUrls - -interface LayerGeojson { - type: 'geojson' - attributions?: string -} -interface LayerGeojsonWithUrl extends LayerGeojson { - url: string - data?: never -} -interface LayerGeojsonWithData extends LayerGeojson { - data: FeatureCollection | string - url?: never -} -export type MapContextLayerGeojsonModel = - | LayerGeojsonWithUrl - | LayerGeojsonWithData - -export type MapContextLayerModel = - | MapContextLayerWmsModel - | MapContextLayerWmtsModel - | MapContextLayerWfsModel - | MapContextLayerXyzModel - | MapContextLayerGeojsonModel - | MapContextLayerOgcapiModel - -export interface MapContextViewModel { - center?: Coordinate // expressed in long/lat (EPSG:4326) - zoom?: number - extent?: Extent // expressed in long/lat (EPSG:4326) - maxZoom?: number - maxExtent?: Extent // 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 deleted file mode 100644 index 2826f42aaf..0000000000 --- a/libs/feature/map/src/lib/map-context/map-context.service.spec.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing' -import { TestBed } from '@angular/core/testing' -import { MapConfig, mapConfigFixture } from '@geonetwork-ui/util/app-config' -import { FeatureCollection } from 'geojson' -import { Geometry } from 'ol/geom' -import TileLayer from 'ol/layer/Tile' -import VectorLayer from 'ol/layer/Vector' -import Map from 'ol/Map' -import TileWMS from 'ol/source/TileWMS' -import VectorSource from 'ol/source/Vector' -import XYZ from 'ol/source/XYZ' -import { Style } from 'ol/style' -import View from 'ol/View' -import GeoJSON from 'ol/format/GeoJSON' -import { - defaultMapStyleFixture, - defaultMapStyleHlFixture, -} from '../style/map-style.fixtures' -import { MapStyleService } from '../style/map-style.service' -import { - mapCtxExtentFixture, - mapCtxFixture, - mapCtxLayerGeojsonFixture, - mapCtxLayerGeojsonRemoteFixture, - mapCtxLayerWfsFixture, - mapCtxLayerWmsFixture, - mapCtxLayerXyzFixture, -} from './map-context.fixtures' - -import { - DEFAULT_BASELAYER_CONTEXT, - DEFAULT_VIEW, - MapContextService, -} from './map-context.service' -import Feature from 'ol/Feature' -import ImageWMS from 'ol/source/ImageWMS' -import ImageLayer from 'ol/layer/Image' - -const mapStyleServiceMock = { - createDefaultStyle: jest.fn(() => new Style()), - styles: { - default: defaultMapStyleFixture(), - defaultHL: defaultMapStyleHlFixture(), - }, -} - -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 - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [ - { - provide: MapStyleService, - useValue: mapStyleServiceMock, - }, - ], - }) - service = TestBed.inject(MapContextService) - }) - - it('should be created', () => { - expect(service).toBeTruthy() - }) - - describe('#createLayer', () => { - let layerModel, layer - - describe('XYZ', () => { - beforeEach(() => { - layerModel = mapCtxLayerXyzFixture() - layer = service.createLayer(layerModel) - }) - it('create a tile layer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(TileLayer) - }) - it('create a XYZ source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(XYZ) - }) - it('set correct urls', () => { - const source = layer.getSource() - const urls = source.getUrls() - expect(urls.length).toBe(3) - expect(urls[0]).toEqual( - 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png' - ) - }) - }) - - describe('WMS', () => { - describe('when mapConfig.DO_NOT_TILE_WMS === false', () => { - beforeEach(() => { - const mapConfig: MapConfig = { - ...mapConfigFixture(), - DO_NOT_TILE_WMS: false, - } - ;(layerModel = mapCtxLayerWmsFixture()), - (layer = service.createLayer(layerModel, mapConfig)) - }) - it('create a tile layer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(TileLayer) - }) - it('create a TileWMS source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(TileWMS) - }) - it('set correct WMS params', () => { - const source = layer.getSource() - const params = source.getParams() - expect(params.LAYERS).toBe(layerModel.name) - }) - it('set correct url without existing REQUEST and SERVICE params', () => { - const source = layer.getSource() - const urls = source.getUrls() - expect(urls.length).toBe(1) - expect(urls[0]).toBe( - 'https://www.geograndest.fr/geoserver/region-grand-est/ows?REQUEST=GetCapabilities&SERVICE=WMS' - ) - }) - }) - - describe('when mapConfig.DO_NOT_TILE_WMS === true', () => { - beforeEach(() => { - const mapConfig: MapConfig = { - ...mapConfigFixture(), - DO_NOT_TILE_WMS: true, - } - ;(layerModel = mapCtxLayerWmsFixture()), - (layer = service.createLayer(layerModel, mapConfig)) - }) - it('create an image layer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(ImageLayer) - }) - it('create an ImageWMS source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(ImageWMS) - }) - it('set correct WMS params', () => { - const source = layer.getSource() - const params = source.getParams() - expect(params.LAYERS).toBe(layerModel.name) - }) - }) - }) - - describe('WFS', () => { - beforeEach(() => { - ;(layerModel = mapCtxLayerWfsFixture()), - (layer = service.createLayer(layerModel)) - }) - it('create a vector layer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(VectorLayer) - }) - it('create a Vector source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(VectorSource) - }) - it('set correct url load function', () => { - const source = layer.getSource() - const urlLoader = source.getUrl() - expect(urlLoader([10, 20, 30, 40])).toBe( - '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('GEOJSON', () => { - describe('with inline data', () => { - beforeEach(() => { - layerModel = mapCtxLayerGeojsonFixture() - layer = service.createLayer(layerModel) - }) - it('create a VectorLayer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(VectorLayer) - }) - it('create a VectorSource source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(VectorSource) - }) - it('add features', () => { - const source = layer.getSource() - const features = source.getFeatures() - expect(features.length).toBe(layerModel.data.features.length) - }) - }) - describe('with inline data as string', () => { - beforeEach(() => { - layerModel = { ...mapCtxLayerGeojsonFixture() } - layerModel.data = JSON.stringify(layerModel.data) - layer = service.createLayer(layerModel) - }) - it('create a VectorLayer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(VectorLayer) - }) - it('create a VectorSource source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(VectorSource) - }) - it('add features', () => { - const source = layer.getSource() - const features = source.getFeatures() - expect(features.length).toBe( - (mapCtxLayerGeojsonFixture().data as FeatureCollection).features - .length - ) - }) - }) - describe('with invalid inline data as string', () => { - beforeEach(() => { - const spy = jest.spyOn(global.console, 'warn') - spy.mockClear() - layerModel = { ...mapCtxLayerGeojsonFixture(), data: 'blargz' } - layer = service.createLayer(layerModel) - }) - it('create a VectorLayer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(VectorLayer) - }) - it('outputs error in the console', () => { - expect(global.console.warn).toHaveBeenCalled() - }) - it('create an empty VectorSource source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(VectorSource) - expect(source.getFeatures().length).toBe(0) - }) - }) - describe('with remote file url', () => { - beforeEach(() => { - layerModel = mapCtxLayerGeojsonRemoteFixture() - layer = service.createLayer(layerModel) - }) - it('create a VectorLayer', () => { - expect(layer).toBeTruthy() - expect(layer).toBeInstanceOf(VectorLayer) - }) - it('create a VectorSource source', () => { - const source = layer.getSource() - expect(source).toBeInstanceOf(VectorSource) - }) - it('sets the format as GeoJSON', () => { - const source = layer.getSource() - expect(source.getFormat()).toBeInstanceOf(GeoJSON) - }) - it('set the url to point to the file', () => { - const source = layer.getSource() - expect(source.getUrl()).toBe(layerModel.url) - }) - }) - }) - }) - - describe('#createView', () => { - describe('from center and zoom', () => { - let view - const contextModel = mapCtxFixture() - beforeEach(() => { - view = service.createView(contextModel.view) - }) - it('create a view', () => { - expect(view).toBeTruthy() - expect(view).toBeInstanceOf(View) - }) - it('set center', () => { - const center = view.getCenter() - expect(center).toEqual([862726.0536478702, 6207260.308175252]) - }) - it('set zoom', () => { - const zoom = view.getZoom() - expect(zoom).toEqual(contextModel.view.zoom) - }) - }) - describe('from extent', () => { - let view - const contextModel = mapCtxFixture() - contextModel.view.extent = mapCtxExtentFixture() - const map = new Map({}) - map.setSize([100, 100]) - beforeEach(() => { - view = service.createView(contextModel.view, map) - }) - it('create a view', () => { - expect(view).toBeTruthy() - expect(view).toBeInstanceOf(View) - }) - it('set center', () => { - const center = view.getCenter() - expect(center).toEqual([324027.04834895337, 6438563.654151043]) - }) - it('set zoom', () => { - const zoom = view.getZoom() - expect(zoom).toEqual(5) - }) - }) - }) - describe('#resetMapFromContext', () => { - describe('without config', () => { - const map = new Map({}) - const mapContext = mapCtxFixture() - beforeEach(() => { - service.resetMapFromContext(map, mapContext) - }) - it('create a map', () => { - expect(map).toBeTruthy() - expect(map).toBeInstanceOf(Map) - }) - it('add layers', () => { - const layers = map.getLayers().getArray() - expect(layers.length).toEqual(4) - }) - it('set view', () => { - const view = map.getView() - expect(view).toBeTruthy() - expect(view).toBeInstanceOf(View) - }) - it('set first layer as baselayer', () => { - const baselayerUrls = (map.getLayers().item(0) as TileLayer) - .getSource() - .getUrls() - expect(baselayerUrls).toEqual(DEFAULT_BASELAYER_CONTEXT.urls) - }) - }) - describe('with config', () => { - const map = new Map({}) - const mapContext = mapCtxFixture() - const mapConfig = mapConfigFixture() - beforeEach(() => { - mapConfig.DO_NOT_USE_DEFAULT_BASEMAP = true - service.resetMapFromContext(map, mapContext, mapConfig) - }) - it('set maxZoom', () => { - const maxZoom = map.getView().getMaxZoom() - expect(maxZoom).toBe(10) - }) - it('set first layer as baselayer', () => { - const baselayerUrls = (map.getLayers().item(0) as TileLayer) - .getSource() - .getUrls() - expect(baselayerUrls).toEqual(['https://some-basemap-server']) - }) - it('add one WMS layer from config on top of baselayer', () => { - const layerWMSUrl = (map.getLayers().item(1) as TileLayer) - .getSource() - .getUrls()[0] - expect(layerWMSUrl).toEqual('https://some-wms-server') - }) - it('add one WFS layer from config on top of baselayer', () => { - const layerWFSSource = ( - map.getLayers().item(2) as VectorLayer< - VectorSource> - > - ).getSource() - expect(layerWFSSource).toBeInstanceOf(VectorSource) - }) - }) - describe('with config, but keeping default basemap', () => { - const map = new Map({}) - const mapContext = mapCtxFixture() - const mapConfig = mapConfigFixture() - beforeEach(() => { - mapConfig.DO_NOT_USE_DEFAULT_BASEMAP = false - service.resetMapFromContext(map, mapContext, mapConfig) - }) - it('set first layer as baselayer', () => { - const baselayerUrls = (map.getLayers().item(0) as TileLayer) - .getSource() - .getUrls() - expect(baselayerUrls).toEqual(DEFAULT_BASELAYER_CONTEXT.urls) - }) - }) - describe('uses default fallback view (without config)', () => { - let view - const map = new Map({}) - const mapContext = { - extent: null, - center: null, - zoom: null, - layers: [ - mapCtxLayerXyzFixture(), - mapCtxLayerWmsFixture(), - mapCtxLayerGeojsonFixture(), - ], - } - beforeEach(() => { - service.resetMapFromContext(map, mapContext) - }) - it('create a map', () => { - expect(map).toBeTruthy() - expect(map).toBeInstanceOf(Map) - }) - it('add layers', () => { - const layers = map.getLayers().getArray() - expect(layers.length).toEqual(4) - }) - it('set view', () => { - view = map.getView() - expect(view).toBeTruthy() - expect(view).toBeInstanceOf(View) - }) - it('set center', () => { - const center = view.getCenter() - expect(center).toEqual([0, 1689200.1396078935]) - }) - it('set zoom', () => { - const zoom = view.getZoom() - expect(zoom).toEqual(DEFAULT_VIEW.zoom) - }) - }) - describe('uses fallback view from config', () => { - let view - const map = new Map({}) - const mapConfig = mapConfigFixture() - const mapContext = { - extent: null, - center: null, - zoom: null, - layers: [], - } - beforeEach(() => { - service.resetMapFromContext(map, mapContext, mapConfig) - }) - it('create a map', () => { - expect(map).toBeTruthy() - expect(map).toBeInstanceOf(Map) - }) - it('add layers', () => { - const layers = map.getLayers().getArray() - expect(layers.length).toEqual(4) - }) - it('set view', () => { - view = map.getView() - expect(view).toBeTruthy() - expect(view).toBeInstanceOf(View) - }) - it('set center', () => { - const center = view.getCenter() - expect(center).toEqual([271504.324469, 5979210.100579999]) - }) - it('set zoom', () => { - const zoom = view.getZoom() - expect(zoom).toEqual(3) - }) - }) - }) - describe('#mergeMapConfigWithContext', () => { - const mapContext = mapCtxFixture() - const mapConfig = mapConfigFixture() - beforeEach(() => { - mapConfig.DO_NOT_USE_DEFAULT_BASEMAP = true - }) - it('merges mapconfig into existing mapcontext', () => { - const mergedMapContext = service.mergeMapConfigWithContext( - mapContext, - mapConfig - ) - const layersContext = mapConfigFixture().MAP_LAYERS.map( - service.getContextLayerFromConfig - ) - - expect(mergedMapContext).toEqual({ - ...mapCtxFixture(), - view: { - ...mapCtxFixture().view, - maxZoom: mapConfigFixture().MAX_ZOOM, - maxExtent: mapConfigFixture().MAX_EXTENT, - }, - layers: [ - layersContext[0], - layersContext[1], - layersContext[2], - ...mapCtxFixture().layers, - ], - }) - }) - }) -}) 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 deleted file mode 100644 index 3119c01656..0000000000 --- a/libs/feature/map/src/lib/map-context/map-context.service.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { Injectable } from '@angular/core' -import { MapStyleService } from '../style/map-style.service' -import { - MapContextLayerModel, - MapContextLayerTypeEnum, - MapContextLayerXyzModel, - MapContextModel, - MapContextViewModel, -} from './map-context.model' -import Map from 'ol/Map' -import View from 'ol/View' -import Layer from 'ol/layer/Base' -import VectorLayer from 'ol/layer/Vector' -import TileWMS from 'ol/source/TileWMS' -import TileLayer from 'ol/layer/Tile' -import XYZ from 'ol/source/XYZ' -import VectorSource from 'ol/source/Vector' -import GeoJSON from 'ol/format/GeoJSON' -import { MapUtilsService } from '../utils/map-utils.service' -import { bbox as bboxStrategy } from 'ol/loadingstrategy' -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 { 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' -import ImageLayer from 'ol/layer/Image' -import ImageWMS from 'ol/source/ImageWMS' - -export const DEFAULT_BASELAYER_CONTEXT: MapContextLayerXyzModel = { - type: MapContextLayerTypeEnum.XYZ, - urls: [ - `https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png`, - `https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png`, - `https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png`, - ], - attributions: `© OpenStreetMap contributors, © Carto`, -} - -export const DEFAULT_VIEW: MapContextViewModel = { - center: [0, 15], - zoom: 2, -} - -export const WFS_MAX_FEATURES = 10000 - -@Injectable({ - providedIn: 'root', -}) -export class MapContextService { - constructor( - private mapUtils: MapUtilsService, - private styleService: MapStyleService - ) {} - - resetMapFromContext( - map: Map, - mapContext: MapContextModel, - mapConfig?: MapConfig - ): Map { - if (mapConfig) { - mapContext = this.mergeMapConfigWithContext(mapContext, mapConfig) - } else { - mapContext.layers = this.addDefaultBaselayerContext(mapContext.layers) - } - if ( - !mapContext.view?.extent && - (!mapContext.view?.center || !mapContext.view?.zoom) - ) { - mapContext.view = this.getFallbackView(mapConfig) - } - map.setView(this.createView(mapContext.view, map)) - map.getLayers().clear() - mapContext.layers.forEach((layer) => - map.addLayer(this.createLayer(layer, mapConfig)) - ) - return map - } - - createLayer(layerModel: MapContextLayerModel, mapConfig?: MapConfig): Layer { - const { type } = layerModel - const style = this.styleService.styles.default - switch (type) { - case MapContextLayerTypeEnum.OGCAPI: - if (layerModel.layerType === 'vectorTiles') { - return new VectorTileLayer({ - source: new OGCVectorTile({ - url: layerModel.url, - format: new MVT(), - attributions: layerModel.attributions, - }), - }) - } else if (layerModel.layerType === 'mapTiles') { - return new TileLayer({ - source: new OGCMapTile({ - url: layerModel.url, - attributions: layerModel.attributions, - }), - }) - } else { - return new VectorLayer({ - source: new VectorSource({ - format: new GeoJSON(), - url: layerModel.url, - attributions: layerModel.attributions, - }), - style, - }) - } - case MapContextLayerTypeEnum.XYZ: - return new TileLayer({ - source: new XYZ({ - url: 'url' in layerModel ? layerModel.url : undefined, - urls: 'urls' in layerModel ? layerModel.urls : undefined, - attributions: layerModel.attributions, - }), - }) - case MapContextLayerTypeEnum.WMS: - if (mapConfig?.DO_NOT_TILE_WMS) { - return new ImageLayer({ - source: new ImageWMS({ - url: layerModel.url, - params: { LAYERS: layerModel.name }, - attributions: layerModel.attributions, - }), - }) - } else { - return new TileLayer({ - source: new TileWMS({ - url: layerModel.url, - params: { LAYERS: layerModel.name, TILED: true }, - attributions: layerModel.attributions, - }), - }) - } - - 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, - attributions: layerModel.attributions, - }) - ) - }) - 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, - attributions: layerModel.attributions, - }) - ) - }) - return olLayer - } - case MapContextLayerTypeEnum.GEOJSON: { - if ('url' in layerModel) { - return new VectorLayer({ - source: new VectorSource({ - format: new GeoJSON(), - url: layerModel.url, - }), - style, - }) - } else { - let geojson = layerModel.data - if (typeof geojson === 'string') { - try { - geojson = JSON.parse(geojson) - } catch (e) { - console.warn('A layer could not be created', layerModel, e) - geojson = { type: 'FeatureCollection', features: [] } - } - } - const features = this.mapUtils.readFeatureCollection( - geojson as FeatureCollection - ) as Feature[] - return new VectorLayer({ - source: new VectorSource({ - features, - }), - style, - }) - } - } - default: - throw new Error(`Unrecognized layer type: ${layerModel.type}`) - } - } - - createView(viewModel: MapContextViewModel, map?: Map): View { - const { center: centerInViewProj, zoom, maxZoom, maxExtent } = viewModel - const center = centerInViewProj - ? fromLonLat(centerInViewProj, 'EPSG:3857') - : [0, 0] - const view = new View({ - center, - zoom, - maxZoom, - extent: maxExtent, - multiWorld: false, - constrainResolution: true, - }) - if (viewModel.extent && map) { - view.fit(viewModel.extent, { - size: map.getSize(), - }) - } - return view - } - - addDefaultBaselayerContext( - layers: MapContextLayerModel[] - ): MapContextLayerModel[] { - return layers.includes(DEFAULT_BASELAYER_CONTEXT) - ? layers - : [DEFAULT_BASELAYER_CONTEXT, ...layers] - } - - mergeMapConfigWithContext( - mapContext: MapContextModel, - mapConfig: MapConfig - ): MapContextModel { - return { - ...mapContext, - view: { - ...mapContext.view, - ...(mapConfig.MAX_ZOOM && { - maxZoom: mapConfig.MAX_ZOOM, - }), - ...(mapConfig.MAX_EXTENT && { - maxExtent: mapConfig.MAX_EXTENT, - }), - }, - layers: [ - ...(mapConfig.DO_NOT_USE_DEFAULT_BASEMAP - ? [] - : [DEFAULT_BASELAYER_CONTEXT]), - ...mapConfig.MAP_LAYERS.map(this.getContextLayerFromConfig), - ...mapContext.layers, - ], - } - } - - getFallbackView(mapConfig: MapConfig): MapContextViewModel { - return mapConfig?.MAX_EXTENT - ? { extent: mapConfig.MAX_EXTENT } - : DEFAULT_VIEW - } - - getContextLayerFromConfig(config: LayerConfig): MapContextLayerModel { - switch (config.TYPE) { - case 'wms': - return { - type: 'wms', - url: config.URL, - name: config.NAME, - } - case 'wfs': - return { - type: 'wfs', - url: config.URL, - name: config.NAME, - } - case 'xyz': - return { - type: config.TYPE, - url: config.URL, - name: config.NAME, - } - case 'geojson': - return { - type: config.TYPE, - ...(config.DATA ? { data: config.DATA } : { url: config.URL }), - } - } - } -} From 85bfef7deb30091e2254c5b1be0de7548de9d506 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 13 Sep 2024 18:13:10 +0200 Subject: [PATCH 04/12] refactor(map): rework map state & map-related features --- libs/feature/map/src/index.ts | 9 +- .../feature/map/src/lib/+state/map.actions.ts | 35 +- .../map/src/lib/+state/map.effects.spec.ts | 27 -- .../feature/map/src/lib/+state/map.effects.ts | 7 - .../map/src/lib/+state/map.facade.spec.ts | 43 +- libs/feature/map/src/lib/+state/map.facade.ts | 26 +- libs/feature/map/src/lib/+state/map.models.ts | 9 - .../map/src/lib/+state/map.reducer.spec.ts | 124 +----- .../feature/map/src/lib/+state/map.reducer.ts | 69 +-- .../map/src/lib/+state/map.selectors.spec.ts | 42 +- .../map/src/lib/+state/map.selectors.ts | 9 +- ...add-layer-record-preview.component.spec.ts | 46 +- .../add-layer-record-preview.component.ts | 27 +- .../add-layer-from-file.component.spec.ts | 31 +- .../add-layer-from-file.component.ts | 28 +- .../add-layer-from-ogc-api.component.spec.ts | 37 +- .../add-layer-from-ogc-api.component.ts | 23 +- .../add-layer-from-wfs.component.spec.ts | 37 +- .../add-layer-from-wfs.component.ts | 22 +- .../add-layer-from-wms.component.spec.ts | 39 +- .../add-layer-from-wms.component.ts | 20 +- .../feature-info/feature-info.service.spec.ts | 33 -- .../lib/feature-info/feature-info.service.ts | 42 -- .../feature/map/src/lib/feature-map.module.ts | 22 +- .../src/lib/geocoding/geocoding.component.ts | 35 +- .../layers-panel.component.spec.ts | 13 +- .../layers-panel/layers-panel.component.ts | 20 +- .../map-state-container.component.css | 0 .../map-state-container.component.html | 4 + .../map-state-container.component.spec.ts | 79 ++++ .../map-state-container.component.ts | 29 ++ .../src/lib/utils/map-utils.service.spec.ts | 402 +----------------- .../map/src/lib/utils/map-utils.service.ts | 230 +--------- 33 files changed, 420 insertions(+), 1199 deletions(-) delete mode 100644 libs/feature/map/src/lib/+state/map.effects.spec.ts delete mode 100644 libs/feature/map/src/lib/+state/map.effects.ts delete mode 100644 libs/feature/map/src/lib/+state/map.models.ts delete mode 100644 libs/feature/map/src/lib/feature-info/feature-info.service.spec.ts delete mode 100644 libs/feature/map/src/lib/feature-info/feature-info.service.ts create mode 100644 libs/feature/map/src/lib/map-state-container/map-state-container.component.css create mode 100644 libs/feature/map/src/lib/map-state-container/map-state-container.component.html create mode 100644 libs/feature/map/src/lib/map-state-container/map-state-container.component.spec.ts create mode 100644 libs/feature/map/src/lib/map-state-container/map-state-container.component.ts diff --git a/libs/feature/map/src/index.ts b/libs/feature/map/src/index.ts index 826ed9d9a5..fb179415c9 100644 --- a/libs/feature/map/src/index.ts +++ b/libs/feature/map/src/index.ts @@ -1,20 +1,13 @@ export * from './lib/+state/map.facade' -export * from './lib/+state/map.models' export * from './lib/+state/map.selectors' export * from './lib/+state/map.reducer' export * from './lib/+state/map.actions' export * from './lib/feature-map.module' -export * from './lib/manager/map-manager.service' -export * from './lib/manager/map-instance.directive' -export * from './lib/feature-info/feature-info.service' -export * from './lib/map-context/map-context.model' -export * from './lib/map-context/map-context.service' -export * from './lib/map-context/component/map-context.component' +export * from './lib/map-state-container/map-state-container.component' export * from './lib/constant' export * from './lib/utils' export * from './lib/style' export * from './lib/layers-panel/layers-panel.component' export * from './lib/add-layer-from-catalog/add-layer-from-catalog.component' export * from './lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component' -export * from './lib/map-container/map-container.component' export * from './lib/geocoding/geocoding.component' diff --git a/libs/feature/map/src/lib/+state/map.actions.ts b/libs/feature/map/src/lib/+state/map.actions.ts index 9ac4249534..c298c5e1e9 100644 --- a/libs/feature/map/src/lib/+state/map.actions.ts +++ b/libs/feature/map/src/lib/+state/map.actions.ts @@ -1,32 +1,17 @@ import { createAction, props } from '@ngrx/store' -import { MapLayer } from './map.models' +import { MapContext } from '@geospatial-sdk/core' +import type { Feature } from 'geojson' -export const addLayer = createAction( - '[Map] Add Layer', - props<{ layer: MapLayer; atIndex?: number }>() +export const setContext = createAction( + '[Map] Set Context', + props<{ context: MapContext }>() ) -export const removeLayer = createAction( - '[Map] Remove Layer', - props<{ index: number }>() +export const setSelectedFeatures = createAction( + '[Map] Set Selected Features', + props<{ selectedFeatures: Feature[] }>() ) -export const updateLayer = createAction( - '[Map] Update Layer', - props<{ updatedLayer: MapLayer; index: number }>() -) - -export const changeLayerOrder = createAction( - '[Map] Change Layer Order', - props<{ currentIndex: number; newIndex: number }>() -) - -export const setLayerError = createAction( - '[Map] Set Layer Error', - props<{ index: number; error: string }>() -) - -export const clearLayerError = createAction( - '[Map] Clear Layer Error', - props<{ index: number }>() +export const clearSelectedFeatures = createAction( + '[Map] Clear Selected Features' ) diff --git a/libs/feature/map/src/lib/+state/map.effects.spec.ts b/libs/feature/map/src/lib/+state/map.effects.spec.ts deleted file mode 100644 index 291bdd5a64..0000000000 --- a/libs/feature/map/src/lib/+state/map.effects.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { TestBed } from '@angular/core/testing' -import { provideMockActions } from '@ngrx/effects/testing' -import { Action } from '@ngrx/store' -import { provideMockStore } from '@ngrx/store/testing' -import { Observable } from 'rxjs' -import { MapEffects } from './map.effects' - -describe('MapEffects', () => { - let actions: Observable - let effects: MapEffects - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - MapEffects, - provideMockActions(() => actions), - provideMockStore(), - ], - }) - - effects = TestBed.inject(MapEffects) - }) - - it('placeholder', () => { - expect(true).toBe(true) - }) -}) diff --git a/libs/feature/map/src/lib/+state/map.effects.ts b/libs/feature/map/src/lib/+state/map.effects.ts deleted file mode 100644 index 6056d881da..0000000000 --- a/libs/feature/map/src/lib/+state/map.effects.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Injectable } from '@angular/core' -import { Actions } from '@ngrx/effects' - -@Injectable() -export class MapEffects { - constructor(private readonly actions$: Actions) {} -} diff --git a/libs/feature/map/src/lib/+state/map.facade.spec.ts b/libs/feature/map/src/lib/+state/map.facade.spec.ts index f0578ccb5b..17e13edd1d 100644 --- a/libs/feature/map/src/lib/+state/map.facade.spec.ts +++ b/libs/feature/map/src/lib/+state/map.facade.spec.ts @@ -2,11 +2,10 @@ import { NgModule } from '@angular/core' import { TestBed } from '@angular/core/testing' import { EffectsModule } from '@ngrx/effects' import { Store, StoreModule } from '@ngrx/store' -import { readFirst } from '@nx/angular/testing' -import { MapEffects } from './map.effects' import { MapFacade } from './map.facade' +import * as MapActions from './map.actions' import { MAP_FEATURE_KEY, mapReducer, MapState } from './map.reducer' -import { mapCtxLayerWmsFixture } from '../map-context/map-context.fixtures' +import { mapCtxLayerWmsFixture } from '@geonetwork-ui/common/fixtures' interface TestSchema { map: MapState @@ -19,10 +18,7 @@ describe('MapFacade', () => { describe('used in NgModule', () => { beforeEach(() => { @NgModule({ - imports: [ - StoreModule.forFeature(MAP_FEATURE_KEY, mapReducer), - EffectsModule.forFeature([MapEffects]), - ], + imports: [StoreModule.forFeature(MAP_FEATURE_KEY, mapReducer)], providers: [MapFacade], }) class CustomFeatureModule {} @@ -41,31 +37,18 @@ describe('MapFacade', () => { facade = TestBed.inject(MapFacade) }) - describe('layers$ / addLayer / addLayerAtIndex', () => { - it('emits the list of layers after each change', async () => { - let list = await readFirst(facade.layers$) - expect(list.length).toBe(0) - - facade.addLayer({ - ...mapCtxLayerWmsFixture(), - title: 'world', - }) - - list = await readFirst(facade.layers$) - expect(list.length).toBe(1) - expect(list.map((l) => l.title)).toEqual(['world']) + describe('setContext', () => { + it('dispatches a setContext action', async () => { + const spy = jest.spyOn(store, 'dispatch') + const context = { + layers: [mapCtxLayerWmsFixture()], + view: null, + } - facade.addLayerAtIndex( - { - ...mapCtxLayerWmsFixture(), - title: 'hello', - }, - 0 - ) + facade.applyContext(context) - list = await readFirst(facade.layers$) - expect(list.length).toBe(2) - expect(list.map((l) => l.title)).toEqual(['hello', 'world']) + const action = MapActions.setContext({ context }) + expect(spy).toHaveBeenCalledWith(action) }) }) }) diff --git a/libs/feature/map/src/lib/+state/map.facade.ts b/libs/feature/map/src/lib/+state/map.facade.ts index efa9ee6ccf..cef7e2c759 100644 --- a/libs/feature/map/src/lib/+state/map.facade.ts +++ b/libs/feature/map/src/lib/+state/map.facade.ts @@ -1,30 +1,26 @@ import { Injectable } from '@angular/core' import { select, Store } from '@ngrx/store' import * as MapSelectors from './map.selectors' -import { MapLayer } from './map.models' import * as MapActions from './map.actions' +import { MapContext } from '@geospatial-sdk/core' +import { Feature } from 'geojson' @Injectable() export class MapFacade { - layers$ = this.store.pipe(select(MapSelectors.getMapLayers)) + context$ = this.store.pipe(select(MapSelectors.getMapContext)) + selectedFeatures$ = this.store.pipe(select(MapSelectors.getSelectedFeatures)) constructor(private readonly store: Store) {} - /** - * Use the initialization action to perform one - * or more tasks in your Effects. - */ - init() { - // placeholder + applyContext(context: MapContext) { + this.store.dispatch(MapActions.setContext({ context })) } - addLayer(layer: MapLayer) { - this.store.dispatch(MapActions.addLayer({ layer })) + selectFeatures(selectedFeatures: Feature[]) { + this.store.dispatch(MapActions.setSelectedFeatures({ selectedFeatures })) } - addLayerAtIndex(layer: MapLayer, index: number) { - this.store.dispatch(MapActions.addLayer({ layer, atIndex: index })) - } - removeLayer(index: number) { - this.store.dispatch(MapActions.removeLayer({ index })) + + clearFeatureSelection() { + this.store.dispatch(MapActions.clearSelectedFeatures()) } } diff --git a/libs/feature/map/src/lib/+state/map.models.ts b/libs/feature/map/src/lib/+state/map.models.ts deleted file mode 100644 index f27110b8d2..0000000000 --- a/libs/feature/map/src/lib/+state/map.models.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { MapContextLayerModel } from '../map-context/map-context.model' - -export type MapLayer = MapContextLayerModel & { - title: string -} -export type MapLayerWithInfo = MapLayer & { - error: string | null - loading: boolean -} diff --git a/libs/feature/map/src/lib/+state/map.reducer.spec.ts b/libs/feature/map/src/lib/+state/map.reducer.spec.ts index 7f00a59ded..fabc274ee5 100644 --- a/libs/feature/map/src/lib/+state/map.reducer.spec.ts +++ b/libs/feature/map/src/lib/+state/map.reducer.spec.ts @@ -1,12 +1,10 @@ -import { Action } from '@ngrx/store' - import * as MapActions from './map.actions' import { initialMapState, mapReducer, MapState } from './map.reducer' -import { mapCtxLayerWmsFixture } from '../map-context/map-context.fixtures' -import { MapLayerWithInfo } from './map.models' +import { MapContextLayer } from '@geospatial-sdk/core' +import { mapCtxLayerWmsFixture } from '@geonetwork-ui/common/fixtures' -function getSampleLayer(title: string): MapLayerWithInfo { - return { ...mapCtxLayerWmsFixture(), loading: false, error: null, title } +function getSampleLayer(label: string): MapContextLayer { + return { ...mapCtxLayerWmsFixture(), label } } describe('Map Reducer', () => { @@ -15,119 +13,27 @@ describe('Map Reducer', () => { beforeEach(() => { initialState = { ...initialMapState, - layers: [ - getSampleLayer('first'), - getSampleLayer('second'), - getSampleLayer('third'), - ], } }) - describe('addLayer', () => { + describe('setContext', () => { it('should add a layer at the end of the list if no index specified', () => { - const action = MapActions.addLayer({ layer: getSampleLayer('test') }) - const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.title)).toEqual([ - 'first', - 'second', - 'third', - 'test', - ]) - }) - it('should add a layer at a specific index if specified', () => { - const action = MapActions.addLayer({ - layer: getSampleLayer('test'), - atIndex: 0, - }) - const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.title)).toEqual([ - 'test', - 'first', - 'second', - 'third', - ]) - }) - }) - - describe('removeLayer', () => { - it('should remove a layer at the specified index', () => { - const action = MapActions.removeLayer({ index: 2 }) - const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.title)).toEqual(['first', 'second']) - }) - }) - - describe('updateLayer', () => { - it('should update a layer at the specified index', () => { - const action = MapActions.updateLayer({ - updatedLayer: getSampleLayer('updated'), - index: 1, + const action = MapActions.setContext({ + context: { + ...initialMapState.context, + layers: [ + getSampleLayer('first'), + getSampleLayer('second'), + getSampleLayer('third'), + ], + }, }) const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.title)).toEqual([ + expect(result.context.layers.map((l) => l.label)).toEqual([ 'first', - 'updated', - 'third', - ]) - }) - }) - - describe('changeLayerOrder', () => { - it('should reorder the array of layers (current index > new index)', () => { - const action = MapActions.changeLayerOrder({ - currentIndex: 1, - newIndex: 0, - }) - const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.title)).toEqual([ 'second', - 'first', - 'third', - ]) - }) - it('should reorder the array of layers (current index < new index)', () => { - const action = MapActions.changeLayerOrder({ - currentIndex: 1, - newIndex: 2, - }) - const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.title)).toEqual([ - 'first', 'third', - 'second', - ]) - }) - }) - - describe('setLayerError', () => { - it('should set the error on a layer', () => { - const action = MapActions.setLayerError({ - index: 2, - error: 'something went wrong', - }) - const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.error)).toEqual([ - null, - null, - 'something went wrong', ]) }) }) - - describe('clearLayerError', () => { - it('should clear the error on a layer', () => { - initialState.layers[1].error = 'oopsie' - const action = MapActions.clearLayerError({ index: 1 }) - const result: MapState = mapReducer(initialState, action) - expect(result.layers.map((l) => l.error)).toEqual([null, null, null]) - }) - }) - - describe('unknown action', () => { - it('should return the previous state', () => { - const action = {} as Action - const result = mapReducer(initialState, action) - expect(result).toBe(initialState) - }) - }) }) diff --git a/libs/feature/map/src/lib/+state/map.reducer.ts b/libs/feature/map/src/lib/+state/map.reducer.ts index 77c114853d..0d3eac9fbc 100644 --- a/libs/feature/map/src/lib/+state/map.reducer.ts +++ b/libs/feature/map/src/lib/+state/map.reducer.ts @@ -1,12 +1,13 @@ import { Action, createReducer, on } from '@ngrx/store' - import * as MapActions from './map.actions' -import { MapLayerWithInfo } from './map.models' +import { MapContext } from '@geospatial-sdk/core' +import { Feature } from 'geojson' export const MAP_FEATURE_KEY = 'map' export interface MapState { - layers: MapLayerWithInfo[] + context: MapContext + selectedFeatures: Feature[] } export interface MapPartialState { @@ -14,68 +15,30 @@ export interface MapPartialState { } export const initialMapState: MapState = { - layers: [], + context: { layers: [], view: null }, + selectedFeatures: [], } const reducer = createReducer( initialMapState, - on(MapActions.addLayer, (state, action) => { - const layers: MapLayerWithInfo[] = [...state.layers] - const layerWithInfo = { ...action.layer, loading: false, error: null } - if (!('atIndex' in action)) layers.push(layerWithInfo) - else layers.splice(action.atIndex, 0, layerWithInfo) + on(MapActions.setContext, (state, { context }) => { return { ...state, - layers, + context, } }), - on(MapActions.updateLayer, (state, action) => ({ - ...state, - layers: state.layers.map((layer, index) => - index === action.index - ? { - ...action.updatedLayer, - loading: false, - error: null, - } - : layer - ), - })), - on(MapActions.removeLayer, (state, action) => ({ - ...state, - layers: state.layers.filter((layer, index) => index !== action.index), - })), - on(MapActions.changeLayerOrder, (state, action) => { - const layers: MapLayerWithInfo[] = [...state.layers] - const moved = layers.splice(action.currentIndex, 1)[0] - layers.splice(action.newIndex, 0, moved) + on(MapActions.setSelectedFeatures, (state, { selectedFeatures }) => { return { ...state, - layers, + selectedFeatures, } }), - on(MapActions.setLayerError, (state, action) => ({ - ...state, - layers: state.layers.map((layer, index) => - index === action.index - ? { - ...layer, - error: action.error, - } - : layer - ), - })), - on(MapActions.clearLayerError, (state, action) => ({ - ...state, - layers: state.layers.map((layer, index) => - index === action.index - ? { - ...layer, - error: null, - } - : layer - ), - })) + on(MapActions.clearSelectedFeatures, (state) => { + return { + ...state, + selectedFeatures: [], + } + }) ) export function mapReducer(state: MapState | undefined, action: Action) { diff --git a/libs/feature/map/src/lib/+state/map.selectors.spec.ts b/libs/feature/map/src/lib/+state/map.selectors.spec.ts index eaec9b1655..75d6bf618e 100644 --- a/libs/feature/map/src/lib/+state/map.selectors.spec.ts +++ b/libs/feature/map/src/lib/+state/map.selectors.spec.ts @@ -3,7 +3,7 @@ import * as MapSelectors from './map.selectors' import { mapCtxLayerGeojsonFixture, mapCtxLayerWmsFixture, -} from '../map-context/map-context.fixtures' +} from '@geonetwork-ui/common/fixtures' describe('Map Selectors', () => { let state: MapPartialState @@ -11,29 +11,37 @@ describe('Map Selectors', () => { beforeEach(() => { state = { map: { - layers: [ + context: { + layers: [mapCtxLayerWmsFixture(), mapCtxLayerGeojsonFixture()], + view: null, + }, + selectedFeatures: [ { - ...mapCtxLayerWmsFixture(), - title: 'wms', - error: null, - loading: false, - }, - { - ...mapCtxLayerGeojsonFixture(), - title: 'geojson', - error: null, - loading: false, + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, }, ], }, } }) - describe('getLayers', () => { - it('returns the list of layers', () => { - const results = MapSelectors.getMapLayers(state) - expect(results.length).toBe(2) - expect(results.map((l) => l.title)).toEqual(['wms', 'geojson']) + describe('getMapContext', () => { + it('returns the context', () => { + const result = MapSelectors.getMapContext(state) + expect(result.layers.map((l) => l.type)).toEqual(['wms', 'geojson']) + }) + }) + describe('getSelectedFeatures', () => { + it('returns the selected features', () => { + const result = MapSelectors.getSelectedFeatures(state) + expect(result).toEqual([ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + }, + ]) }) }) }) diff --git a/libs/feature/map/src/lib/+state/map.selectors.ts b/libs/feature/map/src/lib/+state/map.selectors.ts index c2719383e8..ef4623656a 100644 --- a/libs/feature/map/src/lib/+state/map.selectors.ts +++ b/libs/feature/map/src/lib/+state/map.selectors.ts @@ -4,7 +4,12 @@ import { MAP_FEATURE_KEY, MapState } from './map.reducer' // Lookup the 'Map' feature state managed by NgRx export const getMapState = createFeatureSelector(MAP_FEATURE_KEY) -export const getMapLayers = createSelector( +export const getMapContext = createSelector( getMapState, - (state: MapState) => state.layers + (state: MapState) => state.context +) + +export const getSelectedFeatures = createSelector( + getMapState, + (state: MapState) => state.selectedFeatures ) diff --git a/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.spec.ts b/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.spec.ts index c68b8e6fbe..0ec78de466 100644 --- a/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.spec.ts +++ b/libs/feature/map/src/lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component.spec.ts @@ -4,37 +4,29 @@ import { MapFacade } from '../../+state/map.facade' import { aSetOfLinksFixture, datasetRecordsFixture, + mapCtxFixture, } from '@geonetwork-ui/common/fixtures' import { of } from 'rxjs' import { NO_ERRORS_SCHEMA } from '@angular/core' -import { MapUtilsService } from '../../utils' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' - -class MapFacadeMock { - addLayer = jest.fn() -} - -class MapUtilsServiceMock { - getWmtsOptionsFromCapabilities = jest.fn(() => of()) -} +import { MockBuilder, MockProvider } from 'ng-mocks' describe('AddLayerRecordPreviewComponent', () => { let component: AddLayerRecordPreviewComponent let fixture: ComponentFixture let mapFacade: MapFacade + beforeEach(() => { + return MockBuilder(AddLayerRecordPreviewComponent) + }) + beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [AddLayerRecordPreviewComponent], providers: [ - { - provide: MapFacade, - useClass: MapFacadeMock, - }, - { - provide: MapUtilsService, - useClass: MapUtilsServiceMock, - }, + MockProvider(MapFacade, { + context$: of(mapCtxFixture()), + applyContext: jest.fn(), + }), ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents() @@ -55,12 +47,18 @@ describe('AddLayerRecordPreviewComponent', () => { component.handleLinkClick(aSetOfLinksFixture().geodataWms()) }) it('adds a layer', () => { - expect(mapFacade.addLayer).toHaveBeenCalledWith({ - name: 'mylayer', - title: - 'A very interesting dataset (un jeu de données très intéressant)', - type: 'wms', - url: 'https://my.ogc.server/wms', + expect(mapFacade.applyContext).toHaveBeenCalledWith({ + ...mapCtxFixture(), + layers: [ + ...mapCtxFixture().layers, + { + name: 'mylayer', + label: + 'A very interesting dataset (un jeu de données très intéressant)', + type: 'wms', + url: 'https://my.ogc.server/wms', + }, + ], }) }) }) 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 688ad763ba..6cd2766868 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 @@ -5,17 +5,13 @@ import { LinkClassifierService, LinkUsage, } from '@geonetwork-ui/util/shared' -import { Observable, of, throwError } from 'rxjs' -import { MapUtilsService } from '../../utils' +import { firstValueFrom, Observable, of, throwError } from 'rxjs' import { MapFacade } from '../../+state/map.facade' -import { - MapContextLayerModel, - MapContextLayerTypeEnum, -} from '../../map-context/map-context.model' import { DatasetOnlineResource, DatasetRecord, } from '@geonetwork-ui/common/domain/model/record' +import { MapContextLayer } from '@geospatial-sdk/core' import { ThumbnailComponent } from '@geonetwork-ui/ui/elements' import { ButtonComponent } from '@geonetwork-ui/ui/inputs' import { CommonModule } from '@angular/common' @@ -38,20 +34,21 @@ export class AddLayerRecordPreviewComponent extends RecordPreviewComponent { constructor( protected elementRef: ElementRef, private linkClassifier: LinkClassifierService, - private mapFacade: MapFacade, - private mapUtils: MapUtilsService + private mapFacade: MapFacade ) { super(elementRef) } async handleLinkClick(link: DatasetOnlineResource) { - const layer = await this.getLayerFromLink(link).toPromise() - this.mapFacade.addLayer({ ...layer, title: this.record.title }) + const layer = await firstValueFrom(this.getLayerFromLink(link)) + const context = await firstValueFrom(this.mapFacade.context$) + this.mapFacade.applyContext({ + ...context, + layers: [...context.layers, { ...layer, label: this.record.title }], + }) } - getLayerFromLink( - link: DatasetOnlineResource - ): Observable { + getLayerFromLink(link: DatasetOnlineResource): Observable { if (link.type !== 'service') return throwError( () => 'map layer could not be built for this distribution' @@ -59,13 +56,13 @@ export class AddLayerRecordPreviewComponent extends RecordPreviewComponent { if (link.accessServiceProtocol === 'wms') { return of({ url: link.url.toString(), - type: MapContextLayerTypeEnum.WMS, + type: 'wms', name: link.name, }) } else if (link.accessServiceProtocol === 'wmts') { return of({ url: link.url.toString(), - type: MapContextLayerTypeEnum.WMTS, + type: 'wmts', name: link.name, }) } diff --git a/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.spec.ts b/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.spec.ts index a8ee413678..bfa3beb59d 100644 --- a/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.spec.ts +++ b/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.spec.ts @@ -1,7 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { AddLayerFromFileComponent } from './add-layer-from-file.component' import { MapFacade } from '../+state/map.facade' -import { TranslateModule } from '@ngx-translate/core' +import { MockBuilder, MockProvider } from 'ng-mocks' +import { of } from 'rxjs' +import { mapCtxFixture } from '@geonetwork-ui/common/fixtures' class MapFacadeMock { addLayer = jest.fn() @@ -12,14 +14,17 @@ describe('AddLayerFromFileComponent', () => { let fixture: ComponentFixture let mapFacade: MapFacade + beforeEach(() => { + return MockBuilder(AddLayerFromFileComponent) + }) + beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), AddLayerFromFileComponent], providers: [ - { - provide: MapFacade, - useClass: MapFacadeMock, - }, + MockProvider(MapFacade, { + context$: of(mapCtxFixture()), + applyContext: jest.fn(), + }), ], }).compileComponents() @@ -117,10 +122,16 @@ describe('AddLayerFromFileComponent', () => { }) it('should add the layer', () => { - expect(mapFacade.addLayer).toHaveBeenCalledWith({ - type: 'geojson', - title: 'filename', - data: JSON.stringify(data, null, 2), + expect(mapFacade.applyContext).toHaveBeenCalledWith({ + ...mapCtxFixture(), + layers: [ + ...mapCtxFixture().layers, + { + type: 'geojson', + label: 'filename', + data: JSON.stringify(data, null, 2), + }, + ], }) }) }) diff --git a/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.ts b/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.ts index 92d65f6b5b..8725f9f9ce 100644 --- a/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.ts +++ b/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectorRef, Component } from '@angular/core' -import { MapContextLayerModel } from '../map-context/map-context.model' import { MapFacade } from '../+state/map.facade' +import { MapContextLayerGeojson } from '@geospatial-sdk/core' +import { firstValueFrom } from 'rxjs' import { UiInputsModule } from '@geonetwork-ui/ui/inputs' import { TranslateModule } from '@ngx-translate/core' import { CommonModule } from '@angular/common' @@ -65,25 +66,30 @@ export class AddLayerFromFileComponent { } private addGeoJsonLayer(file: File) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { try { const reader = new FileReader() reader.onload = () => { - const result = reader.result as string - const title = file.name.split('.').slice(0, -1).join('.') - const layerToAdd: MapContextLayerModel = { - type: 'geojson', - data: result, - } - this.mapFacade.addLayer({ ...layerToAdd, title: title }) - this.displayMessage('File successfully added to map', 'success') - resolve() + resolve(reader.result as string) } reader.onerror = reject reader.readAsText(file) } catch (error) { reject(error) } + }).then(async (result: string) => { + const context = await firstValueFrom(this.mapFacade.context$) + const title = file.name.split('.').slice(0, -1).join('.') + const layerToAdd: MapContextLayerGeojson = { + type: 'geojson', + data: result, + label: title, + } + this.mapFacade.applyContext({ + ...context, + layers: [...context.layers, layerToAdd], + }) + this.displayMessage('File successfully added to map', 'success') }) } 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 8979e6e2b7..7c71ecaef1 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,8 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { AddLayerFromOgcApiComponent } from './add-layer-from-ogc-api.component' -import { TranslateModule } from '@ngx-translate/core' -import { NO_ERRORS_SCHEMA } from '@angular/core' -import { MapContextLayerTypeEnum } from '../map-context/map-context.model' +import { MockBuilder } from 'ng-mocks' jest.mock('@camptocamp/ogc-client', () => ({ OgcApiEndpoint: class { @@ -79,12 +77,12 @@ describe('AddLayerFromOgcApiComponent', () => { let component: AddLayerFromOgcApiComponent let fixture: ComponentFixture + beforeEach(() => { + return MockBuilder(AddLayerFromOgcApiComponent) + }) + beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), AddLayerFromOgcApiComponent], - declarations: [], - schemas: [NO_ERRORS_SCHEMA], - }).compileComponents() + await TestBed.configureTestingModule({}).compileComponents() fixture = TestBed.createComponent(AddLayerFromOgcApiComponent) component = fixture.componentInstance @@ -138,33 +136,32 @@ describe('AddLayerFromOgcApiComponent', () => { const layerAddedSpy = jest.spyOn(component.layerAdded, 'emit') await component.addLayer('layer1', 'features') expect(layerAddedSpy).toHaveBeenCalledWith({ - name: 'layer1', + collection: 'layer1', url: 'http://example.com/collections/layer1/items', - type: MapContextLayerTypeEnum.OGCAPI, - layerType: 'features', - title: 'layer1', + type: 'ogcapi', + label: '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', + collection: 'layer1', url: 'http://example.com/collections/layer1/tiles/vector', - type: MapContextLayerTypeEnum.OGCAPI, - layerType: 'vectorTiles', - title: 'layer1', + type: 'ogcapi', + useTiles: 'vector', + label: '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', + collection: 'layer1', url: 'http://example.com/collections/layer1/tiles/map', - type: MapContextLayerTypeEnum.OGCAPI, - layerType: 'mapTiles', - title: 'layer1', + type: 'ogcapi', + useTiles: 'map', + label: '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 c5f0f03ee4..6656b3a5f8 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 @@ -8,10 +8,6 @@ import { } from '@angular/core' import { OgcApiEndpoint } from '@camptocamp/ogc-client' import { debounceTime, Subject } from 'rxjs' -import { - MapContextLayerModel, - MapContextLayerTypeEnum, -} from '../map-context/map-context.model' import { TranslateModule } from '@ngx-translate/core' import { DropdownChoice, @@ -19,7 +15,7 @@ import { UiInputsModule, } from '@geonetwork-ui/ui/inputs' import { CommonModule } from '@angular/common' -import { MapLayer } from '../+state/map.models' +import { MapContextLayer, MapContextLayerOgcApi } from '@geospatial-sdk/core' @Component({ selector: 'gn-ui-add-layer-from-ogc-api', @@ -30,7 +26,7 @@ import { MapLayer } from '../+state/map.models' }) export class AddLayerFromOgcApiComponent implements OnInit { @Input() ogcUrl: string - @Output() layerAdded = new EventEmitter() + @Output() layerAdded = new EventEmitter() urlChange = new Subject() loading = false @@ -112,24 +108,27 @@ export class AddLayerFromOgcApiComponent implements OnInit { try { const ogcEndpoint = await new OgcApiEndpoint(this.ogcUrl) let layerUrl: string + let useTiles: MapContextLayerOgcApi['useTiles'] if (layerType === 'vectorTiles') { layerUrl = await ogcEndpoint.getVectorTilesetUrl(layer) + useTiles = 'vector' } else if (layerType === 'mapTiles') { layerUrl = await ogcEndpoint.getMapTilesetUrl(layer) + useTiles = 'map' } else { layerUrl = await ogcEndpoint.getCollectionItemsUrl(layer, { outputFormat: 'json', }) } - const layerToAdd: MapContextLayerModel = { - name: layer, + this.layerAdded.emit({ url: layerUrl, - type: MapContextLayerTypeEnum.OGCAPI, - layerType: layerType, - } - this.layerAdded.emit({ ...layerToAdd, title: layer }) + type: 'ogcapi', + collection: layer, + ...(useTiles && { useTiles }), + label: layer, + }) } catch (error) { const err = error as Error console.error('Error adding layer:', err.message) diff --git a/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.spec.ts b/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.spec.ts index 6a4b22dce6..dd864b6334 100644 --- a/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.spec.ts +++ b/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.spec.ts @@ -1,8 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { AddLayerFromWfsComponent } from './add-layer-from-wfs.component' import { MapFacade } from '../+state/map.facade' -import { TranslateModule } from '@ngx-translate/core' import { By } from '@angular/platform-browser' +import { MockBuilder, MockProvider } from 'ng-mocks' +import { of } from 'rxjs' +import { mapCtxFixture } from '@geonetwork-ui/common/fixtures' jest.mock('@camptocamp/ogc-client', () => ({ WfsEndpoint: class { @@ -37,23 +39,22 @@ jest.mock('@camptocamp/ogc-client', () => ({ }, })) -class MapFacadeMock { - addLayer = jest.fn() -} - describe('AddLayerFromWfsComponent', () => { let component: AddLayerFromWfsComponent let fixture: ComponentFixture let mapFacade: MapFacade + beforeEach(() => { + return MockBuilder(AddLayerFromWfsComponent) + }) + beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), AddLayerFromWfsComponent], providers: [ - { - provide: MapFacade, - useClass: MapFacadeMock, - }, + MockProvider(MapFacade, { + applyContext: jest.fn(), + context$: of(mapCtxFixture()), + }), ], }).compileComponents() @@ -153,11 +154,17 @@ describe('AddLayerFromWfsComponent', () => { }) }) it('should add the selected layer in the current map context', () => { - expect(mapFacade.addLayer).toHaveBeenCalledWith({ - name: 'ft1', - title: 'Feature Type 1', - url: 'http://my.service.org/wfs', - type: 'wfs', + expect(mapFacade.applyContext).toHaveBeenCalledWith({ + ...mapCtxFixture(), + layers: [ + ...mapCtxFixture().layers, + { + featureType: 'ft1', + label: 'Feature Type 1', + url: 'http://my.service.org/wfs', + type: 'wfs', + }, + ], }) }) }) diff --git a/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.ts b/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.ts index b615579173..025766d3aa 100644 --- a/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.ts +++ b/libs/feature/map/src/lib/add-layer-from-wfs/add-layer-from-wfs.component.ts @@ -1,12 +1,9 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core' import { WfsEndpoint, WfsFeatureTypeBrief } from '@camptocamp/ogc-client' -import { Subject } from 'rxjs' -import { - MapContextLayerModel, - MapContextLayerTypeEnum, -} from '../map-context/map-context.model' +import { firstValueFrom, Subject } from 'rxjs' import { MapFacade } from '../+state/map.facade' import { debounceTime } from 'rxjs/operators' +import { MapContextLayer } from '@geospatial-sdk/core' import { ButtonComponent, TextInputComponent } from '@geonetwork-ui/ui/inputs' import { TranslateModule } from '@ngx-translate/core' import { CommonModule } from '@angular/common' @@ -58,12 +55,17 @@ export class AddLayerFromWfsComponent implements OnInit { } } - addLayer(layer: WfsFeatureTypeBrief) { - const layerToAdd: MapContextLayerModel = { - name: layer.name, + async addLayer(layer: WfsFeatureTypeBrief) { + const context = await firstValueFrom(this.mapFacade.context$) + const layerToAdd: MapContextLayer = { + featureType: layer.name, url: this.wfsUrl.toString(), - type: MapContextLayerTypeEnum.WFS, + type: 'wfs', + label: layer.title, } - this.mapFacade.addLayer({ ...layerToAdd, title: layer.title }) + this.mapFacade.applyContext({ + ...context, + layers: [...context.layers, layerToAdd], + }) } } diff --git a/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.spec.ts b/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.spec.ts index d71caab404..f015676a5d 100644 --- a/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.spec.ts +++ b/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.spec.ts @@ -1,9 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { AddLayerFromWmsComponent } from './add-layer-from-wms.component' import { MapFacade } from '../+state/map.facade' -import { NO_ERRORS_SCHEMA } from '@angular/core' -import { TranslateModule } from '@ngx-translate/core' import { By } from '@angular/platform-browser' +import { MockBuilder, MockProvider } from 'ng-mocks' +import { of } from 'rxjs' +import { mapCtxFixture } from '@geonetwork-ui/common/fixtures' jest.mock('@camptocamp/ogc-client', () => ({ WmsEndpoint: class { @@ -39,25 +40,23 @@ jest.mock('@camptocamp/ogc-client', () => ({ }, })) -class MapFacadeMock { - addLayer = jest.fn() -} - describe('AddLayerFromWmsComponent', () => { let component: AddLayerFromWmsComponent let fixture: ComponentFixture let mapFacade: MapFacade + beforeEach(() => { + return MockBuilder(AddLayerFromWmsComponent) + }) + beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), AddLayerFromWmsComponent], providers: [ - { - provide: MapFacade, - useClass: MapFacadeMock, - }, + MockProvider(MapFacade, { + context$: of(mapCtxFixture()), + applyContext: jest.fn(), + }), ], - schemas: [NO_ERRORS_SCHEMA], }).compileComponents() mapFacade = TestBed.inject(MapFacade) @@ -153,11 +152,17 @@ describe('AddLayerFromWmsComponent', () => { }) }) it('adds the selected layer in the current map context', () => { - expect(mapFacade.addLayer).toHaveBeenCalledWith({ - name: 'myLayer', - title: 'My Layer', - type: 'wms', - url: 'http://my.service.org/wms', + expect(mapFacade.applyContext).toHaveBeenCalledWith({ + ...mapCtxFixture(), + layers: [ + ...mapCtxFixture().layers, + { + name: 'myLayer', + label: 'My Layer', + type: 'wms', + url: 'http://my.service.org/wms', + }, + ], }) }) }) diff --git a/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.ts b/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.ts index 21223949ab..aeebe3ef4a 100644 --- a/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.ts +++ b/libs/feature/map/src/lib/add-layer-from-wms/add-layer-from-wms.component.ts @@ -1,12 +1,9 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core' import { WmsEndpoint, WmsLayerSummary } from '@camptocamp/ogc-client' import { MapFacade } from '../+state/map.facade' -import { - MapContextLayerModel, - MapContextLayerTypeEnum, -} from '../map-context/map-context.model' -import { Subject } from 'rxjs' +import { firstValueFrom, Subject } from 'rxjs' import { debounceTime } from 'rxjs/operators' +import { MapContextLayer } from '@geospatial-sdk/core' import { ButtonComponent, TextInputComponent } from '@geonetwork-ui/ui/inputs' import { CommonModule } from '@angular/common' import { TranslateModule } from '@ngx-translate/core' @@ -57,12 +54,17 @@ export class AddLayerFromWmsComponent implements OnInit { } } - addLayer(layer: WmsLayerSummary) { - const layerToAdd: MapContextLayerModel = { + async addLayer(layer: WmsLayerSummary) { + const context = await firstValueFrom(this.mapFacade.context$) + const layerToAdd: MapContextLayer = { name: layer.name, url: this.wmsUrl.toString(), - type: MapContextLayerTypeEnum.WMS, + type: 'wms', + label: layer.title, } - this.mapFacade.addLayer({ ...layerToAdd, title: layer.title }) + this.mapFacade.applyContext({ + ...context, + layers: [...context.layers, layerToAdd], + }) } } diff --git a/libs/feature/map/src/lib/feature-info/feature-info.service.spec.ts b/libs/feature/map/src/lib/feature-info/feature-info.service.spec.ts deleted file mode 100644 index c0d3ba8f2b..0000000000 --- a/libs/feature/map/src/lib/feature-info/feature-info.service.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { TestBed } from '@angular/core/testing' -import { MapManagerService } from '../manager/map-manager.service' -import { MapUtilsService } from '../utils/map-utils.service' - -import { FeatureInfoService } from './feature-info.service' - -const utils = { - createEmptyMap: jest.fn, -} -const manager = {} -describe('FeatureInfoService', () => { - let service: FeatureInfoService - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - { - provide: MapUtilsService, - useValue: utils, - }, - { - provide: MapManagerService, - useValue: manager, - }, - ], - }) - service = TestBed.inject(FeatureInfoService) - }) - - it('should be created', () => { - expect(service).toBeTruthy() - }) -}) diff --git a/libs/feature/map/src/lib/feature-info/feature-info.service.ts b/libs/feature/map/src/lib/feature-info/feature-info.service.ts deleted file mode 100644 index 5c5f6d794c..0000000000 --- a/libs/feature/map/src/lib/feature-info/feature-info.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { EventEmitter, Injectable } from '@angular/core' -import Feature from 'ol/Feature' -import { Geometry } from 'ol/geom' -import { forkJoin, Observable, of } from 'rxjs' -import { MapManagerService } from '../manager/map-manager.service' -import { MapUtilsService } from '../utils/map-utils.service' - -@Injectable({ - providedIn: 'root', -}) -export class FeatureInfoService { - features$ = new EventEmitter[]>() - - constructor( - private manager: MapManagerService, - private mapUtils: MapUtilsService - ) {} - - handleFeatureInfo(): void { - const { map } = this.manager - map.on('click', (event) => { - const gfiFeaturesObservables = - this.mapUtils.getGFIFeaturesObservablesFromClick(map, event) - const vectorFeatures$ = of( - this.mapUtils.getVectorFeaturesFromClick(map, event) - ) - - const featuresObservablesArray: Observable[]>[] = [ - ...gfiFeaturesObservables, - vectorFeatures$, - ] - - forkJoin(...featuresObservablesArray).subscribe((featuresArrays) => { - const allFeatures = featuresArrays.reduce( - (outputFeatures, features) => [...outputFeatures, ...features], - [] - ) - this.features$.emit(allFeatures) - }) - }) - } -} diff --git a/libs/feature/map/src/lib/feature-map.module.ts b/libs/feature/map/src/lib/feature-map.module.ts index 6809f680ae..9b795377d2 100644 --- a/libs/feature/map/src/lib/feature-map.module.ts +++ b/libs/feature/map/src/lib/feature-map.module.ts @@ -1,20 +1,14 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' -import { UiMapModule } from '@geonetwork-ui/ui/map' import { defaultMapOptions, FEATURE_MAP_OPTIONS } from './constant' -import { MapInstanceDirective } from './manager/map-instance.directive' -import { MapContextComponent } from './map-context/component/map-context.component' import { UiLayoutModule } from '@geonetwork-ui/ui/layout' import { MatIconModule } from '@angular/material/icon' import { MatTabsModule } from '@angular/material/tabs' import { TranslateModule } from '@ngx-translate/core' import { FeatureSearchModule } from '@geonetwork-ui/feature/search' import { StoreModule } from '@ngrx/store' -import { EffectsModule } from '@ngrx/effects' import * as fromMap from './+state/map.reducer' -import { MapEffects } from './+state/map.effects' import { MapFacade } from './+state/map.facade' -import { MapContainerComponent } from './map-container/map-container.component' import { UiElementsModule } from '@geonetwork-ui/ui/elements' import { TextInputComponent, UiInputsModule } from '@geonetwork-ui/ui/inputs' import { GeocodingComponent } from './geocoding/geocoding.component' @@ -22,28 +16,16 @@ import { GEOCODING_PROVIDER, GeocodingProvider } from './geocoding.service' import { AddLayerFromOgcApiComponent } from './add-layer-from-ogc-api/add-layer-from-ogc-api.component' @NgModule({ - declarations: [ - MapContextComponent, - MapInstanceDirective, - MapContainerComponent, - GeocodingComponent, - ], - exports: [ - MapContextComponent, - MapInstanceDirective, - MapContainerComponent, - GeocodingComponent, - ], + declarations: [GeocodingComponent], + exports: [GeocodingComponent], imports: [ CommonModule, - UiMapModule, UiLayoutModule, MatIconModule, MatTabsModule, TranslateModule, FeatureSearchModule, StoreModule.forFeature(fromMap.MAP_FEATURE_KEY, fromMap.mapReducer), - EffectsModule.forFeature([MapEffects]), UiElementsModule, UiInputsModule, AddLayerFromOgcApiComponent, diff --git a/libs/feature/map/src/lib/geocoding/geocoding.component.ts b/libs/feature/map/src/lib/geocoding/geocoding.component.ts index 20794964b9..551b3243e6 100644 --- a/libs/feature/map/src/lib/geocoding/geocoding.component.ts +++ b/libs/feature/map/src/lib/geocoding/geocoding.component.ts @@ -1,10 +1,8 @@ import { Component, OnDestroy } from '@angular/core' -import { catchError, from, Subject, takeUntil } from 'rxjs' +import { catchError, firstValueFrom, from, Subject, takeUntil } from 'rxjs' import { debounceTime, switchMap } from 'rxjs/operators' -import { MapManagerService } from '../manager/map-manager.service' -import { fromLonLat } from 'ol/proj' -import { Polygon } from 'ol/geom' import { GeocodingService } from '../geocoding.service' +import { MapFacade } from '../+state/map.facade' @Component({ selector: 'gn-ui-geocoding', @@ -19,7 +17,7 @@ export class GeocodingComponent implements OnDestroy { errorMessage: string | null = null constructor( - private mapManager: MapManagerService, + private mapFacade: MapFacade, private geocodingService: GeocodingService ) { this.searchTextChanged @@ -63,31 +61,30 @@ export class GeocodingComponent implements OnDestroy { } zoomToLocation(result: any) { - const map = this.mapManager.map - const view = map.getView() const geometry = result.geom if (geometry.type === 'Point') { - this.zoomToPoint(geometry.coordinates, view) + this.zoomToPoint(geometry.coordinates) } else if (geometry.type === 'Polygon') { - this.zoomToPolygon(geometry.coordinates, view) + this.zoomToPolygon(geometry.coordinates) } else { console.error(`Unsupported geometry type: ${geometry.type}`) } } - zoomToPoint(pointCoords: [number, number], view: any) { - const transformedCoords = fromLonLat(pointCoords) - view.setCenter(transformedCoords) - view.setZoom(12) + async zoomToPoint(pointCoords: [number, number]) { + const context = await firstValueFrom(this.mapFacade.context$) + this.mapFacade.applyContext({ + ...context, + // TODO: change context to fit point + }) } - zoomToPolygon(polygonCoords: [[number, number][]], view: any) { - const transformedCoords = polygonCoords[0].map((coord) => fromLonLat(coord)) - const polygon = new Polygon([transformedCoords]) - view.fit(polygon, { - duration: 100, - maxZoom: 12, + async zoomToPolygon(polygonCoords: [[number, number][]]) { + const context = await firstValueFrom(this.mapFacade.context$) + this.mapFacade.applyContext({ + ...context, + // TODO: change context to fit polygon }) } diff --git a/libs/feature/map/src/lib/layers-panel/layers-panel.component.spec.ts b/libs/feature/map/src/lib/layers-panel/layers-panel.component.spec.ts index 2861760b83..f0960c1edb 100644 --- a/libs/feature/map/src/lib/layers-panel/layers-panel.component.spec.ts +++ b/libs/feature/map/src/lib/layers-panel/layers-panel.component.spec.ts @@ -1,14 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { LayersPanelComponent } from './layers-panel.component' import { of } from 'rxjs' -import { mapCtxLayerXyzFixture } from '../map-context/map-context.fixtures' import { MockBuilder, MockProvider } from 'ng-mocks' import { MapFacade } from '../+state/map.facade' - -class MapFacadeMock { - layers$ = of([mapCtxLayerXyzFixture()]) - removeLayer = jest.fn() -} +import { mapCtxFixture } from '@geonetwork-ui/common/fixtures' describe('LayersPanelComponent', () => { let component: LayersPanelComponent @@ -21,7 +16,11 @@ describe('LayersPanelComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [LayersPanelComponent], - providers: [MockProvider(MapFacade, MapFacadeMock, 'useClass')], + providers: [ + MockProvider(MapFacade, { + context$: of(mapCtxFixture()), + }), + ], }).compileComponents() }) 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 1f9e7a2c93..a84656ef56 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 @@ -1,5 +1,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' import { MapFacade } from '../+state/map.facade' +import { firstValueFrom, map } from 'rxjs' +import { MapContextLayer } from '@geospatial-sdk/core' import { MatIconModule } from '@angular/material/icon' import { UiLayoutModule } from '@geonetwork-ui/ui/layout' import { MatTabsModule } from '@angular/material/tabs' @@ -31,15 +33,23 @@ import { CommonModule } from '@angular/common' ], }) export class LayersPanelComponent { - layers$ = this.mapFacade.layers$ + layers$ = this.mapFacade.context$.pipe(map((context) => context.layers)) ogcUrl = '' constructor(private mapFacade: MapFacade) {} - deleteLayer(index: number) { - this.mapFacade.removeLayer(index) + async deleteLayer(index: number) { + const context = await firstValueFrom(this.mapFacade.context$) + this.mapFacade.applyContext({ + ...context, + layers: context.layers.filter((_, i) => i !== index), + }) } - addLayer(layer) { - this.mapFacade.addLayer(layer) + async addLayer(layer: MapContextLayer) { + const context = await firstValueFrom(this.mapFacade.context$) + this.mapFacade.applyContext({ + ...context, + layers: [...context.layers, layer], + }) } } diff --git a/libs/feature/map/src/lib/map-state-container/map-state-container.component.css b/libs/feature/map/src/lib/map-state-container/map-state-container.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/feature/map/src/lib/map-state-container/map-state-container.component.html b/libs/feature/map/src/lib/map-state-container/map-state-container.component.html new file mode 100644 index 0000000000..37cd4a268f --- /dev/null +++ b/libs/feature/map/src/lib/map-state-container/map-state-container.component.html @@ -0,0 +1,4 @@ + diff --git a/libs/feature/map/src/lib/map-state-container/map-state-container.component.spec.ts b/libs/feature/map/src/lib/map-state-container/map-state-container.component.spec.ts new file mode 100644 index 0000000000..506c8d207c --- /dev/null +++ b/libs/feature/map/src/lib/map-state-container/map-state-container.component.spec.ts @@ -0,0 +1,79 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { MapStateContainerComponent } from './map-state-container.component' +import { MapFacade } from '../+state/map.facade' +import { of } from 'rxjs' +import { NO_ERRORS_SCHEMA } from '@angular/core' +import { mapCtxLayerXyzFixture } from '@geonetwork-ui/common/fixtures' +import { MockBuilder, MockProvider } from 'ng-mocks' +import { By } from '@angular/platform-browser' +import { MapContainerComponent } from '@geonetwork-ui/ui/map' + +describe('MapContainerComponent', () => { + let component: MapStateContainerComponent + let fixture: ComponentFixture + let mapFacade: MapFacade + + beforeEach(() => { + jest.clearAllMocks() + }) + + beforeEach(() => { + return MockBuilder(MapStateContainerComponent) + }) + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + MockProvider(MapFacade, { + context$: of({ + layers: [mapCtxLayerXyzFixture()], + view: null, + }), + selectFeatures: jest.fn(), + clearFeatureSelection: jest.fn(), + }), + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents() + + mapFacade = TestBed.inject(MapFacade) + fixture = TestBed.createComponent(MapStateContainerComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + describe('context$', () => { + it('gives the context to the map container', async () => { + const mapContainer = fixture.debugElement.query( + By.directive(MapContainerComponent) + ) + expect(mapContainer.componentInstance.context).toStrictEqual({ + layers: [mapCtxLayerXyzFixture()], + view: null, + }) + }) + }) + + describe('handleFeaturesClicked', () => { + it('select features on click', () => { + component.handleFeaturesClicked([ + { type: 'Feature', geometry: null, properties: {} }, + ]) + expect(mapFacade.selectFeatures).toHaveBeenCalledWith([ + { + geometry: null, + properties: {}, + type: 'Feature', + }, + ]) + }) + it('clears feature selection when no features', () => { + component.handleFeaturesClicked([]) + expect(mapFacade.clearFeatureSelection).toHaveBeenCalled() + }) + }) +}) diff --git a/libs/feature/map/src/lib/map-state-container/map-state-container.component.ts b/libs/feature/map/src/lib/map-state-container/map-state-container.component.ts new file mode 100644 index 0000000000..c5f32b577b --- /dev/null +++ b/libs/feature/map/src/lib/map-state-container/map-state-container.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { Observable } from 'rxjs' +import { MapFacade } from '../+state/map.facade' +import { MapContext } from '@geospatial-sdk/core' +import { MapContainerComponent } from '@geonetwork-ui/ui/map' +import { CommonModule } from '@angular/common' +import { Feature } from 'geojson' + +@Component({ + selector: 'gn-ui-map-state-container', + templateUrl: './map-state-container.component.html', + styleUrls: ['./map-state-container.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [MapContainerComponent, CommonModule], +}) +export class MapStateContainerComponent { + context$: Observable = this.mapFacade.context$ + + constructor(private mapFacade: MapFacade) {} + + handleFeaturesClicked(features: Feature[]) { + if (!features.length) { + this.mapFacade.clearFeatureSelection() + return + } + this.mapFacade.selectFeatures(features) + } +} 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 56df49d3c3..295afaf2f8 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 @@ -1,328 +1,12 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing' -import { TestBed } from '@angular/core/testing' -import { polygonFeatureCollectionFixture } from '@geonetwork-ui/common/fixtures' -import Feature from 'ol/Feature' -import { Polygon } from 'ol/geom' -import ImageLayer from 'ol/layer/Image' -import TileLayer from 'ol/layer/Tile' -import Map from 'ol/Map' -import ImageWMS from 'ol/source/ImageWMS' -import TileWMS from 'ol/source/TileWMS' -import XYZ from 'ol/source/XYZ' -import { - dragPanCondition, - MapUtilsService, - mouseWheelZoomCondition, -} from './map-utils.service' -import { - defaults, - DragPan, - DragRotate, - MouseWheelZoom, - PinchRotate, -} from 'ol/interaction' +import { MapUtilsService } from './map-utils.service' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' -import MapBrowserEvent from 'ol/MapBrowserEvent' -import * as olProjProj4 from 'ol/proj/proj4' -import * as olProj from 'ol/proj' -import { fromLonLat, get } from 'ol/proj' - -jest.mock('@camptocamp/ogc-client', () => ({ - WmsEndpoint: class { - constructor(private url) {} - isReady() { - return Promise.resolve({ - getLayerByName: (name) => { - if (name.includes('error')) { - throw new Error('Something went wrong') - } - let boundingBoxes - if (name.includes('nobbox')) { - 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], - } - } else if (name.includes('2154')) { - boundingBoxes = { - '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], - } - } - return { - name, - boundingBoxes, - } - }, - }) - } - }, -})) - -const wmsTileLayer = new TileLayer({ - source: new TileWMS({ - url: 'url', - params: { LAYERS: 'layerName' }, - }), -}) - -const wmsImageLayer = new ImageLayer({ - source: new ImageWMS({ - url: 'url', - params: { LAYERS: 'layerName' }, - }), -}) -const xyzLayer = new TileLayer({ - source: new XYZ({ - url: 'url', - }), -}) +import { fromLonLat } from 'ol/proj' describe('MapUtilsService', () => { let service: MapUtilsService beforeEach(() => { - jest.clearAllMocks() - }) - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }) - service = TestBed.inject(MapUtilsService) - }) - - it('should be created', () => { - expect(service).toBeTruthy() - }) - - describe('#readFeatureCollection', () => { - const collection = polygonFeatureCollectionFixture() - let olFeatures, featureSample: Feature - describe('when no option', () => { - beforeEach(() => { - olFeatures = service.readFeatureCollection(collection) - featureSample = olFeatures[0] - }) - it('returns an array of ol Features', () => { - expect(olFeatures).toBeInstanceOf(Array) - expect(olFeatures.length).toBe(collection.features.length) - expect(olFeatures.length).toBe(collection.features.length) - expect(featureSample).toBeInstanceOf(Feature) - }) - it('output data in 3857', () => { - expect( - featureSample.getGeometry().getLinearRing(0).getFirstCoordinate() - ).toEqual([353183.8433283152, 6448353.725194501]) - }) - }) - describe('when featureProjection = 4326', () => { - beforeEach(() => { - olFeatures = service.readFeatureCollection(collection, 'EPSG:4326') - featureSample = olFeatures[0] - }) - it('output data in 4326', () => { - expect( - featureSample.getGeometry().getLinearRing(0).getFirstCoordinate() - ).toEqual([3.172704445659, 50.011996744997]) - }) - }) - }) - - describe('#isWMSLayer', () => { - let layer - describe('when WMS tile layer', () => { - beforeEach(() => { - layer = wmsTileLayer - }) - it('returns true', () => { - expect(service.isWMSLayer(layer)).toBe(true) - }) - }) - describe('when WMS image layer', () => { - beforeEach(() => { - layer = wmsImageLayer - }) - it('returns true', () => { - expect(service.isWMSLayer(layer)).toBe(true) - }) - }) - describe('when XYZ layer', () => { - beforeEach(() => { - layer = xyzLayer - }) - it('returns false', () => { - expect(service.isWMSLayer(layer)).toBe(false) - }) - }) - }) - - describe('#getGFIUrl', () => { - let url - const coordinate = [-182932.49329334166, 6125319.813853541] - const viewMock = { - getProjection: jest.fn(() => 'EPSG:3857'), - getResolution: jest.fn(() => 30000), - } - const mapMock = { - getView: jest.fn(() => viewMock), - } - beforeEach(() => { - url = service.getGFIUrl(wmsImageLayer, mapMock, coordinate) - }) - it('returns true', () => { - expect(url).toEqual( - 'url?QUERY_LAYERS=layerName&INFO_FORMAT=application%2Fjson&REQUEST=GetFeatureInfo&SERVICE=WMS&VERSION=1.3.0&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=true&LAYERS=layerName&I=50&J=50&WIDTH=101&HEIGHT=101&CRS=EPSG%3A3857&BBOX=-1697932.4932933417%2C4610319.813853541%2C1332067.5067066583%2C7640319.813853541' - ) - }) - }) - - describe('#createEmptyMap', () => { - let map - beforeEach(() => { - map = service.createEmptyMap() - }) - it('creates map', () => { - expect(map).toBeInstanceOf(Map) - }) - it('with no control', () => { - expect(map.getControls().getArray().length).toBe(3) - }) - it('with no layer', () => { - expect(map.getLayers().getArray().length).toBe(0) - }) - }) - - describe('#getLayerExtent', () => { - describe('geojson layer', () => { - let layer - beforeEach(() => { - layer = { - type: 'geojson', - data: polygonFeatureCollectionFixture(), - } - }) - it('returns an observable emitting the aggregated extent', async () => { - const extent = await service.getLayerExtent(layer) - expect(extent).toEqual([ - -571959.6817241046, 5065908.545923665, 1064128.2009725596, - 6636971.049871371, - ]) - }) - }) - describe('geojson layer with invalid geometry', () => { - let layer - beforeEach(() => { - layer = { - type: 'geojson', - data: { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - geometry: { type: 'Point', coordinates: [NaN, NaN] }, - properties: { code: '02', nom: 'Aisne' }, - }, - ], - }, - } - }) - it('returns an observable emitting null', async () => { - const extent = await service.getLayerExtent(layer) - expect(extent).toEqual(null) - }) - }) - describe('WMS layer', () => { - let layer - beforeEach(() => { - jest - .spyOn(olProj, 'transformExtent') - .mockImplementation((extent) => extent) - }) - afterEach(() => { - jest.restoreAllMocks() - }) - - describe('extent available in capabilities', () => { - beforeEach(() => { - layer = { - type: 'wms', - name: 'mock', - url: 'http://mock/wms', - } - }) - it('returns the advertised extent (CRS 84)', async () => { - const extent = await service.getLayerExtent(layer) - expect(extent).toEqual([2.3, 50.6, 2.8, 50.9]) - }) - }) - - describe('bbox in EPSG:4326', () => { - beforeEach(() => { - layer = { - type: 'wms', - name: 'mock_4326', - url: 'http://mock/wms', - } - }) - it('returns EPSG:4326 bbox', async () => { - const extent = await service.getLayerExtent(layer) - expect(extent).toEqual([1, 2.6, 3.3, 4.2]) - }) - }) - describe('no lon lat bbox', () => { - beforeEach(() => { - layer = { - type: 'wms', - name: 'mock_2154', - url: 'http://mock/wms', - } - jest - .spyOn(olProjProj4, 'fromEPSGCode') - .mockImplementation(async () => get('EPSG:4326')) - }) - it('transforms to EPSG:4326 bbox', async () => { - const extent = await service.getLayerExtent(layer) - expect(olProjProj4.fromEPSGCode).toHaveBeenCalledWith('EPSG:2154') - expect(extent).toEqual([650796.4, 7060330.6, 690891.3, 7090402.2]) - }) - }) - describe('no bbox at all', () => { - beforeEach(() => { - layer = { - type: 'wms', - name: 'mock_nobbox', - url: 'http://mock/wms', - } - }) - it('returns the advertised extent', async () => { - const extent = await service.getLayerExtent(layer) - expect(extent).toEqual(null) - }) - }) - describe('error while loading capabilities', () => { - beforeEach(() => { - layer = { - type: 'wms', - name: 'mock_error', - url: 'http://mock/wms', - } - }) - it('returns a translatable error', async () => { - try { - await service.getLayerExtent(layer) - } catch (e) { - const error = e as Error - expect(error.message).toEqual('Something went wrong') - } - }) - }) - }) + service = new MapUtilsService() }) describe('#getRecordExtent', () => { @@ -368,84 +52,4 @@ describe('MapUtilsService', () => { ]) }) }) - - describe('#prioritizePageScroll', () => { - const interactions = defaults() - let dragRotate - let pinchRotate - beforeEach(() => { - service.prioritizePageScroll(interactions) - }) - it('adds condition to DragPan', () => { - const dragPan = interactions - .getArray() - .find((interaction) => interaction instanceof DragPan) - expect(dragPan.condition_).toEqual(dragPanCondition) - }) - it('adds condition to MouseWheelZoom', () => { - const mouseWheelZoom = interactions - .getArray() - .find((interaction) => interaction instanceof MouseWheelZoom) - expect(mouseWheelZoom.condition_).toEqual(mouseWheelZoomCondition) - }) - describe('interactions', () => { - beforeEach(() => { - interactions.forEach((interaction) => { - if (interaction instanceof DragRotate) { - dragRotate = interaction - } - if (interaction instanceof PinchRotate) { - pinchRotate = interaction - } - }) - }) - it('with no DragRotate interaction', () => { - expect(dragRotate).toBeFalsy() - }) - it('with no PinchRotate interaction', () => { - expect(pinchRotate).toBeFalsy() - }) - }) - }) - - describe('#dragPanCondition', () => { - let interaction: DragPan - beforeEach(() => { - interaction = new DragPan() - const map = new Map({}) - map.addInteraction(interaction) - }) - - it('returns true for a left click without modifier key', () => { - const nativeEvent = { - type: 'pointer', - pointerType: 'mouse', - isPrimary: true, - button: 0, - } - const event = new MapBrowserEvent( - 'pointer', - interaction.getMap(), - nativeEvent as PointerEvent - ) - - expect(dragPanCondition.bind(interaction)(event)).toBe(true) - }) - it('returns false for a left click with modifier key', () => { - const nativeEvent = { - type: 'pointer', - pointerType: 'mouse', - isPrimary: true, - button: 0, - shiftKey: true, - } - const event = new MapBrowserEvent( - 'pointer', - interaction.getMap(), - nativeEvent as PointerEvent - ) - - expect(dragPanCondition.bind(interaction)(event)).toBe(false) - }) - }) }) 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 52a0f36c88..04ac5a7f1f 100644 --- a/libs/feature/map/src/lib/utils/map-utils.service.ts +++ b/libs/feature/map/src/lib/utils/map-utils.service.ts @@ -1,42 +1,8 @@ -import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' -import type { FeatureCollection } from 'geojson' -import { extend, Extent, isEmpty } from 'ol/extent' -import Feature from 'ol/Feature' +import { extend, Extent } from 'ol/extent' import GeoJSON from 'ol/format/GeoJSON' -import { Geometry } from 'ol/geom' -import Layer from 'ol/layer/Layer' -import Map from 'ol/Map' import { transformExtent } from 'ol/proj' -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 { defaults, DragPan, Interaction, MouseWheelZoom } from 'ol/interaction' -import { - mouseOnly, - noModifierKeys, - platformModifierKeyOnly, - primaryAction, -} from 'ol/events/condition' -import { Observable } from 'rxjs' -import { map } from 'rxjs/operators' -import { - MapContextLayerModel, - MapContextLayerWmsModel, -} from '../map-context/map-context.model' -import Collection from 'ol/Collection' -import MapBrowserEvent from 'ol/MapBrowserEvent' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' -import { ProxyService } from '@geonetwork-ui/util/shared' -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' -import { defaults as defaultControls } from 'ol/control/defaults' - -const FEATURE_PROJECTION = 'EPSG:3857' -const DATA_PROJECTION = 'EPSG:4326' const GEOJSON = new GeoJSON() @@ -44,178 +10,6 @@ const GEOJSON = new GeoJSON() providedIn: 'root', }) export class MapUtilsService { - constructor(private http: HttpClient, private proxy: ProxyService) {} - - createEmptyMap(): Map { - return new Map({ - controls: defaultControls({ - attribution: true, - attributionOptions: { collapsible: false }, - }), - pixelRatio: 1, - }) - } - - readFeatureCollection = ( - featureCollection: FeatureCollection, - featureProjection = FEATURE_PROJECTION, - dataProjection = DATA_PROJECTION - ): Feature[] => { - return GEOJSON.readFeatures(featureCollection, { - featureProjection, - dataProjection, - }) as Feature[] - } - - isWMSLayer(layer: Layer): boolean { - return ( - layer.getSource() instanceof TileWMS || - layer.getSource() instanceof ImageWMS - ) - } - - getGFIUrl(layer, map, coordinate): string { - const view = map.getView() - const projection = view.getProjection() - const resolution = view.getResolution() - const source = layer.getSource() - const params = { - ...source.getParams(), - INFO_FORMAT: 'application/json', - } - return source.getFeatureInfoUrl(coordinate, resolution, projection, params) - } - - getVectorFeaturesFromClick(olMap, event): Feature[] { - const features = [] - const hit = olMap.forEachFeatureAtPixel( - event.pixel, - (feature: Feature) => { - return feature - }, - { layerFilter: (layer) => layer.getSource() instanceof VectorSource } - ) - if (hit) { - features.push(hit) - } - return features - } - - getGFIFeaturesObservablesFromClick( - olMap: Map, - event: MapBrowserEvent - ): Observable[]>[] { - const wmsLayers = olMap.getLayers().getArray().filter(this.isWMSLayer) - - if (wmsLayers.length > 0) { - const { coordinate } = event - const gfiUrls = wmsLayers.reduce( - (urls, layer) => [...urls, this.getGFIUrl(layer, olMap, coordinate)], - - [] - ) - return gfiUrls.map((url) => - this.http - .get(url) - .pipe(map((collection) => this.readFeatureCollection(collection))) - ) - } else { - return [] - } - } - - /** - * Will emit `null` if no extent could be computed - */ - async getLayerExtent(layer: MapContextLayerModel): Promise { - let latLonExtent: Extent - if ( - layer && - layer.type === 'geojson' && - 'data' in layer && - typeof layer.data === 'object' && - layer.data.features[0] && - layer.data.features[0].geometry - ) { - latLonExtent = new GeoJSON() - .readFeatures(layer.data) - .map((feature) => feature.getGeometry()) - .filter((geom) => !!geom) - .reduce( - (prev, curr) => - prev ? extend(prev, curr.getExtent()) : curr.getExtent(), - null as Extent - ) - } else if (layer && layer.type === 'wms') { - latLonExtent = await this.getWmsLayerExtent(layer) - } else if (layer && layer.type === 'wmts') { - // 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 - } - if (!latLonExtent || isEmpty(latLonExtent)) { - return null - } - return transformExtent(latLonExtent, 'EPSG:4326', 'EPSG:3857') - } - - async getWmsLayerExtent( - layer: MapContextLayerWmsModel - ): Promise { - const endpoint = await new WmsEndpoint( - this.proxy.getProxiedUrl(layer.url) - ).isReady() - const { boundingBoxes } = endpoint.getLayerByName(layer.name) - if (!Object.keys(boundingBoxes).length) { - return null - } - const lonLatCRS = Object.keys(boundingBoxes)?.find((crs) => - LONLAT_CRS_CODES.includes(crs) - ) - if (lonLatCRS) { - return boundingBoxes[lonLatCRS] - } else { - const availableEPSGCode = Object.keys(boundingBoxes)[0] - register(proj4) - const proj = await fromEPSGCode(availableEPSGCode) - return transformExtent( - boundingBoxes[availableEPSGCode], - proj, - 'EPSG:4326' - ) - } - } - - prioritizePageScroll(interactions: Collection) { - interactions.clear() - interactions.extend( - defaults({ - // remove rotate interactions - altShiftDragRotate: false, - pinchRotate: false, - // replace drag and zoom interactions - dragPan: false, - mouseWheelZoom: false, - }) - .extend([ - new DragPan({ - condition: dragPanCondition, - }), - new MouseWheelZoom({ - condition: mouseWheelZoomCondition, - }), - ]) - .getArray() - ) - } - getRecordExtent(record: Partial): Extent { if (!('spatialExtents' in record) || record.spatialExtents.length === 0) { return null @@ -235,25 +29,3 @@ export class MapUtilsService { return transformExtent(totalExtent, 'EPSG:4326', 'EPSG:3857') } } - -export function dragPanCondition( - this: DragPan, - event: MapBrowserEvent -) { - const dragPanCondition = this.getPointerCount() === 2 || mouseOnly(event) - if (!dragPanCondition) { - this.getMap().dispatchEvent('mapmuted') - } - // combine the condition with the default DragPan conditions - return dragPanCondition && noModifierKeys(event) && primaryAction(event) -} - -export function mouseWheelZoomCondition( - this: MouseWheelZoom, - event: MapBrowserEvent -) { - if (!platformModifierKeyOnly(event) && event.type === 'wheel') { - this.getMap().dispatchEvent('mapmuted') - } - return platformModifierKeyOnly(event) -} From e50b1a4088ec3430cf0f5c78334183dd7958e1cd Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 13 Sep 2024 18:13:29 +0200 Subject: [PATCH 05/12] refactor(map): further refactoring to use geo sdk --- apps/datahub/src/app/app.module.ts | 33 +++ apps/map-viewer/src/app/app.component.html | 2 +- apps/map-viewer/src/app/app.component.ts | 9 +- apps/map-viewer/src/app/app.module.ts | 2 + apps/search/src/app/app.module.ts | 2 - .../app/main-search/main-search.component.ts | 2 - .../dataviz/src/lib/feature-dataviz.module.ts | 8 +- .../geo-table-view.component.html | 7 +- .../geo-table-view.component.spec.ts | 118 ++++----- .../geo-table-view.component.stories.ts | 31 ++- .../geo-table-view.component.ts | 115 +++------ libs/feature/map/src/lib/constant/index.ts | 1 - .../map/src/lib/constant/map-options.ts | 17 -- .../feature/map/src/lib/feature-map.module.ts | 5 - .../external-viewer-button.component.spec.ts | 23 +- .../external-viewer-button.component.ts | 45 ++-- .../record/src/lib/feature-record.module.ts | 14 +- .../src/lib/map-view/map-view.component.html | 8 +- .../lib/map-view/map-view.component.spec.ts | 229 +++++++----------- .../src/lib/map-view/map-view.component.ts | 115 ++++----- libs/util/app-config/src/index.ts | 1 + libs/util/app-config/src/lib/map-layers.ts | 31 +++ 22 files changed, 374 insertions(+), 444 deletions(-) delete mode 100644 libs/feature/map/src/lib/constant/map-options.ts create mode 100644 libs/util/app-config/src/lib/map-layers.ts diff --git a/apps/datahub/src/app/app.module.ts b/apps/datahub/src/app/app.module.ts index 0c69562559..5ecdff9612 100644 --- a/apps/datahub/src/app/app.module.ts +++ b/apps/datahub/src/app/app.module.ts @@ -9,6 +9,8 @@ import { ORGANIZATION_URL_TOKEN, } from '@geonetwork-ui/feature/catalog' import { + EXTERNAL_VIEWER_OPEN_NEW_TAB, + EXTERNAL_VIEWER_URL_TEMPLATE, FeatureRecordModule, GN_UI_VERSION, WEB_COMPONENT_EMBEDDER_URL, @@ -43,6 +45,8 @@ import { import { UiSearchModule } from '@geonetwork-ui/ui/search' import { getGlobalConfig, + getMapContextLayerFromConfig, + getOptionalMapConfig, getOptionalSearchConfig, getThemeConfig, TRANSLATE_WITH_OVERRIDES_CONFIG, @@ -95,6 +99,11 @@ import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' import { RecordUserFeedbacksComponent } from './record/record-user-feedbacks/record-user-feedbacks.component' import { LetDirective } from '@ngrx/component' import { OrganizationPageComponent } from './organization/organization-page/organization-page.component' +import { + BASEMAP_LAYERS, + DO_NOT_USE_DEFAULT_BASEMAP, + MAP_VIEW_CONSTRAINTS, +} from '@geonetwork-ui/ui/map' export const metaReducers: MetaReducer[] = !environment.production ? [] : [] @@ -229,6 +238,30 @@ export const metaReducers: MetaReducer[] = !environment.production ? [] : [] provide: ORGANIZATION_URL_TOKEN, useValue: `${ROUTER_ROUTE_SEARCH}?${ROUTE_PARAMS.PUBLISHER}=\${name}`, }, + { + provide: DO_NOT_USE_DEFAULT_BASEMAP, + useFactory: () => getOptionalMapConfig()?.DO_NOT_USE_DEFAULT_BASEMAP, + }, + { + provide: BASEMAP_LAYERS, + useFactory: () => + getOptionalMapConfig()?.MAP_LAYERS.map(getMapContextLayerFromConfig), + }, + { + provide: MAP_VIEW_CONSTRAINTS, + useFactory: () => ({ + maxExtent: getOptionalMapConfig()?.MAX_EXTENT, + maxZoom: getOptionalMapConfig()?.MAX_ZOOM, + }), + }, + { + provide: EXTERNAL_VIEWER_URL_TEMPLATE, + useFactory: () => getOptionalMapConfig()?.EXTERNAL_VIEWER_URL_TEMPLATE, + }, + { + provide: EXTERNAL_VIEWER_OPEN_NEW_TAB, + useFactory: () => getOptionalMapConfig()?.EXTERNAL_VIEWER_OPEN_NEW_TAB, + }, ], bootstrap: [AppComponent], }) diff --git a/apps/map-viewer/src/app/app.component.html b/apps/map-viewer/src/app/app.component.html index 08409ca81a..089a633d42 100644 --- a/apps/map-viewer/src/app/app.component.html +++ b/apps/map-viewer/src/app/app.component.html @@ -1,5 +1,5 @@
- + [] = !environment.production EffectsModule.forRoot(), FeatureCatalogModule, LayersPanelComponent, + MapStateContainerComponent, ], providers: [ importProvidersFrom(FeatureAuthModule), diff --git a/apps/search/src/app/app.module.ts b/apps/search/src/app/app.module.ts index e8eced3a70..7b1d5d3b8b 100644 --- a/apps/search/src/app/app.module.ts +++ b/apps/search/src/app/app.module.ts @@ -5,7 +5,6 @@ import { FeatureCatalogModule } from '@geonetwork-ui/feature/catalog' import { FeatureDatavizModule } from '@geonetwork-ui/feature/dataviz' import { FeatureMapModule } from '@geonetwork-ui/feature/map' import { UiLayoutModule } from '@geonetwork-ui/ui/layout' -import { UiMapModule } from '@geonetwork-ui/ui/map' import { TRANSLATE_DEFAULT_CONFIG, UtilI18nModule, @@ -39,7 +38,6 @@ export const metaReducers: MetaReducer[] = !environment.production FeatureCatalogModule, UiLayoutModule, FeatureMapModule, - UiMapModule, FeatureDatavizModule, StoreModule.forRoot({}, { metaReducers }), !environment.production ? StoreDevtoolsModule.instrument() : [], diff --git a/apps/search/src/app/main-search/main-search.component.ts b/apps/search/src/app/main-search/main-search.component.ts index ce6d20d8e0..b57473af23 100644 --- a/apps/search/src/app/main-search/main-search.component.ts +++ b/apps/search/src/app/main-search/main-search.component.ts @@ -1,5 +1,4 @@ import { Component, OnInit } from '@angular/core' -import { FeatureInfoService } from '@geonetwork-ui/feature/map' import { SearchFacade } from '@geonetwork-ui/feature/search' import { UiApiService } from '@geonetwork-ui/data-access/gn4' import { firstValueFrom, map } from 'rxjs' @@ -12,7 +11,6 @@ import { firstValueFrom, map } from 'rxjs' export class MainSearchComponent implements OnInit { constructor( private uiService: UiApiService, - private featureInfo: FeatureInfoService, private searchFacade: SearchFacade ) {} diff --git a/libs/feature/dataviz/src/lib/feature-dataviz.module.ts b/libs/feature/dataviz/src/lib/feature-dataviz.module.ts index 91c70f365b..6aa9e14c05 100644 --- a/libs/feature/dataviz/src/lib/feature-dataviz.module.ts +++ b/libs/feature/dataviz/src/lib/feature-dataviz.module.ts @@ -1,7 +1,10 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { FeatureMapModule } from '@geonetwork-ui/feature/map' -import { UiMapModule } from '@geonetwork-ui/ui/map' +import { + FeatureDetailComponent, + MapContainerComponent, +} from '@geonetwork-ui/ui/map' import { GeoTableViewComponent } from './geo-table-view/geo-table-view.component' import { FigureContainerComponent } from './figure/figure-container/figure-container.component' import { @@ -19,7 +22,6 @@ import { UiInputsModule } from '@geonetwork-ui/ui/inputs' imports: [ CommonModule, FeatureMapModule, - UiMapModule, UiDatavizModule, TableComponent, UiWidgetsModule, @@ -27,6 +29,8 @@ import { UiInputsModule } from '@geonetwork-ui/ui/inputs' ChartComponent, UiInputsModule, PopupAlertComponent, + FeatureDetailComponent, + MapContainerComponent, ], declarations: [ GeoTableViewComponent, diff --git a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.html b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.html index 052c61d38b..c1ef589599 100644 --- a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.html +++ b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.html @@ -1,14 +1,17 @@
- + (featuresClick)="onMapFeatureSelect($event)" + > >, -}) +import { MapContext } from '@geospatial-sdk/core' +import { Subject } from 'rxjs' +import type { FeatureCollection } from 'geojson' -const mapMock = new Map({ - layers: [ - new TileLayer({ - source: new XYZ({ - url: 'http://test', - }), - }), - vectorLayer, - ], +@Component({ + selector: 'gn-ui-map-container', + template: '
', }) -const featureInfoServiceMock = { - handleFeatureInfo: jest.fn(), - features$: new Subject(), +export class MockMapContainerComponent { + @Input() context: MapContext + @Output() featuresClick = new Subject() + openlayersMap = Promise.resolve({}) } -const mapManagerMock = { - map: mapMock, +@Component({ + selector: 'gn-ui-table', + template: '
', +}) +export class MockTableComponent { + @Input() data: FeatureCollection + @Input() activeId: string + scrollToItem = jest.fn() } describe('GeoTableViewComponent', () => { @@ -58,22 +39,12 @@ describe('GeoTableViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [GeoTableViewComponent], - schemas: [NO_ERRORS_SCHEMA], - providers: [ - { - provide: FeatureInfoService, - useValue: featureInfoServiceMock, - }, - { - provide: MapManagerService, - useValue: mapManagerMock, - }, - { - provide: FEATURE_MAP_OPTIONS, - useValue: defaultMapOptions, - }, + declarations: [ + GeoTableViewComponent, + MockMapContainerComponent, + MockTableComponent, ], + schemas: [NO_ERRORS_SCHEMA], }) .overrideComponent(GeoTableViewComponent, { set: { changeDetection: ChangeDetectionStrategy.Default }, @@ -84,6 +55,7 @@ describe('GeoTableViewComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(GeoTableViewComponent) component = fixture.componentInstance + component.data = pointFeatureCollectionFixture() fixture.detectChanges() }) @@ -105,22 +77,21 @@ describe('GeoTableViewComponent', () => { expect(component.mapContext).toEqual({ layers: [ { - type: MapContextLayerTypeEnum.XYZ, + type: 'xyz', url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png', }, { - type: MapContextLayerTypeEnum.GEOJSON, + type: 'geojson', data: component.data, }, ], + view: { + center: [0, 0], + zoom: 2, + }, }) }) - it('feature info', () => { - expect(featureInfoServiceMock.handleFeatureInfo).toHaveBeenCalled() - }) it('map objects', () => { - expect(component['view']).toBe(mapMock.getView()) - expect(component['vectorLayer']).toBe(vectorLayer) expect(component['features'].length).toBe( pointFeatureCollectionFixture().features.length ) @@ -134,19 +105,21 @@ describe('GeoTableViewComponent', () => { id: 1, name: 'feature 1', } - jest.spyOn(component['view'], 'fit') component.onTableSelect(tableEntry) }) it('set the selection', () => { expect(component.selectionId).toBe(1) - expect(component.selection).toBe( - component['vectorSource'] - .getFeatures() - .find((feature) => feature.getId() === 1) + expect(component.selection).toEqual( + pointFeatureCollectionFixture().features.find((f) => f.id === 1) ) }) it('zoom on feature', () => { - expect(component['view'].fit).toHaveBeenCalled() + expect(component.mapContext.view).toEqual({ + geometry: { + coordinates: [2.335333, 51.070817], + type: 'Point', + }, + }) }) }) @@ -154,22 +127,19 @@ describe('GeoTableViewComponent', () => { let features beforeEach(() => { features = [ - component['vectorSource'] - .getFeatures() - .find((feature) => feature.getId() === 2), + pointFeatureCollectionFixture().features.find((f) => f.id === 2), ] const changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef) jest.spyOn(changeDetectorRef.constructor.prototype, 'detectChanges') - jest.spyOn(component['vectorLayer'], 'changed') - featureInfoServiceMock.features$.next(features) + component.onMapFeatureSelect(features) }) it('set the selection', () => { expect(component.selectionId).toBe(2) expect(component.selection).toBe(features[0]) }) it('layer is refreshed', () => { - expect(component['vectorLayer'].changed).toHaveBeenCalled() + // expect(component['vectorLayer'].changed).toHaveBeenCalled() expect(component['changeRef'].detectChanges).toHaveBeenCalled() }) }) diff --git a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts index 4c6271efc3..5331ae0e33 100644 --- a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts +++ b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts @@ -1,34 +1,33 @@ -import { HttpClientModule } from '@angular/common/http' -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' -import { FeatureMapModule } from '@geonetwork-ui/feature/map' -import { UiLayoutModule } from '@geonetwork-ui/ui/layout' -import { UiMapModule } from '@geonetwork-ui/ui/map' -import { TRANSLATE_DEFAULT_CONFIG } from '@geonetwork-ui/util/i18n' import { TranslateModule } from '@ngx-translate/core' import { applicationConfig, componentWrapperDecorator, Meta, moduleMetadata, + StoryObj, } from '@storybook/angular' import { GeoTableViewComponent } from './geo-table-view.component' import { importProvidersFrom } from '@angular/core' +import { + FeatureDetailComponent, + MapContainerComponent, +} from '@geonetwork-ui/ui/map' +import { polygonFeatureCollectionFixture } from '@geonetwork-ui/common/fixtures' +import { TableComponent } from '@geonetwork-ui/ui/dataviz' +import { HttpClientModule } from '@angular/common/http' +import { TRANSLATE_DEFAULT_CONFIG } from '@geonetwork-ui/util/i18n' export default { title: 'Map/GeoTable', component: GeoTableViewComponent, decorators: [ moduleMetadata({ - imports: [ - UiMapModule, - UiLayoutModule, - FeatureMapModule, - TranslateModule.forRoot(TRANSLATE_DEFAULT_CONFIG), - ], + imports: [FeatureDetailComponent, MapContainerComponent, TableComponent], }), applicationConfig({ providers: [ - importProvidersFrom(BrowserAnimationsModule, HttpClientModule), + importProvidersFrom(TranslateModule.forRoot(TRANSLATE_DEFAULT_CONFIG)), + importProvidersFrom(HttpClientModule), ], }), componentWrapperDecorator( @@ -36,3 +35,9 @@ export default { ), ], } as Meta + +export const Primary: StoryObj = { + args: { + data: polygonFeatureCollectionFixture(), + }, +} diff --git a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts index 77665bfd89..e709b06f6f 100644 --- a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts +++ b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts @@ -1,36 +1,21 @@ import { - AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, - Inject, Input, OnDestroy, OnInit, ViewChild, } from '@angular/core' -import { - FEATURE_MAP_OPTIONS, - FeatureInfoService, - MapContextLayerTypeEnum, - MapContextModel, - MapManagerService, - MapOptionsModel, -} from '@geonetwork-ui/feature/map' import { TableComponent, TableItemId, TableItemModel, } from '@geonetwork-ui/ui/dataviz' -import type { FeatureCollection } from 'geojson' -import Map from 'ol/Map' -import View from 'ol/View' -import Feature from 'ol/Feature' -import { Geometry } from 'ol/geom' -import VectorLayer from 'ol/layer/Vector' -import VectorSource from 'ol/source/Vector' -import Style from 'ol/style/Style' +import type { Feature, FeatureCollection } from 'geojson' import { Subscription } from 'rxjs' +import { MapContext } from '@geospatial-sdk/core' +import { MapContainerComponent } from '@geonetwork-ui/ui/map' @Component({ selector: 'gn-ui-geo-table-view', @@ -38,50 +23,26 @@ import { Subscription } from 'rxjs' styleUrls: ['./geo-table-view.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class GeoTableViewComponent implements OnInit, AfterViewInit, OnDestroy { +export class GeoTableViewComponent implements OnInit, OnDestroy { @Input() data: FeatureCollection = { type: 'FeatureCollection', features: [] } - @ViewChild(TableComponent) uiTable: TableComponent - - private map: Map - private view: View - private vectorLayer: VectorLayer>> - private vectorSource: VectorSource> - private features: Feature[] + @ViewChild('table') uiTable: TableComponent + @ViewChild('mapContainer') mapContainer: MapContainerComponent tableData: TableItemModel[] - mapContext: MapContextModel + mapContext: MapContext selectionId: TableItemId - selection: Feature + selection: Feature private subscription = new Subscription() - constructor( - private manager: MapManagerService, - private featureInfo: FeatureInfoService, - private changeRef: ChangeDetectorRef, - @Inject(FEATURE_MAP_OPTIONS) private mapOptions: MapOptionsModel - ) {} + get features() { + return this.data.features + } + + constructor(private changeRef: ChangeDetectorRef) {} ngOnInit(): void { this.tableData = this.geojsonToTableData(this.data) this.mapContext = this.initMapContext() - this.featureInfo.handleFeatureInfo() - this.subscription.add( - this.featureInfo.features$.subscribe((features) => { - this.onMapFeatureSelect(features) - }) - ) - } - - ngAfterViewInit(): void { - const map = (this.map = this.manager.map) - this.view = map.getView() - this.vectorLayer = this.manager.map.getLayers().item(1) as VectorLayer< - VectorSource> - > - this.vectorLayer.setStyle(this.styleFn.bind(this)) - this.vectorSource = this.vectorLayer.getSource() - this.features = this.vectorSource.getFeatures() - this.view.fit(this.vectorSource.getExtent()) } onTableSelect(tableEntry: TableItemModel) { @@ -93,12 +54,13 @@ export class GeoTableViewComponent implements OnInit, AfterViewInit, OnDestroy { } } - onMapFeatureSelect(features: Feature[]): void { + onMapFeatureSelect(features: Feature[]): void { this.selection = features?.length > 0 && features[0] if (this.selection) { - this.selectionId = this.selection.getId() + // FIXME: show styling for selection + this.selectionId = this.selection.id this.changeRef.detectChanges() - this.vectorLayer.changed() + // this.vectorLayer.changed() this.uiTable.scrollToItem(this.selectionId) } } @@ -110,44 +72,41 @@ export class GeoTableViewComponent implements OnInit, AfterViewInit, OnDestroy { })) } - private initMapContext(): MapContextModel { + private initMapContext(): MapContext { return { layers: [ { - type: MapContextLayerTypeEnum.XYZ, + type: 'xyz', url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png', }, { - type: MapContextLayerTypeEnum.GEOJSON, + type: 'geojson', data: this.data, }, ], + view: { + center: [0, 0], + zoom: 2, + }, } } - private animateToFeature(feature: Feature): void { - this.view.fit(feature.getGeometry().getExtent(), { - duration: 1000, - maxZoom: 11, - }) + private animateToFeature(feature: Feature): void { + this.mapContext = { + ...this.mapContext, + view: { + geometry: feature.geometry, + }, + } + // FIXME: animate the view + // this.view.fit(feature.getGeometry().getExtent(), { + // duration: 1000, + // maxZoom: 11, + // }) } private getFeatureFromId(id: TableItemId) { - return this.features.find((feature) => feature.getId() === id) - } - - private styleFn( - feature: Feature, - resolution: number - ): void | Style | Style[] { - if ( - this.selectionId !== undefined && - this.selectionId === feature.getId() - ) { - return this.mapOptions.hlStyle(feature, resolution) - } else { - return this.mapOptions.defaultStyle(feature, resolution) - } + return this.features.find((feature) => feature.id === id) } ngOnDestroy(): void { diff --git a/libs/feature/map/src/lib/constant/index.ts b/libs/feature/map/src/lib/constant/index.ts index b431b3f59d..e276aed351 100644 --- a/libs/feature/map/src/lib/constant/index.ts +++ b/libs/feature/map/src/lib/constant/index.ts @@ -1,2 +1 @@ -export * from './map-options' export * from './projections' diff --git a/libs/feature/map/src/lib/constant/map-options.ts b/libs/feature/map/src/lib/constant/map-options.ts deleted file mode 100644 index b9c145fee5..0000000000 --- a/libs/feature/map/src/lib/constant/map-options.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { InjectionToken } from '@angular/core' -import { StyleFunction } from 'ol/style/Style' -import { defaultStyle, hlStyle } from './style.constant' - -export interface MapOptionsModel { - defaultStyle: StyleFunction - hlStyle: StyleFunction -} - -export const defaultMapOptions: MapOptionsModel = { - defaultStyle, - hlStyle, -} - -export const FEATURE_MAP_OPTIONS = new InjectionToken( - 'mapOptions' -) diff --git a/libs/feature/map/src/lib/feature-map.module.ts b/libs/feature/map/src/lib/feature-map.module.ts index 9b795377d2..329e4e63c2 100644 --- a/libs/feature/map/src/lib/feature-map.module.ts +++ b/libs/feature/map/src/lib/feature-map.module.ts @@ -1,6 +1,5 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' -import { defaultMapOptions, FEATURE_MAP_OPTIONS } from './constant' import { UiLayoutModule } from '@geonetwork-ui/ui/layout' import { MatIconModule } from '@angular/material/icon' import { MatTabsModule } from '@angular/material/tabs' @@ -32,10 +31,6 @@ import { AddLayerFromOgcApiComponent } from './add-layer-from-ogc-api/add-layer- TextInputComponent, ], providers: [ - { - provide: FEATURE_MAP_OPTIONS, - useValue: defaultMapOptions, - }, MapFacade, { provide: GEOCODING_PROVIDER, diff --git a/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.spec.ts b/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.spec.ts index 8a1a8fc5a3..c71f89948c 100644 --- a/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.spec.ts +++ b/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.spec.ts @@ -1,8 +1,11 @@ import { Component, EventEmitter, Output } from '@angular/core' import { ComponentFixture, TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' -import { mapConfigFixture } from '@geonetwork-ui/util/app-config' -import { ExternalViewerButtonComponent } from './external-viewer-button.component' +import { + EXTERNAL_VIEWER_OPEN_NEW_TAB, + EXTERNAL_VIEWER_URL_TEMPLATE, + ExternalViewerButtonComponent, +} from './external-viewer-button.component' import { TranslateModule } from '@ngx-translate/core' import { MatIconModule } from '@angular/material/icon' @@ -22,6 +25,17 @@ describe('ExternalViewerButtonComponent', () => { await TestBed.configureTestingModule({ declarations: [ExternalViewerButtonComponent, MockButtonComponent], imports: [TranslateModule.forRoot(), MatIconModule], + providers: [ + { + provide: EXTERNAL_VIEWER_URL_TEMPLATE, + useValue: + 'https://example.com/myviewer/#/?actions=[{"type":"CATALOG:ADD_LAYERS_FROM_CATALOGS","layers":["${layer_name}"],"sources":[{"url":"${service_url}","type":"${service_type}"}]}]', + }, + { + provide: EXTERNAL_VIEWER_OPEN_NEW_TAB, + useValue: true, + }, + ], }).compileComponents() }) @@ -35,7 +49,6 @@ describe('ExternalViewerButtonComponent', () => { }) describe('with mapConfig and no link', () => { beforeEach(() => { - component.mapConfig = mapConfigFixture() component.link = null fixture.detectChanges() }) @@ -51,7 +64,6 @@ describe('ExternalViewerButtonComponent', () => { const focusMock = jest.fn().mockReturnThis() describe('with mapConfig and WMS link', () => { beforeEach(() => { - component.mapConfig = mapConfigFixture() component.link = { url: new URL( 'http://example.com/ows?service=wms&request=getcapabilities' @@ -100,7 +112,6 @@ describe('ExternalViewerButtonComponent', () => { }) describe('with mapConfig and WFS link', () => { beforeEach(() => { - component.mapConfig = mapConfigFixture() component.link = { url: new URL( 'http://example.com/ows?service=wfs&request=getcapabilities' @@ -149,7 +160,6 @@ describe('ExternalViewerButtonComponent', () => { }) describe('with mapConfig and GEOJSON link', () => { beforeEach(() => { - component.mapConfig = mapConfigFixture() component.link = { url: new URL('http://example.com/somespatialdata.geojson'), type: 'download', @@ -196,7 +206,6 @@ describe('ExternalViewerButtonComponent', () => { }) describe('with mapConfig and invalid external link (non WMS/WFS/GEOJSON)', () => { beforeEach(() => { - component.mapConfig = mapConfigFixture() component.link = { url: new URL('http://example.com/'), name: 'layername', diff --git a/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.ts b/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.ts index 6929eeec4a..326eac72de 100644 --- a/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.ts +++ b/libs/feature/record/src/lib/external-viewer-button/external-viewer-button.component.ts @@ -1,5 +1,11 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' -import { MapConfig } from '@geonetwork-ui/util/app-config' +import { + ChangeDetectionStrategy, + Component, + Inject, + InjectionToken, + Input, + Optional, +} from '@angular/core' import { DatasetOnlineResource } from '@geonetwork-ui/common/domain/model/record' import { marker } from '@biesbjerg/ngx-translate-extract-marker' import { TranslateService } from '@ngx-translate/core' @@ -7,6 +13,14 @@ import { getFileFormat } from '@geonetwork-ui/util/shared' marker('externalviewer.dataset.unnamed') +export const EXTERNAL_VIEWER_URL_TEMPLATE = new InjectionToken( + 'externalViewerUrlTemplate' +) +export const EXTERNAL_VIEWER_OPEN_NEW_TAB = new InjectionToken( + 'externalViewerOpenNewTab', + { factory: () => false } +) + @Component({ selector: 'gn-ui-external-viewer-button', templateUrl: './external-viewer-button.component.html', @@ -15,16 +29,9 @@ marker('externalviewer.dataset.unnamed') }) export class ExternalViewerButtonComponent { @Input() link: DatasetOnlineResource - @Input() mapConfig: MapConfig get externalViewer() { - if (this.link && this.mapConfig) { - return ( - !!this.mapConfig.EXTERNAL_VIEWER_URL_TEMPLATE && - !!this.supportedLinkLayerType - ) - } - return false + return !!this.urlTemplate && !!this.supportedLinkLayerType } get supportedLinkLayerType() { @@ -45,10 +52,17 @@ export class ExternalViewerButtonComponent { return null } - constructor(private translateService: TranslateService) {} + constructor( + private translateService: TranslateService, + @Inject(EXTERNAL_VIEWER_URL_TEMPLATE) + @Optional() + private urlTemplate: string, + @Inject(EXTERNAL_VIEWER_OPEN_NEW_TAB) + private openinNewTab: boolean + ) {} openInExternalViewer() { - const templateUrl = this.mapConfig.EXTERNAL_VIEWER_URL_TEMPLATE + const templateUrl = this.urlTemplate const layerName = this.link.name ? this.link.name : this.translateService.instant('externalviewer.dataset.unnamed') @@ -59,11 +73,6 @@ export class ExternalViewerButtonComponent { `${encodeURIComponent(this.link.url.toString())}` ) .replace('${service_type}', `${this.supportedLinkLayerType}`) - window - .open( - url, - this.mapConfig.EXTERNAL_VIEWER_OPEN_NEW_TAB ? '_blank' : '_self' - ) - .focus() + window.open(url, this.openinNewTab ? '_blank' : '_self').focus() } } diff --git a/libs/feature/record/src/lib/feature-record.module.ts b/libs/feature/record/src/lib/feature-record.module.ts index e0488ff889..f1d20005b8 100644 --- a/libs/feature/record/src/lib/feature-record.module.ts +++ b/libs/feature/record/src/lib/feature-record.module.ts @@ -1,10 +1,16 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' -import { UiMapModule } from '@geonetwork-ui/ui/map' +import { + FeatureDetailComponent, + MapContainerComponent, +} from '@geonetwork-ui/ui/map' import { StoreModule } from '@ngrx/store' import { EffectsModule } from '@ngrx/effects' import { UiLayoutModule } from '@geonetwork-ui/ui/layout' -import { FeatureMapModule } from '@geonetwork-ui/feature/map' +import { + FeatureMapModule, + MapStateContainerComponent, +} from '@geonetwork-ui/feature/map' import { UiInputsModule } from '@geonetwork-ui/ui/inputs' import { UiElementsModule } from '@geonetwork-ui/ui/elements' import { MdViewFacade } from './state' @@ -43,7 +49,6 @@ import { DataViewShareComponent } from './data-view-share/data-view-share.compon UiLayoutModule, FeatureMapModule, FeatureCatalogModule, - UiMapModule, UiInputsModule, UiElementsModule, MatTabsModule, @@ -53,6 +58,9 @@ import { DataViewShareComponent } from './data-view-share/data-view-share.compon TableComponent, FeatureDatavizModule, PopupAlertComponent, + FeatureDetailComponent, + MapStateContainerComponent, + MapContainerComponent, ], providers: [MdViewFacade], exports: [ diff --git a/libs/feature/record/src/lib/map-view/map-view.component.html b/libs/feature/record/src/lib/map-view/map-view.component.html index 6199d8e7c2..b0b88ea2ff 100644 --- a/libs/feature/record/src/lib/map-view/map-view.component.html +++ b/libs/feature/record/src/lib/map-view/map-view.component.html @@ -10,17 +10,17 @@
- + (featuresClick)="onMapFeatureSelect($event)" + >
({ + prioritizePageScroll: jest.fn(), +})) + +jest.mock('@geospatial-sdk/core', () => { + let returnImmediately = true + let resolver + let rejecter + return { + createViewFromLayer: jest.fn(function () { + return new Promise((resolve, reject) => { + resolver = resolve + rejecter = reject + if (returnImmediately) { + resolve(null) + } + }) + }), + returnImmediately(v) { + returnImmediately = v + }, + resolve(v) { + resolver(v) + }, + reject(v) { + rejecter(v) + }, + } +}) const recordMapExtent = [-30, -60, 30, 60] @@ -47,32 +67,7 @@ const emptyMapContext = { view: { extent: recordMapExtent, }, -} as MapContextModel - -const mapConfigMock = { - MAX_ZOOM: 10, - MAX_EXTENT: [-418263.418776, 5251529.591305, 961272.067714, 6706890.609855], - DO_NOT_USE_DEFAULT_BASEMAP: false, - EXTERNAL_VIEWER_URL_TEMPLATE: - 'https://example.com/myviewer?layer=${layer_name}&url=${service_url}&type=${service_type}', - EXTERNAL_VIEWER_OPEN_NEW_TAB: true, - MAP_LAYERS: [ - { - TYPE: 'wms', - URL: 'https://some-wms-server', - NAME: 'some_layername', - }, - { - TYPE: 'wfs', - URL: 'https://some-wfs-server', - NAME: 'some_layername', - }, - ], -} -jest.mock('@geonetwork-ui/util/app-config', () => ({ - getOptionalMapConfig: () => mapConfigMock, - isConfigLoaded: jest.fn(() => true), -})) +} as MapContext class MdViewFacadeMock { mapApiLinks$ = new Subject() @@ -81,26 +76,7 @@ class MdViewFacadeMock { } class MapUtilsServiceMock { - createEmptyMap = jest.fn() - getLayerExtent = jest.fn(function () { - return new Promise((resolve, reject) => { - this._resolve = resolve - this._reject = reject - if (this._returnImmediately) { - this._resolve(null) - } - }) - }) - getWmtsLayerFromCapabilities = jest.fn(function () { - return new Observable((observer) => { - observer.next({ type: 'wmts', options: null }) - }) - }) - prioritizePageScroll = jest.fn() getRecordExtent = jest.fn(() => recordMapExtent) - _returnImmediately = true - _resolve = null - _reject = null } const SAMPLE_GEOJSON = { @@ -127,14 +103,6 @@ class DataServiceMock { ) } -class MapStyleServiceMock { - createDefaultStyle = jest.fn(() => [new Style()]) - styles = { - default: defaultMapStyleFixture(), - defaultHL: defaultMapStyleHlFixture(), - } -} - class OpenLayersMapMock { _size = undefined updateSize() { @@ -150,22 +118,13 @@ class OpenLayersMapMock { class InteractionsMock extends Collection {} -class mapManagerMock { - map = new OpenLayersMapMock() -} - -class FeatureInfoServiceMock { - handleFeatureInfo = jest.fn() - features$ = new Subject() -} - @Component({ - selector: 'gn-ui-map-context', + selector: 'gn-ui-map-container', template: '
', }) -export class MockMapContextComponent { - @Input() context: MapContextModel - @Input() mapConfig: MapConfig +export class MockMapContainerComponent { + @Input() context: MapContext + openlayersMap = Promise.resolve(new OpenLayersMapMock()) } @Component({ @@ -184,7 +143,6 @@ export class MockDropdownSelectorComponent { }) export class MockExternalViewerButtonComponent { @Input() link: DatasetOnlineResource - @Input() mapConfig: MapConfig } @Component({ @@ -205,14 +163,18 @@ describe('MapViewComponent', () => { let component: MapViewComponent let fixture: ComponentFixture let mdViewFacade - let mapUtilsService - let featureInfoService + let mapComponent: MockMapContainerComponent + + beforeEach(() => { + jest.clearAllMocks() + geoSdkCore.returnImmediately(true) + }) beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ MapViewComponent, - MockMapContextComponent, + MockMapContainerComponent, MockDropdownSelectorComponent, MockExternalViewerButtonComponent, MockLoadingMaskComponent, @@ -232,30 +194,20 @@ describe('MapViewComponent', () => { provide: DataService, useClass: DataServiceMock, }, - { - provide: MapStyleService, - useClass: MapStyleServiceMock, - }, - { - provide: MapManagerService, - useClass: mapManagerMock, - }, - { - provide: FeatureInfoService, - useClass: FeatureInfoServiceMock, - }, ], imports: [TranslateModule.forRoot()], }).compileComponents() mdViewFacade = TestBed.inject(MdViewFacade) - mapUtilsService = TestBed.inject(MapUtilsService) - featureInfoService = TestBed.inject(FeatureInfoService) }) beforeEach(() => { fixture = TestBed.createComponent(MapViewComponent) component = fixture.componentInstance fixture.detectChanges() + + mapComponent = fixture.debugElement.query( + By.directive(MockMapContainerComponent) + ).componentInstance }) it('should create', () => { @@ -263,14 +215,10 @@ describe('MapViewComponent', () => { }) describe('map layers', () => { - let mapComponent: MockMapContextComponent let dropdownComponent: DropdownSelectorComponent let externalViewerButtonComponent: MockExternalViewerButtonComponent beforeEach(() => { - mapComponent = fixture.debugElement.query( - By.directive(MockMapContextComponent) - ).componentInstance dropdownComponent = fixture.debugElement.query( By.directive(MockDropdownSelectorComponent) ).componentInstance @@ -292,12 +240,6 @@ describe('MapViewComponent', () => { view: expect.any(Object), }) }) - it('emits map config to map component', () => { - expect(mapComponent.mapConfig).toEqual(mapConfigMock) - }) - it('emits map config to external viewer component', () => { - expect(externalViewerButtonComponent.mapConfig).toEqual(mapConfigMock) - }) it('emits no link to external viewer component', () => { expect(externalViewerButtonComponent.link).toEqual(undefined) }) @@ -666,7 +608,7 @@ describe('MapViewComponent', () => { describe('when selecting a layer', () => { beforeEach(fakeAsync(() => { - mapUtilsService._returnImmediately = false + geoSdkCore.returnImmediately(false) mdViewFacade.mapApiLinks$.next([ { url: new URL('http://abcd.com/'), @@ -693,7 +635,7 @@ describe('MapViewComponent', () => { }) describe('when extent is received', () => { beforeEach(fakeAsync(() => { - mapUtilsService._resolve([-100, -200, 100, 200]) + geoSdkCore.resolve({ extent: [-100, -200, 100, 200] }) tick() fixture.detectChanges() })) @@ -722,7 +664,7 @@ describe('MapViewComponent', () => { }) describe('when extent could not be determined', () => { beforeEach(fakeAsync(() => { - mapUtilsService._resolve(null) + geoSdkCore.resolve(null) tick() fixture.detectChanges() })) @@ -751,7 +693,7 @@ describe('MapViewComponent', () => { }) describe('when extent computation fails', () => { beforeEach(fakeAsync(() => { - mapUtilsService._reject('extent computation failed') + geoSdkCore.reject('extent computation failed') tick() fixture.detectChanges() })) @@ -778,7 +720,7 @@ describe('MapViewComponent', () => { }) describe('selecting another layer, while extent is not ready', () => { beforeEach(fakeAsync(() => { - mapUtilsService._resolve(recordMapExtent) + geoSdkCore.resolve({ extent: recordMapExtent }) tick() dropdownComponent.selectValue.emit(0) tick() @@ -799,7 +741,7 @@ describe('MapViewComponent', () => { describe('prioritizePageScroll', () => { it('calls prioritzePageScroll with interactions', () => { - expect(mapUtilsService.prioritizePageScroll).toHaveBeenCalledWith( + expect(prioritizePageScroll).toHaveBeenCalledWith( expect.any(InteractionsMock) ) }) @@ -808,35 +750,18 @@ describe('MapViewComponent', () => { describe('feature info', () => { let selectionFeatures beforeEach(() => { - const vectorLayer = new VectorLayer({ - source: new VectorSource({ - features: new GeoJSON().readFeatures( - pointFeatureCollectionFixture(), - { - featureProjection: 'EPSG:3857', - dataProjection: 'EPSG:4326', - } - ), - }), - }) - selectionFeatures = [ - vectorLayer - .getSource() - .getFeatures() - .find((feature) => feature.getId() === 2), - ] + selectionFeatures = pointFeatureCollectionFixture().features.filter( + (feature) => feature.id === 2 + ) }) - it('creates selection style', () => { - expect(component['selectionStyle']).toBeTruthy() - }) describe('#onMapFeatureSelect', () => { beforeEach(() => { const changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef) jest.spyOn(changeDetectorRef.constructor.prototype, 'detectChanges') jest.spyOn(component, 'resetSelection') - featureInfoService.features$.next(selectionFeatures) + component.onMapFeatureSelect(selectionFeatures) }) it('reset the selection first', () => { expect(component.resetSelection).toHaveBeenCalled() @@ -847,7 +772,8 @@ describe('MapViewComponent', () => { it('change detection applied', () => { expect(component['changeRef'].detectChanges).toHaveBeenCalled() }) - it('set feature style', () => { + it.skip('set feature style', () => { + // FIXME: restore test expect(component.selection.getStyle()).toBe(component['selectionStyle']) }) }) @@ -856,7 +782,8 @@ describe('MapViewComponent', () => { component.selection = selectionFeatures[0] component.resetSelection() }) - it('reset the style of the feature', () => { + it.skip('reset the style of the feature', () => { + // FIXME: restore test expect(selectionFeatures[0].getStyle()).toBeNull() }) it('remove the selection', () => { @@ -874,4 +801,30 @@ describe('MapViewComponent', () => { }) }) }) + + describe('map view extent', () => { + describe('if no record extent', () => { + beforeEach(fakeAsync(() => { + component['mapUtils'].getRecordExtent = jest.fn(() => null) + + mdViewFacade.mapApiLinks$.next([]) + mdViewFacade.geoDataLinksWithGeometry$.next([ + { + name: 'ogc layer', + url: new URL('http://abcd.com/data/ogcapi'), + type: 'service', + accessServiceProtocol: 'ogcFeatures', + }, + ]) + tick(200) + fixture.detectChanges() + })) + it('uses a default view', () => { + expect(mapComponent.context).toEqual({ + layers: expect.any(Array), + view: null, + }) + }) + }) + }) }) 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 9a957804b8..953823db8f 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 @@ -1,31 +1,19 @@ import { + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, - OnDestroy, - OnInit, + ViewChild, } from '@angular/core' -import { - FeatureInfoService, - MapContextLayerModel, - MapContextLayerTypeEnum, - MapContextModel, - MapManagerService, - MapStyleService, - MapUtilsService, -} from '@geonetwork-ui/feature/map' -import { getOptionalMapConfig, MapConfig } from '@geonetwork-ui/util/app-config' +import { MapUtilsService } from '@geonetwork-ui/feature/map' import { getLinkLabel } from '@geonetwork-ui/util/shared' -import Feature from 'ol/Feature' -import { Geometry } from 'ol/geom' -import { StyleLike } from 'ol/style/Style' import { BehaviorSubject, combineLatest, from, Observable, of, - Subscription, + startWith, throwError, withLatestFrom, } from 'rxjs' @@ -34,13 +22,22 @@ import { distinctUntilChanged, finalize, map, - startWith, switchMap, tap, } from 'rxjs/operators' import { MdViewFacade } from '../state/mdview.facade' import { DataService } from '@geonetwork-ui/feature/dataviz' import { DatasetOnlineResource } from '@geonetwork-ui/common/domain/model/record' +import { + createViewFromLayer, + MapContext, + MapContextLayer, +} from '@geospatial-sdk/core' +import { + MapContainerComponent, + prioritizePageScroll, +} from '@geonetwork-ui/ui/map' +import { Feature } from 'geojson' @Component({ selector: 'gn-ui-map-view', @@ -48,11 +45,10 @@ import { DatasetOnlineResource } from '@geonetwork-ui/common/domain/model/record styleUrls: ['./map-view.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MapViewComponent implements OnInit, OnDestroy { - mapConfig: MapConfig = getOptionalMapConfig() - selection: Feature - private subscription = new Subscription() - private selectionStyle: StyleLike +export class MapViewComponent implements AfterViewInit { + @ViewChild('mapContainer') mapContainer: MapContainerComponent + + selection: Feature compatibleMapLinks$ = combineLatest([ this.mdViewFacade.mapApiLinks$, @@ -102,93 +98,70 @@ export class MapViewComponent implements OnInit, OnDestroy { }) ) - mapContext$ = this.currentLayers$.pipe( + mapContext$: Observable = this.currentLayers$.pipe( switchMap((layers) => - from(this.mapUtils.getLayerExtent(layers[0])).pipe( - catchError(() => { - this.error = 'The layer has no extent' - return of(undefined) - }), - map( - (extent) => - ({ - layers, - view: { - extent, - }, - } as MapContextModel) - ), - tap((res) => { + from(createViewFromLayer(layers[0])).pipe( + catchError(() => of(null)), // could not zoom on the layer: use the record extent + map((view) => ({ + layers, + view, + })), + tap(() => { this.resetSelection() }) ) ), startWith({ layers: [], - view: {}, - } as MapContextModel), + view: null, + }), withLatestFrom(this.mdViewFacade.metadata$), map(([context, metadata]) => { - if (context.view.extent) return context + if (context.view) return context const extent = this.mapUtils.getRecordExtent(metadata) + const view = extent ? { extent } : null return { ...context, - view: { - ...context.view, - extent, - }, + view, } }) ) constructor( private mdViewFacade: MdViewFacade, - private mapManager: MapManagerService, private mapUtils: MapUtilsService, private dataService: DataService, - private featureInfo: FeatureInfoService, - private changeRef: ChangeDetectorRef, - private styleService: MapStyleService + private changeRef: ChangeDetectorRef ) {} - ngOnDestroy(): void { - this.subscription.unsubscribe() - } - - ngOnInit(): void { - this.mapUtils.prioritizePageScroll(this.mapManager.map.getInteractions()) - this.selectionStyle = this.styleService.styles.defaultHL - this.featureInfo.handleFeatureInfo() - this.subscription.add( - this.featureInfo.features$.subscribe((features) => { - this.onMapFeatureSelect(features) - }) - ) + async ngAfterViewInit() { + const map = await this.mapContainer.openlayersMap + prioritizePageScroll(map.getInteractions()) } - onMapFeatureSelect(features: Feature[]): void { + onMapFeatureSelect(features: Feature[]): void { this.resetSelection() this.selection = features?.length > 0 && features[0] if (this.selection) { - this.selection.setStyle(this.selectionStyle) + // FIXME: restore styling of selected feature + // this.selection.setStyle(this.selectionStyle) } this.changeRef.detectChanges() } resetSelection(): void { if (this.selection) { - this.selection.setStyle(null) + // FIXME: restore styling of selected feature + // this.selection.setStyle(null) } this.selection = null } - getLayerFromLink( - link: DatasetOnlineResource - ): Observable { + getLayerFromLink(link: DatasetOnlineResource): Observable { if (link.type === 'service' && link.accessServiceProtocol === 'wms') { return of({ url: link.url.toString(), - type: MapContextLayerTypeEnum.WMS, + type: 'wms', name: link.name, }) } else if ( @@ -197,7 +170,7 @@ export class MapViewComponent implements OnInit, OnDestroy { ) { return of({ url: link.url.toString(), - type: MapContextLayerTypeEnum.WMTS, + type: 'wmts', name: link.name, }) } else if ( @@ -209,7 +182,7 @@ export class MapViewComponent implements OnInit, OnDestroy { ) { return this.dataService.readAsGeoJson(link).pipe( map((data) => ({ - type: MapContextLayerTypeEnum.GEOJSON, + type: 'geojson', data, })) ) diff --git a/libs/util/app-config/src/index.ts b/libs/util/app-config/src/index.ts index 759a21e9d2..318cb1a16e 100644 --- a/libs/util/app-config/src/index.ts +++ b/libs/util/app-config/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/app-config' export * from './lib/fixtures' export * from './lib/model' +export * from './lib/map-layers' diff --git a/libs/util/app-config/src/lib/map-layers.ts b/libs/util/app-config/src/lib/map-layers.ts new file mode 100644 index 0000000000..8f6e75ddc8 --- /dev/null +++ b/libs/util/app-config/src/lib/map-layers.ts @@ -0,0 +1,31 @@ +import { LayerConfig } from './model' +import { MapContextLayer } from '@geospatial-sdk/core' + +export function getMapContextLayerFromConfig( + config: LayerConfig +): MapContextLayer { + switch (config.TYPE) { + case 'wms': + return { + type: 'wms', + url: config.URL, + name: config.NAME, + } + case 'wfs': + return { + type: 'wfs', + url: config.URL, + featureType: config.NAME, + } + case 'xyz': + return { + type: config.TYPE, + url: config.URL, + } + case 'geojson': + return { + type: config.TYPE, + ...(config.DATA ? { data: config.DATA } : { url: config.URL }), + } + } +} From dcb8a0b7c06e3c89a3a656b6de11206f0d74f320 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Thu, 12 Sep 2024 14:07:02 +0200 Subject: [PATCH 06/12] feat(map): make geocoding standalone & use geospatial-sdk properly Also converted a few more components to standalone on the way --- apps/map-viewer/src/app/app.module.ts | 4 +- .../catalog/src/lib/feature-catalog.module.ts | 6 +- .../feature/map/src/lib/feature-map.module.ts | 3 - .../lib/geocoding/geocoding.component.spec.ts | 118 +++++++----------- .../src/lib/geocoding/geocoding.component.ts | 31 ++++- .../organisations-filter.component.spec.ts | 24 +--- .../organisations-filter.component.ts | 9 +- libs/ui/catalog/src/lib/ui-catalog.module.ts | 3 - .../search-input.component.spec.ts | 2 +- .../search-input/search-input.component.ts | 6 +- libs/ui/inputs/src/lib/ui-inputs.module.ts | 3 - 11 files changed, 97 insertions(+), 112 deletions(-) diff --git a/apps/map-viewer/src/app/app.module.ts b/apps/map-viewer/src/app/app.module.ts index d4e8f2a5f1..46bbf1fb44 100644 --- a/apps/map-viewer/src/app/app.module.ts +++ b/apps/map-viewer/src/app/app.module.ts @@ -4,8 +4,9 @@ import { BrowserModule } from '@angular/platform-browser' import { AppComponent } from './app.component' import { FeatureMapModule, - MapStateContainerComponent, + GeocodingComponent, LayersPanelComponent, + MapStateContainerComponent, } from '@geonetwork-ui/feature/map' import { ThemeService } from '@geonetwork-ui/util/shared' import { TranslateModule } from '@ngx-translate/core' @@ -41,6 +42,7 @@ export const metaReducers: MetaReducer[] = !environment.production FeatureCatalogModule, LayersPanelComponent, MapStateContainerComponent, + GeocodingComponent, ], providers: [ importProvidersFrom(FeatureAuthModule), diff --git a/libs/feature/catalog/src/lib/feature-catalog.module.ts b/libs/feature/catalog/src/lib/feature-catalog.module.ts index 7c97c0d726..543275fb8d 100644 --- a/libs/feature/catalog/src/lib/feature-catalog.module.ts +++ b/libs/feature/catalog/src/lib/feature-catalog.module.ts @@ -1,6 +1,9 @@ import { InjectionToken, NgModule } from '@angular/core' import { SiteTitleComponent } from './site-title/site-title.component' -import { UiCatalogModule } from '@geonetwork-ui/ui/catalog' +import { + OrganisationsFilterComponent, + UiCatalogModule, +} from '@geonetwork-ui/ui/catalog' import { GroupsApiService, SearchApiService, @@ -64,6 +67,7 @@ const organizationsServiceFactory = ( UtilI18nModule, TranslateModule.forChild(), UiElementsModule, + OrganisationsFilterComponent, ], exports: [SiteTitleComponent, SourceLabelComponent, OrganisationsComponent], providers: [ diff --git a/libs/feature/map/src/lib/feature-map.module.ts b/libs/feature/map/src/lib/feature-map.module.ts index 329e4e63c2..5fb34d77b6 100644 --- a/libs/feature/map/src/lib/feature-map.module.ts +++ b/libs/feature/map/src/lib/feature-map.module.ts @@ -10,13 +10,10 @@ import * as fromMap from './+state/map.reducer' import { MapFacade } from './+state/map.facade' import { UiElementsModule } from '@geonetwork-ui/ui/elements' import { TextInputComponent, UiInputsModule } from '@geonetwork-ui/ui/inputs' -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: [GeocodingComponent], - exports: [GeocodingComponent], imports: [ CommonModule, UiLayoutModule, diff --git a/libs/feature/map/src/lib/geocoding/geocoding.component.spec.ts b/libs/feature/map/src/lib/geocoding/geocoding.component.spec.ts index 193b2e258c..d0ea25be65 100644 --- a/libs/feature/map/src/lib/geocoding/geocoding.component.spec.ts +++ b/libs/feature/map/src/lib/geocoding/geocoding.component.spec.ts @@ -1,63 +1,36 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { GeocodingComponent } from './geocoding.component' -import { MapManagerService } from '../manager/map-manager.service' -import { NO_ERRORS_SCHEMA } from '@angular/core' -import Map from 'ol/Map' -import TileLayer from 'ol/layer/Tile' -import XYZ from 'ol/source/XYZ' -import VectorLayer from 'ol/layer/Vector' -import VectorSource from 'ol/source/Vector' -import GeoJSON from 'ol/format/GeoJSON' -import { pointFeatureCollectionFixture } from '@geonetwork-ui/common/fixtures' -import Feature from 'ol/Feature' -import { Geometry } from 'ol/geom' -import { TranslateModule } from '@ngx-translate/core' import { GeocodingService } from '../geocoding.service' import { of } from 'rxjs' - -const vectorLayer = new VectorLayer({ - source: new VectorSource({ - features: new GeoJSON().readFeatures(pointFeatureCollectionFixture(), { - featureProjection: 'EPSG:3857', - dataProjection: 'EPSG:4326', - }), - }) as VectorSource>, -}) - -const mapMock = new Map({ - layers: [ - new TileLayer({ - source: new XYZ({ - url: 'http://test', - }), - }), - vectorLayer, - ], -}) - -const mapManagerMock = { - map: mapMock, -} - -const geocodingServiceMock = { - query: jest.fn().mockReturnValue(of([])), -} +import { MockBuilder, MockProvider } from 'ng-mocks' +import { MapFacade } from '../+state/map.facade' describe('GeocodingComponent', () => { let component: GeocodingComponent let fixture: ComponentFixture + let mapFacade: MapFacade + + beforeEach(() => { + return MockBuilder(GeocodingComponent) + }) beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [GeocodingComponent], providers: [ - { provide: MapManagerService, useValue: mapManagerMock }, - { provide: GeocodingService, useValue: geocodingServiceMock }, + MockProvider(GeocodingService, { + query: jest.fn().mockReturnValue(of([])), + }), + MockProvider(MapFacade, { + context$: of({ + layers: [], + view: null, + }), + applyContext: jest.fn(), + }), ], - schemas: [NO_ERRORS_SCHEMA], }).compileComponents() + mapFacade = TestBed.inject(MapFacade) fixture = TestBed.createComponent(GeocodingComponent) component = fixture.componentInstance fixture.detectChanges() @@ -91,36 +64,21 @@ describe('GeocodingComponent', () => { }) describe('zoomToLocation', () => { - let viewMock: any - let zoomToPointSpy: jest.SpyInstance - let zoomToPolygonSpy: jest.SpyInstance - - beforeEach(() => { - viewMock = { - setCenter: jest.fn(), - setZoom: jest.fn(), - fit: jest.fn(), - } - mapMock.getView = jest.fn().mockReturnValue(viewMock) - zoomToPointSpy = jest.spyOn(component, 'zoomToPoint') - zoomToPolygonSpy = jest.spyOn(component, 'zoomToPolygon') - }) - - it('should zoom to the location of the result if geometry type is Point', () => { + it('should zoom to the location of the result if geometry type is Point', async () => { const result = { geom: { type: 'Point', coordinates: [0, 0], }, } - component.zoomToLocation(result) - expect(zoomToPointSpy).toHaveBeenCalledWith( - result.geom.coordinates, - viewMock - ) + await component.zoomToLocation(result) + expect(mapFacade.applyContext).toHaveBeenCalledWith({ + layers: [], + view: { center: [0, 0], zoom: 10 }, + }) }) - it('should zoom to the location of the result if geometry type is Polygon', () => { + it('should zoom to the location of the result if geometry type is Polygon', async () => { const result = { geom: { type: 'Polygon', @@ -134,14 +92,26 @@ describe('GeocodingComponent', () => { ], }, } - component.zoomToLocation(result) - expect(zoomToPolygonSpy).toHaveBeenCalledWith( - result.geom.coordinates, - viewMock - ) + await component.zoomToLocation(result) + expect(mapFacade.applyContext).toHaveBeenCalledWith({ + layers: [], + view: { + geometry: { + coordinates: [ + [ + [0, 0], + [1, 1], + [2, 2], + [0, 0], + ], + ], + type: 'Polygon', + }, + }, + }) }) - it('should log an error if geometry type is unsupported', () => { + it('should log an error if geometry type is unsupported', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation() const result = { geom: { @@ -149,7 +119,7 @@ describe('GeocodingComponent', () => { coordinates: [0, 0], }, } - component.zoomToLocation(result) + await component.zoomToLocation(result) expect(consoleSpy).toHaveBeenCalledWith( `Unsupported geometry type: ${result.geom.type}` ) diff --git a/libs/feature/map/src/lib/geocoding/geocoding.component.ts b/libs/feature/map/src/lib/geocoding/geocoding.component.ts index 551b3243e6..5af0a7527b 100644 --- a/libs/feature/map/src/lib/geocoding/geocoding.component.ts +++ b/libs/feature/map/src/lib/geocoding/geocoding.component.ts @@ -3,11 +3,22 @@ import { catchError, firstValueFrom, from, Subject, takeUntil } from 'rxjs' import { debounceTime, switchMap } from 'rxjs/operators' import { GeocodingService } from '../geocoding.service' import { MapFacade } from '../+state/map.facade' +import { CommonModule } from '@angular/common' +import { SearchInputComponent, UiInputsModule } from '@geonetwork-ui/ui/inputs' +import { TranslateModule } from '@ngx-translate/core' +import { MapContextView } from '@geospatial-sdk/core' @Component({ selector: 'gn-ui-geocoding', templateUrl: './geocoding.component.html', styleUrls: ['./geocoding.component.css'], + standalone: true, + imports: [ + CommonModule, + UiInputsModule, + TranslateModule, + SearchInputComponent, + ], }) export class GeocodingComponent implements OnDestroy { searchText = '' @@ -60,13 +71,13 @@ export class GeocodingComponent implements OnDestroy { this.errorMessage = null } - zoomToLocation(result: any) { + async zoomToLocation(result: any) { const geometry = result.geom if (geometry.type === 'Point') { - this.zoomToPoint(geometry.coordinates) + await this.zoomToPoint(geometry.coordinates) } else if (geometry.type === 'Polygon') { - this.zoomToPolygon(geometry.coordinates) + await this.zoomToPolygon(geometry.coordinates) } else { console.error(`Unsupported geometry type: ${geometry.type}`) } @@ -74,17 +85,27 @@ export class GeocodingComponent implements OnDestroy { async zoomToPoint(pointCoords: [number, number]) { const context = await firstValueFrom(this.mapFacade.context$) + const view: MapContextView = { + center: pointCoords, + zoom: 10, + } this.mapFacade.applyContext({ ...context, - // TODO: change context to fit point + view, }) } async zoomToPolygon(polygonCoords: [[number, number][]]) { const context = await firstValueFrom(this.mapFacade.context$) + const view: MapContextView = { + geometry: { + type: 'Polygon', + coordinates: polygonCoords, + }, + } this.mapFacade.applyContext({ ...context, - // TODO: change context to fit polygon + view, }) } diff --git a/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.spec.ts b/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.spec.ts index 60ec309274..76a44d41ac 100644 --- a/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.spec.ts +++ b/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.spec.ts @@ -1,32 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { OrganisationsFilterComponent } from './organisations-filter.component' -import { Component, EventEmitter, Input, Output } from '@angular/core' import { TranslateModule } from '@ngx-translate/core' - -@Component({ - selector: 'gn-ui-dropdown-selector', - template: '', -}) -class DropdownSelectorMockComponent { - @Input() showTitle: unknown - @Input() choices: { - value: unknown - label: string - }[] - @Input() selected: unknown - @Output() selectValue = new EventEmitter() -} +import { MockBuilder } from 'ng-mocks' describe('OrganisationsOrderComponent', () => { let component: OrganisationsFilterComponent let fixture: ComponentFixture + beforeEach(() => { + return MockBuilder(OrganisationsFilterComponent) + }) + beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ - OrganisationsFilterComponent, - DropdownSelectorMockComponent, - ], imports: [TranslateModule.forRoot()], }).compileComponents() diff --git a/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.ts b/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.ts index b6f2049525..fd0287f5c3 100644 --- a/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.ts +++ b/libs/ui/catalog/src/lib/organisations-filter/organisations-filter.component.ts @@ -6,12 +6,19 @@ import { } from '@angular/core' import { marker } from '@biesbjerg/ngx-translate-extract-marker' import { SortByField } from '@geonetwork-ui/common/domain/model/search' -import { Subject, debounceTime } from 'rxjs' +import { debounceTime, Subject } from 'rxjs' +import { + DropdownSelectorComponent, + SearchInputComponent, +} from '@geonetwork-ui/ui/inputs' +import { TranslateModule } from '@ngx-translate/core' @Component({ selector: 'gn-ui-organisations-filter', templateUrl: './organisations-filter.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [SearchInputComponent, DropdownSelectorComponent, TranslateModule], }) export class OrganisationsFilterComponent { choices: { value: string; label: string }[] = [ diff --git a/libs/ui/catalog/src/lib/ui-catalog.module.ts b/libs/ui/catalog/src/lib/ui-catalog.module.ts index 6a9a1fdfdd..e940d3ba11 100644 --- a/libs/ui/catalog/src/lib/ui-catalog.module.ts +++ b/libs/ui/catalog/src/lib/ui-catalog.module.ts @@ -5,7 +5,6 @@ import { OrganisationPreviewComponent } from './organisation-preview/organisatio import { TranslateModule } from '@ngx-translate/core' import { MatIconModule } from '@angular/material/icon' import { UiElementsModule } from '@geonetwork-ui/ui/elements' -import { OrganisationsFilterComponent } from './organisations-filter/organisations-filter.component' import { UiInputsModule } from '@geonetwork-ui/ui/inputs' import { LanguageSwitcherComponent } from './language-switcher/language-switcher.component' import { OrganisationsResultComponent } from './organisations-result/organisations-result.component' @@ -15,7 +14,6 @@ import { RouterLink } from '@angular/router' declarations: [ CatalogTitleComponent, OrganisationPreviewComponent, - OrganisationsFilterComponent, LanguageSwitcherComponent, OrganisationsResultComponent, ], @@ -30,7 +28,6 @@ import { RouterLink } from '@angular/router' exports: [ CatalogTitleComponent, OrganisationPreviewComponent, - OrganisationsFilterComponent, LanguageSwitcherComponent, OrganisationsResultComponent, ], diff --git a/libs/ui/inputs/src/lib/search-input/search-input.component.spec.ts b/libs/ui/inputs/src/lib/search-input/search-input.component.spec.ts index 09b77fe969..eaf7c1a61f 100644 --- a/libs/ui/inputs/src/lib/search-input/search-input.component.spec.ts +++ b/libs/ui/inputs/src/lib/search-input/search-input.component.spec.ts @@ -8,7 +8,7 @@ describe('SearchInputComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - declarations: [SearchInputComponent], + imports: [SearchInputComponent], }) fixture = TestBed.createComponent(SearchInputComponent) component = fixture.componentInstance diff --git a/libs/ui/inputs/src/lib/search-input/search-input.component.ts b/libs/ui/inputs/src/lib/search-input/search-input.component.ts index 622f74d53d..c39c65684e 100644 --- a/libs/ui/inputs/src/lib/search-input/search-input.component.ts +++ b/libs/ui/inputs/src/lib/search-input/search-input.component.ts @@ -4,12 +4,16 @@ import { Input, Output, } from '@angular/core' -import { Subject, distinctUntilChanged } from 'rxjs' +import { distinctUntilChanged, Subject } from 'rxjs' +import { MatIconModule } from '@angular/material/icon' +import { CommonModule } from '@angular/common' @Component({ selector: 'gn-ui-search-input', templateUrl: './search-input.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, MatIconModule], }) export class SearchInputComponent { @Input() value = '' diff --git a/libs/ui/inputs/src/lib/ui-inputs.module.ts b/libs/ui/inputs/src/lib/ui-inputs.module.ts index c1a677c35c..42af613057 100644 --- a/libs/ui/inputs/src/lib/ui-inputs.module.ts +++ b/libs/ui/inputs/src/lib/ui-inputs.module.ts @@ -24,7 +24,6 @@ import { CopyTextButtonComponent } from './copy-text-button/copy-text-button.com import { MatTooltipModule } from '@angular/material/tooltip' import { CommonModule } from '@angular/common' import { CheckboxComponent } from './checkbox/checkbox.component' -import { SearchInputComponent } from './search-input/search-input.component' import { DateRangePickerComponent } from './date-range-picker/date-range-picker.component' import { MatFormFieldModule } from '@angular/material/form-field' import { MatInputModule } from '@angular/material/input' @@ -43,7 +42,6 @@ import { ImageInputComponent } from './image-input/image-input.component' ViewportIntersectorComponent, CopyTextButtonComponent, CheckboxComponent, - SearchInputComponent, ], imports: [ CommonModule, @@ -85,7 +83,6 @@ import { ImageInputComponent } from './image-input/image-input.component' CheckToggleComponent, CopyTextButtonComponent, CheckboxComponent, - SearchInputComponent, DateRangePickerComponent, EditableLabelDirective, ImageInputComponent, From de2c07da9851344196d8932f9bab5551a6f263ef Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 13 Sep 2024 13:24:24 +0200 Subject: [PATCH 07/12] feat(me): migrate spatial-extents map to use geo-sdk --- .../form-field-map-container.component.html | 5 +- ...form-field-map-container.component.spec.ts | 226 ++++++++---------- .../form-field-map-container.component.ts | 124 ++++------ 3 files changed, 147 insertions(+), 208 deletions(-) diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.html index 64ca7c8de2..e48ed4f4ff 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.html @@ -1,4 +1 @@ - + diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.spec.ts index 5880a29a30..317b70c8bc 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.spec.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.spec.ts @@ -1,118 +1,33 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' - import { FormFieldMapContainerComponent } from './form-field-map-container.component' -import { - defaultMapStyleFixture, - defaultMapStyleHlFixture, - MapContextModel, - MapFacade, - MapStyleService, - MapUtilsService, -} from '@geonetwork-ui/feature/map' -import { of } from 'rxjs' -import { Style } from 'ol/style' -import { StoreModule } from '@ngrx/store' -import { EffectsModule } from '@ngrx/effects' -import { Gn4Converter } from '@geonetwork-ui/api/metadata-converter' -import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' -import { TranslateModule } from '@ngx-translate/core' -import { Component, Input } from '@angular/core' -import { MapConfig } from '@geonetwork-ui/util/app-config' -import { CommonModule } from '@angular/common' -import Map from 'ol/Map' +import { MockBuilder } from 'ng-mocks' +import { firstValueFrom } from 'rxjs' +import { createViewFromLayer } from '@geospatial-sdk/core' -class ResizeObserverMock { - observe = jest.fn() - unobserve = jest.fn() - disconnect = jest.fn() -} -;(window as any).ResizeObserver = ResizeObserverMock -class Gn4MetadataMapperMock { - readRecords = jest.fn((records) => - Promise.all(records.map((r) => this.readRecord(r))) - ) - readRecord = jest.fn((record) => Promise.resolve(record)) -} -class mapStyleServiceMock { - createDefaultStyle = jest.fn(() => new Style()) - styles = { - default: defaultMapStyleFixture(), - defaultHL: defaultMapStyleHlFixture(), - } - createGeometryStyles = jest.fn(() => new Style()) - createStyleFunction = jest.fn() -} -class MapFacadeMock { - addLayer = jest.fn() - removeLayer = jest.fn() +jest.mock('@geospatial-sdk/core', () => ({ + createViewFromLayer: jest.fn(() => { + return Promise.resolve({ + zoom: 3, + center: [3, 4], + }) + }), +})) - layers$ = of([]) -} -class MapUtilsServiceMock { - getLayerExtent = jest.fn(() => null) - createEmptyMap = jest.fn(() => new Map()) -} -class PlatformServiceInterfaceMock { - searchKeywords = jest.fn(() => - of([{ label: 'Africa', thesaurus: { id: '1' } }]) - ) - getMe = jest.fn(() => of({})) -} -@Component({ - selector: 'gn-ui-map-context', - template: '
', -}) -export class MockMapContextComponent { - @Input() context: MapContextModel - @Input() mapConfig: MapConfig -} -@Component({ - selector: 'gn-ui-map', - template: '
', -}) -export class MockMapComponent { - @Input() map: Map -} describe('FormFieldMapContainerComponent', () => { let component: FormFieldMapContainerComponent let fixture: ComponentFixture - let mapFacade: MapFacade beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [MockMapContextComponent, MockMapComponent], - imports: [ - FormFieldMapContainerComponent, - EffectsModule.forRoot(), - StoreModule.forRoot({}), - TranslateModule.forRoot(), - CommonModule, - ], - providers: [ - { - provide: MapUtilsService, - useClass: MapUtilsServiceMock, - }, - { - provide: MapFacade, - useClass: MapFacadeMock, - }, - { - provide: MapStyleService, - useClass: mapStyleServiceMock, - }, - { - provide: Gn4Converter, - useClass: Gn4MetadataMapperMock, - }, - { - provide: PlatformServiceInterface, - useClass: PlatformServiceInterfaceMock, - }, - ], - }).compileComponents() + jest.clearAllMocks() + }) + + beforeEach(() => { + return MockBuilder(FormFieldMapContainerComponent) + }) + + beforeEach(() => { + TestBed.configureTestingModule({}).compileComponents() - mapFacade = TestBed.inject(MapFacade) fixture = TestBed.createComponent(FormFieldMapContainerComponent) component = fixture.componentInstance fixture.detectChanges() @@ -122,7 +37,12 @@ describe('FormFieldMapContainerComponent', () => { expect(component).toBeTruthy() }) - it('should remove previous layer and add a new layer with geometries', () => { + it('initial context is null (default map)', async () => { + const context = await firstValueFrom(component.mapContext$) + expect(context).toEqual(null) + }) + + it('should remove previous layer and add a new layer with geometries', async () => { component.spatialExtents = [ { geometry: { @@ -139,28 +59,92 @@ describe('FormFieldMapContainerComponent', () => { }, }, ] - component.ngOnChanges() - - expect( - mapFacade.layers$.subscribe((layers) => { - expect(layers.length).toEqual(1) - }) - ) + const context = await firstValueFrom(component.mapContext$) + expect(context).toEqual({ + layers: [ + { + data: { + features: [ + { + geometry: { + coordinates: [ + [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ], + ], + type: 'Polygon', + }, + properties: {}, + type: 'Feature', + }, + ], + type: 'FeatureCollection', + }, + label: 'Spatial extents', + style: { + 'stroke-color': 'black', + 'stroke-width': 2, + }, + type: 'geojson', + }, + ], + view: { + center: [3, 4], + zoom: 3, + }, + }) + expect(createViewFromLayer).toHaveBeenCalledWith(context.layers[0]) }) - it('should remove previous layer and add a new layer with bbox', () => { + it('should remove previous layer and add a new layer with bbox', async () => { component.spatialExtents = [ { bbox: [0, 0, 1, 1], }, ] - component.ngOnChanges() - - expect( - mapFacade.layers$.subscribe((layers) => { - expect(layers.length).toEqual(1) - }) - ) + const context = await firstValueFrom(component.mapContext$) + expect(context).toEqual({ + layers: [ + { + data: { + features: [ + { + geometry: { + coordinates: [ + [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ], + ], + type: 'Polygon', + }, + properties: {}, + type: 'Feature', + }, + ], + type: 'FeatureCollection', + }, + label: 'Spatial extents', + style: { + 'stroke-color': 'black', + 'stroke-width': 2, + }, + type: 'geojson', + }, + ], + view: { + center: [3, 4], + zoom: 3, + }, + }) + expect(createViewFromLayer).toHaveBeenCalledWith(context.layers[0]) }) it('should transform bbox to geometry', () => { diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.ts index fe108e3e3f..159c245000 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-map-container/form-field-map-container.component.ts @@ -1,95 +1,41 @@ -import { - ChangeDetectionStrategy, - Component, - Input, - OnChanges, -} from '@angular/core' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { CommonModule } from '@angular/common' -import { catchError, from, map, Observable, of, switchMap } from 'rxjs' -import { - DEFAULT_BASELAYER_CONTEXT, - FeatureMapModule, - MapContextLayerTypeEnum, - MapContextModel, - MapFacade, - MapStyleService, - MapUtilsService, -} from '@geonetwork-ui/feature/map' -import { Fill, Stroke, Style } from 'ol/style' -import { getOptionalMapConfig, MapConfig } from '@geonetwork-ui/util/app-config' import { Geometry } from 'geojson' -import { GeoJSONFeatureCollection } from 'ol/format/GeoJSON' +import GeoJSON, { GeoJSONFeatureCollection } from 'ol/format/GeoJSON' import { DatasetSpatialExtent } from '@geonetwork-ui/common/domain/model/record' import { Polygon } from 'ol/geom' -import GeoJSON from 'ol/format/GeoJSON' +import { + createViewFromLayer, + MapContext, + MapContextLayer, +} from '@geospatial-sdk/core' +import { MapContainerComponent } from '@geonetwork-ui/ui/map' +import { BehaviorSubject, Observable } from 'rxjs' +import { switchMap } from 'rxjs/operators' @Component({ selector: 'gn-ui-form-field-map-container', standalone: true, - imports: [CommonModule, FeatureMapModule], + imports: [CommonModule, MapContainerComponent], templateUrl: './form-field-map-container.component.html', styleUrls: ['./form-field-map-container.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FormFieldMapContainerComponent implements OnChanges { - @Input() spatialExtents: DatasetSpatialExtent[] - - error = '' - mapContext$: Observable = this.mapFacade.layers$.pipe( - switchMap((layers) => - from(this.mapUtils.getLayerExtent(layers[0])).pipe( - catchError(() => { - this.error = 'The layer has no extent' - return of(undefined) - }), - map((extent) => { - return { - layers: [DEFAULT_BASELAYER_CONTEXT, ...layers], - view: { - extent: extent, - }, - } as MapContextModel - }) - ) - ) - ) - - mapConfig: MapConfig = getOptionalMapConfig() - - constructor( - private mapFacade: MapFacade, - private mapUtils: MapUtilsService, - private styleService: MapStyleService - ) { - const fill = new Fill({ - color: 'transparent', - }) - const stroke = new Stroke({ - color: 'black', - width: 2, - }) - const styles = [ - new Style({ - fill: fill, - stroke: stroke, - }), - ] - this.styleService.styles.default = this.styleService.createStyleFunction({ - ...this.styleService.createGeometryStyles({ color: 'black' }), - polygon: styles, - }) +export class FormFieldMapContainerComponent { + @Input() set spatialExtents(value: DatasetSpatialExtent[]) { + this.spatialExtents$.next(value) } - - ngOnChanges(): void { - this.mapFacade.removeLayer(0) - - if (this.spatialExtents) { + spatialExtents$ = new BehaviorSubject([]) + mapContext$: Observable = this.spatialExtents$.pipe( + switchMap(async (extents) => { + if (extents.length === 0) { + return null // null extent means default view + } const featureCollection: GeoJSONFeatureCollection = { type: 'FeatureCollection', features: [], } - - this.spatialExtents.forEach((extent) => { + extents.forEach((extent) => { if (extent.geometry) { featureCollection.features.push({ type: 'Feature', @@ -104,13 +50,26 @@ export class FormFieldMapContainerComponent implements OnChanges { }) } }) - this.mapFacade.addLayer({ - type: MapContextLayerTypeEnum.GEOJSON, + + const layer: MapContextLayer = { + type: 'geojson', data: featureCollection, - title: 'Spatial extents', - }) - } - } + label: 'Spatial extents', + style: { + 'stroke-color': 'black', + 'stroke-width': 2, + }, + } + const view = await createViewFromLayer(layer) + return { + view, + layers: [layer], + } + }) + ) + + error = '' + bboxCoordsToGeometry(bbox: [number, number, number, number]): Geometry { const geometry = new Polygon([ [ @@ -122,7 +81,6 @@ export class FormFieldMapContainerComponent implements OnChanges { ], ]) - const geoJSONGeom = new GeoJSON().writeGeometryObject(geometry) - return geoJSONGeom + return new GeoJSON().writeGeometryObject(geometry) } } From 3b373057228f2f92c131198c0f7e371b85b5633e Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 13 Sep 2024 18:12:30 +0200 Subject: [PATCH 08/12] chore: add geospatial-sdk deps --- package-lock.json | 183 +++++++++++++++++++++++++++++++++++++++- package.json | 4 +- package/ng-package.json | 2 + package/package.json | 4 +- 4 files changed, 187 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8744beeb59..1715eddfb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,9 @@ "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", "@camptocamp/ogc-client": "1.1.1-dev.ad6d9ab", - "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", + "@geospatial-sdk/core": "^0.0.5-dev.20", + "@geospatial-sdk/geocoding": "^0.0.5-dev.20", + "@geospatial-sdk/openlayers": "^0.0.5-dev.20", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", "@nestjs/common": "10.1.3", @@ -4436,10 +4438,79 @@ "integrity": "sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==", "dev": true }, + "node_modules/@geospatial-sdk/core": { + "version": "0.0.5-dev.20", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/core/-/core-0.0.5-dev.20.tgz", + "integrity": "sha512-Gr0TwGx6ORN5jHUpleXOpA1g7DnVa5dHu74sw8XEJTJWl9kCiFPVicfW3Hrq1Qy25SnTqfp7lHcvGFHPKHSDlQ==", + "dependencies": { + "@camptocamp/ogc-client": "1.1.1-dev.c75dcba", + "proj4": "^2.9.2" + } + }, + "node_modules/@geospatial-sdk/core/node_modules/@camptocamp/ogc-client": { + "version": "1.1.1-dev.c75dcba", + "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.1.1-dev.c75dcba.tgz", + "integrity": "sha512-Zz7nz6Rcf9JQG/MjW9sU69BLTFqBd7samrl4x55Pbmp9WpmVVsgEANJduVkiUGJ8A/y97LAR3h3/i5DZVs7Z8Q==", + "dependencies": { + "@rgrove/parse-xml": "^4.1.0", + "node-fetch": "^3.3.1" + }, + "peerDependencies": { + "ol": ">5.x", + "proj4": ">2.8" + }, + "peerDependenciesMeta": { + "ol": { + "optional": true + }, + "proj4": { + "optional": true + } + } + }, + "node_modules/@geospatial-sdk/core/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/@geospatial-sdk/core/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/@geospatial-sdk/geocoding": { - "version": "0.0.5-alpha.2", - "resolved": "https://registry.npmjs.org/@geospatial-sdk/geocoding/-/geocoding-0.0.5-alpha.2.tgz", - "integrity": "sha512-q9szQpj+/a0A1Dp9+na8wdkhouMhegLVTD5bB1DkHCJW5eG8CUA/cPzfg1REONNQgXMNUHZHp8mGjQEmTu/zHQ==" + "version": "0.0.5-dev.20", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/geocoding/-/geocoding-0.0.5-dev.20.tgz", + "integrity": "sha512-kAR+06UUNwYuEgm8y8QT44rwekKPfE7DWBxESYeg9mMIM4wEupOdQ3BLzgArpKwrnP80hQbXwLxd2ssJ5uSM5Q==" + }, + "node_modules/@geospatial-sdk/openlayers": { + "version": "0.0.5-dev.20", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/openlayers/-/openlayers-0.0.5-dev.20.tgz", + "integrity": "sha512-WcgtPmg4+kllDnl74s0980h6wJFDKT9knW5JWlO/6/L6BbjDSJSSZK81i9AFtTMyTiYRJ1qxKQx7jkLQfXbzMA==", + "dependencies": { + "@geospatial-sdk/core": "^0.0.5-dev.20+87e3bcf", + "chroma-js": "^2.4.2", + "lodash.throttle": "^4.1.1", + "ol-mapbox-style": "^12.3.5" + }, + "peerDependencies": { + "ol": ">6.x" + } }, "node_modules/@humanwhocodes/config-array": { "version": "0.9.5", @@ -5189,6 +5260,45 @@ "node": ">=8" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-style-spec": { + "version": "13.28.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.28.0.tgz", + "integrity": "sha512-B8xM7Fp1nh5kejfIl4SWeY0gtIeewbuRencqO3cJDrCHZpaPg7uY+V8abuR+esMeuOjRl5cLhVTP40v+1ywxbg==", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/unitbezier": "^0.0.0", + "csscolorparser": "~1.0.2", + "json-stringify-pretty-compact": "^2.0.0", + "minimist": "^1.2.6", + "rw": "^1.3.3", + "sort-object": "^0.3.2" + }, + "bin": { + "gl-style-composite": "bin/gl-style-composite.js", + "gl-style-format": "bin/gl-style-format.js", + "gl-style-migrate": "bin/gl-style-migrate.js", + "gl-style-validate": "bin/gl-style-validate.js" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==" + }, "node_modules/@material/animation": { "version": "15.0.0-canary.bc9ae6c9c.0", "resolved": "https://registry.npmjs.org/@material/animation/-/animation-15.0.0-canary.bc9ae6c9c.0.tgz", @@ -16660,6 +16770,11 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -23041,6 +23156,11 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, + "node_modules/json-stringify-pretty-compact": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz", + "integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -23482,6 +23602,11 @@ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "dev": true }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -23756,6 +23881,11 @@ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", "dev": true }, + "node_modules/mapbox-to-css-font": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-2.4.5.tgz", + "integrity": "sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==" + }, "node_modules/mark.js": { "version": "8.11.1", "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", @@ -25820,6 +25950,18 @@ "url": "https://opencollective.com/openlayers" } }, + "node_modules/ol-mapbox-style": { + "version": "12.3.5", + "resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-12.3.5.tgz", + "integrity": "sha512-1tdq+jpzJ7BuqCeRpNV5u90X369MXDbHKpPPt0BNpbzi+4UEJ2dJIrd3eFQV9VbqvZeEIioEjyK7qOqXsUZs8w==", + "dependencies": { + "@mapbox/mapbox-gl-style-spec": "^13.23.1", + "mapbox-to-css-font": "^2.4.1" + }, + "peerDependencies": { + "ol": "*" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -28598,6 +28740,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -29188,6 +29335,34 @@ "node": ">= 10" } }, + "node_modules/sort-asc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", + "integrity": "sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.1.1.tgz", + "integrity": "sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-0.3.2.tgz", + "integrity": "sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==", + "dependencies": { + "sort-asc": "^0.1.0", + "sort-desc": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", diff --git a/package.json b/package.json index 2c094bd3ab..c46356c254 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,9 @@ "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", "@camptocamp/ogc-client": "1.1.1-dev.ad6d9ab", - "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", + "@geospatial-sdk/core": "^0.0.5-dev.20", + "@geospatial-sdk/geocoding": "^0.0.5-dev.20", + "@geospatial-sdk/openlayers": "^0.0.5-dev.20", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", "@nestjs/common": "10.1.3", diff --git a/package/ng-package.json b/package/ng-package.json index 25631cb6b5..ae171d34c5 100644 --- a/package/ng-package.json +++ b/package/ng-package.json @@ -7,6 +7,8 @@ "allowedNonPeerDependencies": [ "@biesbjerg/ngx-translate-extract-marker", "@camptocamp/ogc-client", + "@geospatial-sdk/core", + "@geospatial-sdk/openlayers", "@geospatial-sdk/geocoding", "@ltd/j-toml", "@messageformat/core", diff --git a/package/package.json b/package/package.json index 50a021937b..61e635c3a4 100644 --- a/package/package.json +++ b/package/package.json @@ -39,7 +39,9 @@ "dependencies": { "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", "@camptocamp/ogc-client": "1.1.1-dev.ad6d9ab", - "@geospatial-sdk/geocoding": "^0.0.5-alpha.2", + "@geospatial-sdk/core": "^0.0.5-dev.20", + "@geospatial-sdk/geocoding": "^0.0.5-dev.20", + "@geospatial-sdk/openlayers": "^0.0.5-dev.20", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", "@nx/angular": "16.6.0", From 66308c0f8bd6c5bae7051b30eabb4b5039527b34 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Sun, 15 Sep 2024 16:43:20 +0200 Subject: [PATCH 09/12] chore: update ogc-client Several more dependencies are now only available in ESM only, so they need to be transformed for Jest. --- jest.preset.js | 15 ++++++++- package-lock.json | 74 ++++++++++++++------------------------------ package.json | 2 +- package/package.json | 2 +- 4 files changed, 39 insertions(+), 54 deletions(-) diff --git a/jest.preset.js b/jest.preset.js index e7f46ce016..8c67db3562 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -1,11 +1,24 @@ const nxPreset = require('@nx/jest/preset').default +const npmDependenciesOnlyInEsm = [ + 'color-*', + 'ol', + '@mapbox', + '@geospatial-sdk', + '@camptocamp/ogc-client', + 'node-fetch', + 'data-uri-to-buffer', + 'fetch-blob', + 'formdata-polyfill', + '.*.mjs', +] + module.exports = { ...nxPreset, coverageReporters: ['text'], setupFiles: ['jest-canvas-mock'], transformIgnorePatterns: [ - 'node_modules/(?!(color-*|ol|@mapbox|@geospatial-sdk|@camptocamp/ogc-client|.*.mjs$))', + `node_modules/(?!(${npmDependenciesOnlyInEsm.join('|')}))`, ], transform: { '^.+\\.(ts|mjs|js|html)$': [ diff --git a/package-lock.json b/package-lock.json index 1715eddfb0..949dc36c9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@angular/router": "^16.2", "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@camptocamp/ogc-client": "1.1.1-dev.ad6d9ab", + "@camptocamp/ogc-client": "1.1.1-dev.c75dcba", "@geospatial-sdk/core": "^0.0.5-dev.20", "@geospatial-sdk/geocoding": "^0.0.5-dev.20", "@geospatial-sdk/openlayers": "^0.0.5-dev.20", @@ -3739,11 +3739,12 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/@camptocamp/ogc-client": { - "version": "1.1.1-dev.ad6d9ab", - "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.1.1-dev.ad6d9ab.tgz", - "integrity": "sha512-YK0xaVij5bScgYeXJKIItLu6eoVC/lrCIoH5UepkXG3oUSERnFNs60PumwwH8mAWclV2AAPeOwFe60dNvY356Q==", + "version": "1.1.1-dev.c75dcba", + "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.1.1-dev.c75dcba.tgz", + "integrity": "sha512-Zz7nz6Rcf9JQG/MjW9sU69BLTFqBd7samrl4x55Pbmp9WpmVVsgEANJduVkiUGJ8A/y97LAR3h3/i5DZVs7Z8Q==", "dependencies": { - "@rgrove/parse-xml": "^4.1.0" + "@rgrove/parse-xml": "^4.1.0", + "node-fetch": "^3.3.1" }, "peerDependencies": { "ol": ">5.x", @@ -3766,6 +3767,23 @@ "node": ">=14.0.0" } }, + "node_modules/@camptocamp/ogc-client/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4447,52 +4465,6 @@ "proj4": "^2.9.2" } }, - "node_modules/@geospatial-sdk/core/node_modules/@camptocamp/ogc-client": { - "version": "1.1.1-dev.c75dcba", - "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.1.1-dev.c75dcba.tgz", - "integrity": "sha512-Zz7nz6Rcf9JQG/MjW9sU69BLTFqBd7samrl4x55Pbmp9WpmVVsgEANJduVkiUGJ8A/y97LAR3h3/i5DZVs7Z8Q==", - "dependencies": { - "@rgrove/parse-xml": "^4.1.0", - "node-fetch": "^3.3.1" - }, - "peerDependencies": { - "ol": ">5.x", - "proj4": ">2.8" - }, - "peerDependenciesMeta": { - "ol": { - "optional": true - }, - "proj4": { - "optional": true - } - } - }, - "node_modules/@geospatial-sdk/core/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/@geospatial-sdk/core/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/@geospatial-sdk/geocoding": { "version": "0.0.5-dev.20", "resolved": "https://registry.npmjs.org/@geospatial-sdk/geocoding/-/geocoding-0.0.5-dev.20.tgz", diff --git a/package.json b/package.json index c46356c254..70acb36468 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@angular/router": "^16.2", "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@camptocamp/ogc-client": "1.1.1-dev.ad6d9ab", + "@camptocamp/ogc-client": "1.1.1-dev.c75dcba", "@geospatial-sdk/core": "^0.0.5-dev.20", "@geospatial-sdk/geocoding": "^0.0.5-dev.20", "@geospatial-sdk/openlayers": "^0.0.5-dev.20", diff --git a/package/package.json b/package/package.json index 61e635c3a4..b63e6793b6 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": "1.1.1-dev.ad6d9ab", + "@camptocamp/ogc-client": "1.1.1-dev.c75dcba", "@geospatial-sdk/core": "^0.0.5-dev.20", "@geospatial-sdk/geocoding": "^0.0.5-dev.20", "@geospatial-sdk/openlayers": "^0.0.5-dev.20", From d011705ac1373b859cb79b2faca0cc42995795c8 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Wed, 18 Sep 2024 10:41:07 +0200 Subject: [PATCH 10/12] chore: fixes post review The gn-mapviewer web component should work now layers should appear with their label in the layers panel Add missing styles in web components Fix a failing e2e test --- apps/metadata-editor-e2e/src/e2e/edit.cy.ts | 2 +- apps/webcomponents/project.json | 7 ++++++- .../gn-map-viewer/gn-map-viewer.component.html | 2 +- apps/webcomponents/src/app/webcomponents.module.ts | 8 +++++++- .../geo-table-view/geo-table-view.component.spec.ts | 1 + .../geo-table-view.component.stories.ts | 12 +++++++++--- .../lib/geo-table-view/geo-table-view.component.ts | 1 + .../src/lib/layers-panel/layers-panel.component.html | 4 ++-- 8 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/metadata-editor-e2e/src/e2e/edit.cy.ts b/apps/metadata-editor-e2e/src/e2e/edit.cy.ts index eaed41954e..c6492c2616 100644 --- a/apps/metadata-editor-e2e/src/e2e/edit.cy.ts +++ b/apps/metadata-editor-e2e/src/e2e/edit.cy.ts @@ -346,7 +346,7 @@ describe('editor form', () => { describe('geographical coverage', () => { it('should show a map', () => { cy.get('gn-ui-form-field-spatial-extent') - .find('gn-ui-map') + .find('gn-ui-map-container') .should('be.visible') }) describe('spatial extents', () => { diff --git a/apps/webcomponents/project.json b/apps/webcomponents/project.json index cd6fd9c1d3..f6b63ff2aa 100644 --- a/apps/webcomponents/project.json +++ b/apps/webcomponents/project.json @@ -15,7 +15,12 @@ "polyfills": "apps/webcomponents/src/polyfills.ts", "tsConfig": "apps/webcomponents/tsconfig.app.json", "assets": [], - "styles": [], + "styles": [ + "tailwind.base.css", + "apps/webcomponents/src/styles.css", + "node_modules/tippy.js/dist/tippy.css", + "node_modules/ol/ol.css" + ], "scripts": [], "allowedCommonJsDependencies": [ "duration-relativetimeformat", diff --git a/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.html b/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.html index e46ab441a3..8a33b146de 100644 --- a/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.html +++ b/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.html @@ -1,5 +1,5 @@
- + BaseComponent, string][] = [ FeatureDatavizModule, FeatureAuthModule, BrowserAnimationsModule, + MapStateContainerComponent, + LayersPanelComponent, ], providers: [ provideGn4(), diff --git a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.spec.ts b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.spec.ts index d05ffc3346..46f46b9589 100644 --- a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.spec.ts +++ b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.spec.ts @@ -119,6 +119,7 @@ describe('GeoTableViewComponent', () => { coordinates: [2.335333, 51.070817], type: 'Point', }, + maxZoom: 13, }) }) }) diff --git a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts index 5331ae0e33..3f0a28b94c 100644 --- a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts +++ b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.stories.ts @@ -12,17 +12,23 @@ import { FeatureDetailComponent, MapContainerComponent, } from '@geonetwork-ui/ui/map' -import { polygonFeatureCollectionFixture } from '@geonetwork-ui/common/fixtures' +import { pointFeatureCollectionFixture } from '@geonetwork-ui/common/fixtures' import { TableComponent } from '@geonetwork-ui/ui/dataviz' import { HttpClientModule } from '@angular/common/http' import { TRANSLATE_DEFAULT_CONFIG } from '@geonetwork-ui/util/i18n' +import { BrowserAnimationsModule } from '@angular/platform-browser/animations' export default { title: 'Map/GeoTable', component: GeoTableViewComponent, decorators: [ moduleMetadata({ - imports: [FeatureDetailComponent, MapContainerComponent, TableComponent], + imports: [ + FeatureDetailComponent, + MapContainerComponent, + TableComponent, + BrowserAnimationsModule, + ], }), applicationConfig({ providers: [ @@ -38,6 +44,6 @@ export default { export const Primary: StoryObj = { args: { - data: polygonFeatureCollectionFixture(), + data: pointFeatureCollectionFixture(), }, } diff --git a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts index e709b06f6f..0d47f5fd5c 100644 --- a/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts +++ b/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts @@ -96,6 +96,7 @@ export class GeoTableViewComponent implements OnInit, OnDestroy { ...this.mapContext, view: { geometry: feature.geometry, + maxZoom: 13, }, } // FIXME: animate the view 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 ce5d06cdfb..2b08697279 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 @@ -13,11 +13,11 @@ chevron_right - {{ layer.title }} + {{ layer.label }} delete From 4d6361d4877f2bec5432af10c8f912f7df818421 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Wed, 18 Sep 2024 11:00:48 +0200 Subject: [PATCH 11/12] chore: update geo-sdk again --- package-lock.json | 26 +++++++++++++------------- package.json | 6 +++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 949dc36c9b..f3656a227f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,9 @@ "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", "@camptocamp/ogc-client": "1.1.1-dev.c75dcba", - "@geospatial-sdk/core": "^0.0.5-dev.20", - "@geospatial-sdk/geocoding": "^0.0.5-dev.20", - "@geospatial-sdk/openlayers": "^0.0.5-dev.20", + "@geospatial-sdk/core": "^0.0.5-dev.21", + "@geospatial-sdk/geocoding": "^0.0.5-dev.21", + "@geospatial-sdk/openlayers": "^0.0.5-dev.21", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", "@nestjs/common": "10.1.3", @@ -4457,25 +4457,25 @@ "dev": true }, "node_modules/@geospatial-sdk/core": { - "version": "0.0.5-dev.20", - "resolved": "https://registry.npmjs.org/@geospatial-sdk/core/-/core-0.0.5-dev.20.tgz", - "integrity": "sha512-Gr0TwGx6ORN5jHUpleXOpA1g7DnVa5dHu74sw8XEJTJWl9kCiFPVicfW3Hrq1Qy25SnTqfp7lHcvGFHPKHSDlQ==", + "version": "0.0.5-dev.21", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/core/-/core-0.0.5-dev.21.tgz", + "integrity": "sha512-1IXWazGYbPNMi9DDiMYiFxVSM3h3+ybvCglQln+SXctJV6Rpzdu13sBNtRrDginqJCD766C7YjQvxtCWwRP92g==", "dependencies": { "@camptocamp/ogc-client": "1.1.1-dev.c75dcba", "proj4": "^2.9.2" } }, "node_modules/@geospatial-sdk/geocoding": { - "version": "0.0.5-dev.20", - "resolved": "https://registry.npmjs.org/@geospatial-sdk/geocoding/-/geocoding-0.0.5-dev.20.tgz", - "integrity": "sha512-kAR+06UUNwYuEgm8y8QT44rwekKPfE7DWBxESYeg9mMIM4wEupOdQ3BLzgArpKwrnP80hQbXwLxd2ssJ5uSM5Q==" + "version": "0.0.5-dev.21", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/geocoding/-/geocoding-0.0.5-dev.21.tgz", + "integrity": "sha512-jX/2JBZzE0CNpYkBwnc29lN3rWzkj6VX9DqfxOq1n75ggpNQRr+b4xEbIsI40a55VGCTCTO1My3eTXogebN3Dg==" }, "node_modules/@geospatial-sdk/openlayers": { - "version": "0.0.5-dev.20", - "resolved": "https://registry.npmjs.org/@geospatial-sdk/openlayers/-/openlayers-0.0.5-dev.20.tgz", - "integrity": "sha512-WcgtPmg4+kllDnl74s0980h6wJFDKT9knW5JWlO/6/L6BbjDSJSSZK81i9AFtTMyTiYRJ1qxKQx7jkLQfXbzMA==", + "version": "0.0.5-dev.21", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/openlayers/-/openlayers-0.0.5-dev.21.tgz", + "integrity": "sha512-NIL2TxmwOnpfDAB8dC9RGsuVjUCMZy/iFhtQpYW01UQ0MFAkwMN7osSMg59XbYmp3g93GfQKPnS8A0hbM8mgJA==", "dependencies": { - "@geospatial-sdk/core": "^0.0.5-dev.20+87e3bcf", + "@geospatial-sdk/core": "^0.0.5-dev.21+89507d8", "chroma-js": "^2.4.2", "lodash.throttle": "^4.1.1", "ol-mapbox-style": "^12.3.5" diff --git a/package.json b/package.json index 70acb36468..6af955bf37 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,9 @@ "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", "@camptocamp/ogc-client": "1.1.1-dev.c75dcba", - "@geospatial-sdk/core": "^0.0.5-dev.20", - "@geospatial-sdk/geocoding": "^0.0.5-dev.20", - "@geospatial-sdk/openlayers": "^0.0.5-dev.20", + "@geospatial-sdk/core": "^0.0.5-dev.21", + "@geospatial-sdk/geocoding": "^0.0.5-dev.21", + "@geospatial-sdk/openlayers": "^0.0.5-dev.21", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", "@nestjs/common": "10.1.3", From dd44d8016e4bb17425c19dcb13015140cb3db794 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Wed, 18 Sep 2024 11:20:02 +0200 Subject: [PATCH 12/12] docs: add doc on interactive maps --- docs/.vitepress/config.ts | 4 ++++ docs/reference/maps.md | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 docs/reference/maps.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index dafda188d8..b7f751258a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -99,6 +99,10 @@ function sidebarReference() { text: 'Pivot Format', link: '/reference/pivot-format', }, + { + text: 'Interactive maps', + link: '/reference/maps', + }, ], }, { diff --git a/docs/reference/maps.md b/docs/reference/maps.md new file mode 100644 index 0000000000..83dce0dc9b --- /dev/null +++ b/docs/reference/maps.md @@ -0,0 +1,50 @@ +--- +outline: deep +--- + +# Interactive maps + +GeoNetwork-UI relies on the [geospatial-sdk](https://github.com/camptocamp/geospatial-sdk) library to render maps. This library works by taking in a Map Context ([see the model here](https://github.com/camptocamp/geospatial-sdk/blob/main/packages/core/lib/model/map-context.ts)) describing the layers and the view of the map to be shown. + +Two components are present in GeoNetwork-UI to render a map using a context. + +## `MapContainerComponent` + +This component simply takes a map context as input and will render it. Everytime the map context changes, the map is updated accordingly. + +This component also offers the following events: `mapClick`, `featuresClicked`, `featuresHovered`. + +```ts +import { MapContainerComponent } from '@geonetwork-ui/ui/map' +``` + +```html + +``` + +There are a couple of injection tokens that can be used to specify some map options: + +- `BASEMAP_LAYERS`: this allows specifying layers that will be added in the background of the map, regardless of the layers in the context; note that there is always a default background tile layer so that the map shown is never empty; this default background layer can be disabled by setting the `DO_NOT_USE_DEFAULT_BASEMAP` token to `true` +- `MAP_VIEW_CONSTRAINTS`: this allows specifying `maxZoom` and `maxExtent` options that will be applied regardless of the map context + +## `MapStateContainerComponent` + +This component is connected to a map state accessible through the `MapFacade` class. This allows changing the context used in the map from anywhere in the application, as well as showing the currently selected feature in the map (if any). + +The `LayersPanel` component is an example of how another component can interact with the map through the `MapFacade` class. + +```ts +import { + MapStateContainerComponent, + MapFacade, + LayersPanel, +} from '@geonetwork-ui/feature/map' +``` + +```html + + +```