diff --git a/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts b/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts index 088b06fb4..4db6144c2 100644 --- a/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts +++ b/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts @@ -331,10 +331,12 @@ describe('dataset pages', () => { .children('button') .should('have.length.gt', 1) }) - it('should display the map', () => { + it('should display the map and the legend', () => { cy.get('@previewSection') .find('gn-ui-map-container') .should('be.visible') + + cy.get('@previewSection').find('gn-ui-map-legend').should('be.visible') }) it('should display the table', () => { cy.get('@previewSection') 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 fb1da0662..e85b3dee3 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 @@ -25,14 +25,56 @@ class="top-[1em] right-[1em] p-3 bg-white absolute overflow-y-auto overflow-x-hidden max-h-72 w-56" [class.hidden]="!selection" > - + + +
+
+
Legend
+ + + +
+ +
+ + + Legend + + { useClass: DataServiceMock, }, ], - imports: [TranslateModule.forRoot()], + imports: [TranslateModule.forRoot(), MapLegendComponent], }).compileComponents() mdViewFacade = TestBed.inject(MdViewFacade) }) @@ -768,6 +769,25 @@ describe('MapViewComponent', () => { }) }) + describe('display legend', () => { + it('should render the MapLegendComponent', () => { + const legendComponent = fixture.debugElement.query( + By.directive(MapLegendComponent) + ) + expect(legendComponent).toBeTruthy() + }) + it('should handle legendStatusChange event', () => { + const legendComponent = fixture.debugElement.query( + By.directive(MapLegendComponent) + ).componentInstance + const legendStatusChangeSpy = jest.spyOn( + component, + 'onLegendStatusChange' + ) + legendComponent.legendStatusChange.emit(true) + expect(legendStatusChangeSpy).toHaveBeenCalledWith(true) + }) + }) describe('map view extent', () => { describe('if no record extent', () => { beforeEach(fakeAsync(() => { 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 9b16ef2ab..bc7dfc2a4 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 @@ -22,6 +22,7 @@ import { distinctUntilChanged, finalize, map, + shareReplay, switchMap, tap, } from 'rxjs/operators' @@ -37,12 +38,16 @@ import { FeatureDetailComponent, MapContainerComponent, prioritizePageScroll, + MapLegendComponent, } from '@geonetwork-ui/ui/map' import { Feature } from 'geojson' import { NgIconComponent, provideIcons } from '@ng-icons/core' import { matClose } from '@ng-icons/material-icons/baseline' import { CommonModule } from '@angular/common' -import { DropdownSelectorComponent } from '@geonetwork-ui/ui/inputs' +import { + ButtonComponent, + DropdownSelectorComponent, +} from '@geonetwork-ui/ui/inputs' import { TranslateModule } from '@ngx-translate/core' import { ExternalViewerButtonComponent } from '../external-viewer-button/external-viewer-button.component' import { @@ -66,6 +71,8 @@ import { LoadingMaskComponent, NgIconComponent, ExternalViewerButtonComponent, + ButtonComponent, + MapLegendComponent, ], viewProviders: [provideIcons({ matClose })], }) @@ -73,6 +80,19 @@ export class MapViewComponent implements AfterViewInit { @ViewChild('mapContainer') mapContainer: MapContainerComponent selection: Feature + showLegend = true + legendExists = false + + toggleLegend() { + this.showLegend = !this.showLegend + } + + onLegendStatusChange(status: boolean) { + this.legendExists = status + if (!status) { + this.showLegend = false + } + } compatibleMapLinks$ = combineLatest([ this.mdViewFacade.mapApiLinks$, @@ -148,7 +168,8 @@ export class MapViewComponent implements AfterViewInit { ...context, view, } - }) + }), + shareReplay(1) ) constructor( diff --git a/libs/ui/map/src/index.ts b/libs/ui/map/src/index.ts index fb10d8a26..921923dda 100644 --- a/libs/ui/map/src/index.ts +++ b/libs/ui/map/src/index.ts @@ -1,4 +1,5 @@ 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/components/map-legend/map-legend.component' export * from './lib/map-utils' diff --git a/libs/ui/map/src/lib/components/map-legend/map-legend.component.css b/libs/ui/map/src/lib/components/map-legend/map-legend.component.css new file mode 100644 index 000000000..91a65cd49 --- /dev/null +++ b/libs/ui/map/src/lib/components/map-legend/map-legend.component.css @@ -0,0 +1,5 @@ +.geosdk--legend-container { + overflow: auto; + white-space: normal; + word-wrap: break-word; +} diff --git a/libs/ui/map/src/lib/components/map-legend/map-legend.component.html b/libs/ui/map/src/lib/components/map-legend/map-legend.component.html new file mode 100644 index 000000000..35ed987a8 --- /dev/null +++ b/libs/ui/map/src/lib/components/map-legend/map-legend.component.html @@ -0,0 +1 @@ +
diff --git a/libs/ui/map/src/lib/components/map-legend/map-legend.component.spec.ts b/libs/ui/map/src/lib/components/map-legend/map-legend.component.spec.ts new file mode 100644 index 000000000..1c4cd1f51 --- /dev/null +++ b/libs/ui/map/src/lib/components/map-legend/map-legend.component.spec.ts @@ -0,0 +1,150 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { MapLegendComponent } from './map-legend.component' +import { MapContext } from '@geospatial-sdk/core' +import { createLegendFromLayer } from '@geospatial-sdk/legend' + +jest.mock('@geospatial-sdk/legend', () => ({ + createLegendFromLayer: jest.fn(), +})) + +describe('MapLegendComponent', () => { + let component: MapLegendComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MapLegendComponent], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(MapLegendComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + describe('Change of map-context', () => { + it('should create legend on first change', async () => { + const mockContext: MapContext = { + layers: [ + { + id: 'test-layer', + }, + ], + } as MapContext + + const mockLegendElement = document.createElement('div') + ;(createLegendFromLayer as jest.Mock).mockResolvedValue(mockLegendElement) + + const legendStatusChangeSpy = jest.spyOn( + component.legendStatusChange, + 'emit' + ) + + await component.ngOnChanges({ + context: { + currentValue: mockContext, + previousValue: null, + firstChange: true, + isFirstChange: () => true, + }, + }) + + expect(createLegendFromLayer).toHaveBeenCalledWith(mockContext.layers[0]) + expect(component.legendHTML).toBe(mockLegendElement) + expect(legendStatusChangeSpy).toHaveBeenCalledWith(true) + }) + + it('should create legend and emit status on subsequent context changes', async () => { + const mockContext: MapContext = { + layers: [ + { + id: 'test-layer', + }, + ], + } as MapContext + + const mockLegendElement = document.createElement('div') + ;(createLegendFromLayer as jest.Mock).mockResolvedValue(mockLegendElement) + + const legendStatusChangeSpy = jest.spyOn( + component.legendStatusChange, + 'emit' + ) + + await component.ngOnChanges({ + context: { + currentValue: mockContext, + previousValue: {}, + firstChange: false, + isFirstChange: () => false, + }, + }) + + expect(createLegendFromLayer).toHaveBeenCalledWith(mockContext.layers[0]) + expect(component.legendHTML).toBe(mockLegendElement) + expect(legendStatusChangeSpy).toHaveBeenCalledWith(true) + }) + + it('should emit nothing when no legend is created', async () => { + const mockContext: MapContext = { + layers: [ + { + id: 'test-layer', + }, + ], + } as MapContext + + ;(createLegendFromLayer as jest.Mock).mockResolvedValue(false) + + const legendStatusChangeSpy = jest.spyOn( + component.legendStatusChange, + 'emit' + ) + + await component.ngOnChanges({ + context: { + currentValue: mockContext, + previousValue: {}, + firstChange: false, + isFirstChange: () => false, + }, + }) + + expect(createLegendFromLayer).toHaveBeenCalledWith(mockContext.layers[0]) + expect(component.legendHTML).toBe(false) + expect(legendStatusChangeSpy).not.toHaveBeenCalled() + }) + + it('should handle multiple layers', async () => { + const mockContext: MapContext = { + layers: [{ id: 'layer-1' }, { id: 'layer-2' }], + } as MapContext + + const mockLegendElement = document.createElement('div') + ;(createLegendFromLayer as jest.Mock).mockResolvedValue(mockLegendElement) + + const legendStatusChangeSpy = jest.spyOn( + component.legendStatusChange, + 'emit' + ) + + await component.ngOnChanges({ + context: { + currentValue: mockContext, + previousValue: {}, + firstChange: false, + isFirstChange: () => false, + }, + }) + + expect(createLegendFromLayer).toHaveBeenCalledWith(mockContext.layers[0]) + expect(component.legendHTML).toBe(mockLegendElement) + expect(legendStatusChangeSpy).toHaveBeenCalledWith(true) + }) + }) +}) diff --git a/libs/ui/map/src/lib/components/map-legend/map-legend.component.ts b/libs/ui/map/src/lib/components/map-legend/map-legend.component.ts new file mode 100644 index 000000000..7f635f10e --- /dev/null +++ b/libs/ui/map/src/lib/components/map-legend/map-legend.component.ts @@ -0,0 +1,39 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + ViewEncapsulation, +} from '@angular/core' +import { MapContext } from '@geospatial-sdk/core' +import { createLegendFromLayer } from '@geospatial-sdk/legend' +import { NgIf } from '@angular/common' + +@Component({ + selector: 'gn-ui-map-legend', + templateUrl: './map-legend.component.html', + standalone: true, + styleUrls: ['./map-legend.component.css'], + encapsulation: ViewEncapsulation.None, + imports: [NgIf], +}) +export class MapLegendComponent implements OnChanges { + @Input() context: MapContext | null + @Output() legendStatusChange = new EventEmitter() + legendHTML: HTMLElement | false + + async ngOnChanges(changes: SimpleChanges) { + if ('context' in changes) { + const mapContext = changes['context'].currentValue + if (mapContext.layers && mapContext.layers.length > 0) { + const mapContextLayer = mapContext.layers[0] + this.legendHTML = await createLegendFromLayer(mapContextLayer) + if (this.legendHTML) { + this.legendStatusChange.emit(true) + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 11cb74711..3bfd48281 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,9 @@ "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", "@camptocamp/ogc-client": "1.1.1-dev.3e2d3cc", - "@geospatial-sdk/core": "0.0.5-dev.29", - "@geospatial-sdk/geocoding": "0.0.5-dev.29", + "@geospatial-sdk/core": "0.0.5-dev.31", + "@geospatial-sdk/geocoding": "0.0.5-dev.31", + "@geospatial-sdk/legend": "^0.0.5-dev.31", "@geospatial-sdk/openlayers": "0.0.5-dev.29", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1", @@ -4498,17 +4499,22 @@ } }, "node_modules/@geospatial-sdk/core": { - "version": "0.0.5-dev.29", - "resolved": "https://registry.npmjs.org/@geospatial-sdk/core/-/core-0.0.5-dev.29.tgz", - "integrity": "sha512-urGQ0glk8fTqK4SkdKpuAGGh4TlFbjm+uW7FmyZ47HEvAOTcE0kvZPOsg/LtNY2VovyXVrnyavdOlUt9Rvqgcg==", + "version": "0.0.5-dev.31", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/core/-/core-0.0.5-dev.31.tgz", + "integrity": "sha512-A3U7GuGgyhFnSneqpqXh80lwNJwQT8lLYBdlvKZnLo9usldI0BSSRFQo+iKgkJ1NxWMTdfpcIbefAVpDdILuqw==", "dependencies": { "proj4": "^2.9.2" } }, "node_modules/@geospatial-sdk/geocoding": { - "version": "0.0.5-dev.29", - "resolved": "https://registry.npmjs.org/@geospatial-sdk/geocoding/-/geocoding-0.0.5-dev.29.tgz", - "integrity": "sha512-WcxV6Ys6xN7AFLm4UOXkFspj/osCZ2f/OP3LiC63W/0MdwiAg3ajRldVX5pfDyPx2GTKv0ZYcxuFzi8P7ZLFaw==" + "version": "0.0.5-dev.31", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/geocoding/-/geocoding-0.0.5-dev.31.tgz", + "integrity": "sha512-z6Hkb+fktKmyymDt7yS7xpCptjehZJR+BHzn0mNuawuAP2vW4+jRBj78+/jkdEvi4jKBLM+PXs3Tg1WoP2isbQ==" + }, + "node_modules/@geospatial-sdk/legend": { + "version": "0.0.5-dev.31", + "resolved": "https://registry.npmjs.org/@geospatial-sdk/legend/-/legend-0.0.5-dev.31.tgz", + "integrity": "sha512-pxy1bm6KSqkIH1KVSrBBz9fxxhzmv246bKQQDFXfkPmVaaRnNlpytq3QMQoTUb8XiOFP2p7gWR909wfmHfsNDQ==" }, "node_modules/@geospatial-sdk/openlayers": { "version": "0.0.5-dev.29", diff --git a/package.json b/package.json index ac758e4e4..e3ea18c7d 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,9 @@ "@bartholomej/ngx-translate-extract": "^8.0.2", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", "@camptocamp/ogc-client": "1.1.1-dev.3e2d3cc", - "@geospatial-sdk/core": "0.0.5-dev.29", - "@geospatial-sdk/geocoding": "0.0.5-dev.29", + "@geospatial-sdk/core": "0.0.5-dev.31", + "@geospatial-sdk/geocoding": "0.0.5-dev.31", + "@geospatial-sdk/legend": "^0.0.5-dev.31", "@geospatial-sdk/openlayers": "0.0.5-dev.29", "@ltd/j-toml": "~1.35.2", "@messageformat/core": "^3.0.1",