diff --git a/angular.json b/angular.json index 02fd69b1e1..4b95be5512 100644 --- a/angular.json +++ b/angular.json @@ -58,7 +58,10 @@ "input": "src/themes/dspace/styles/theme.scss", "inject": false, "bundleName": "dspace-theme" - } + }, + "node_modules/leaflet/dist/leaflet.css", + "node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css", + "node_modules/leaflet.markercluster/dist/MarkerCluster.css" ], "scripts": [], "baseHref": "/" diff --git a/config/config.example.yml b/config/config.example.yml index 4554369da3..d5e7dfe501 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -546,7 +546,6 @@ notifyMetrics: config: 'NOTIFY.outgoing.delivered' description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description' - # Live Region configuration # Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms: # Live regions are perceivable regions of a web page that are typically updated as a @@ -560,3 +559,37 @@ liveRegion: messageTimeOutDurationMs: 30000 # The visibility of the live region. Setting this to true is only useful for debugging purposes. isVisible: false + +# Geospatial Map display options +geospatialMapViewer: + # Which fields to use for parsing as geospatial points in search maps + # (note, the item page field component allows any field(s) to be used + # and is set as an input when declaring the component) + spatialMetadataFields: + - 'dcterms.spatial' + # Which discovery configuration to use for 'geospatial search', used + # in the browse map + spatialFacetDiscoveryConfiguration: 'geospatial' + # Which filter / facet name to use for faceted geospatial search + # used in the browse map + spatialPointFilterName: 'point' + # Whether item page geospatial metadata should be displayed + # (assumes they are wrapped in a test for this config in the template as + # per the default templates supplied with DSpace for untyped-item and publication) + enableItemPageFields: false + # Whether the browse map should be enabled and included in the browse menu + enableBrowseMap: false + # Whether a 'map view' mode should be included alongside list and grid views + # in search result pages + enableSearchViewMode: false + # The tile provider(s) to use for the map tiles drawn in the leaflet maps. + # (see https://leaflet-extras.github.io/leaflet-providers/preview/) for a full list + tileProviders: + - 'OpenStreetMap.Mapnik' + # Starting centre point for the map, as lat and lng coordinates. This is useful + # to set the centre of the map when the map is first loaded and if there are no + # points, shapes or markers to display. + # Defaults to the centre of Istanbul + defaultCentrePoint: + lat: 41.015137 + lng: 28.979530 diff --git a/package-lock.json b/package-lock.json index 99415eeb75..28f6cee2a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@ngrx/store": "^18.1.1", "@ngx-translate/core": "^16.0.3", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "@terraformer/wkt": "^2.2.1", "altcha": "^0.9.0", "angulartics2": "^12.2.0", "axios": "^1.7.9", @@ -58,6 +59,9 @@ "json5": "^2.2.3", "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", + "leaflet": "^1.9.4", + "leaflet-providers": "^2.0.0", + "leaflet.markercluster": "^1.5.3", "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", @@ -7583,6 +7587,11 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true }, + "node_modules/@terraformer/wkt": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@terraformer/wkt/-/wkt-2.2.1.tgz", + "integrity": "sha512-XDUsW/lvbMzFi7GIuRD9+UqR4QyP+5C+TugeJLMDczKIRbaHoE9J3N8zLSdyOGmnJL9B6xTS3YMMlBnMU0Ar5A==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -16141,6 +16150,24 @@ "node": "> 0.8" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, + "node_modules/leaflet-providers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/leaflet-providers/-/leaflet-providers-2.0.0.tgz", + "integrity": "sha512-CWwKEnHd66Qsx0m4o5q5ZOa60s00B91pMxnlr4Y22msubfs7dhbZhdMIz8bvZQkrZqi67ppI1fsZRS6vtrLcOA==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/less": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", diff --git a/package.json b/package.json index 7d20579887..244e01f5d7 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@ngrx/store": "^18.1.1", "@ngx-translate/core": "^16.0.3", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "@terraformer/wkt": "^2.2.1", "altcha": "^0.9.0", "angulartics2": "^12.2.0", "axios": "^1.7.9", @@ -140,6 +141,9 @@ "json5": "^2.2.3", "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", + "leaflet": "^1.9.4", + "leaflet-providers": "^2.0.0", + "leaflet.markercluster": "^1.5.3", "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", diff --git a/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.html b/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.html new file mode 100644 index 0000000000..4d563f30b8 --- /dev/null +++ b/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.html @@ -0,0 +1,11 @@ +
+

{{ 'browse.metadata.map' | translate }}

+ @if (isPlatformBrowser(platformId)) { + + + } +
+ diff --git a/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.scss b/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.spec.ts b/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.spec.ts new file mode 100644 index 0000000000..25ce9742d5 --- /dev/null +++ b/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.spec.ts @@ -0,0 +1,147 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { SearchService } from '../../core/shared/search/search.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { FacetValue } from '../../shared/search/models/facet-value.model'; +import { FilterType } from '../../shared/search/models/filter-type.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { SearchFilterConfig } from '../../shared/search/models/search-filter-config.model'; +import { SearchServiceStub } from '../../shared/testing/search-service.stub'; +import { BrowseByGeospatialDataComponent } from './browse-by-geospatial-data.component'; + +// create route stub +const scope = 'test scope'; +const activatedRouteStub = { + queryParams: observableOf({ + scope: scope, + }), +}; + +// Mock search filter config +const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: 'point', + type: FilterType.text, + hasFacets: true, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, +}); + +// Mock facet values with and without point data +const facetValue: FacetValue = { + label: 'test', + value: 'test', + count: 20, + _links: { + self: { href: 'selectedValue-self-link2' }, + search: { href: `` }, + }, +}; +const pointFacetValue: FacetValue = { + label: 'test point', + value: 'Point ( +174.000000 -042.000000 )', + count: 20, + _links: { + self: { href: 'selectedValue-self-link' }, + search: { href: `` }, + }, +}; +const mockValues = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [facetValue])); +const mockPointValues = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [pointFacetValue])); + +// Expected search options used in getFacetValuesFor call +const expectedSearchOptions: PaginatedSearchOptions = Object.assign({ + 'configuration': environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration, + 'scope': scope, + 'facetLimit': 99999, +}); + +// Mock search config service returns mock search filter config on getConfig() +const mockSearchConfigService = jasmine.createSpyObj('searchConfigurationService', { + getConfig: createSuccessfulRemoteDataObject$([mockFilterConfig]), +}); +let searchService: SearchServiceStub = new SearchServiceStub(); + +// initialize testing environment +describe('BrowseByGeospatialDataComponent', () => { + let component: BrowseByGeospatialDataComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot(), StoreModule.forRoot(), BrowseByGeospatialDataComponent], + providers: [ + { provide: SearchService, useValue: searchService }, + { provide: SearchConfigurationService, useValue: mockSearchConfigService }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByGeospatialDataComponent); + component = fixture.componentInstance; + }); + + it('component should be created successfully', () => { + expect(component).toBeTruthy(); + }); + describe('BrowseByGeospatialDataComponent component with valid facet values', () => { + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByGeospatialDataComponent); + component = fixture.componentInstance; + spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockPointValues); + component.scope$ = observableOf(''); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should call searchConfigService.getConfig() after init', waitForAsync(() => { + expect(mockSearchConfigService.getConfig).toHaveBeenCalled(); + })); + + it('should call searchService.getFacetValuesFor() with expected parameters', waitForAsync(() => { + component.getFacetValues().subscribe(() => { + expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, 1, expectedSearchOptions, null, true); + }); + })); + }); + + describe('BrowseByGeospatialDataComponent component with invalid facet values (no point data)', () => { + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByGeospatialDataComponent); + component = fixture.componentInstance; + spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues); + component.scope$ = observableOf(''); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should call searchConfigService.getConfig() after init', waitForAsync(() => { + expect(mockSearchConfigService.getConfig).toHaveBeenCalled(); + })); + + it('should call searchService.getFacetValuesFor() with expected parameters', waitForAsync(() => { + component.getFacetValues().subscribe(() => { + expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, 1, expectedSearchOptions, null, true); + }); + })); + }); + +}); diff --git a/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.ts b/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.ts new file mode 100644 index 0000000000..27d6ce14e2 --- /dev/null +++ b/src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.ts @@ -0,0 +1,110 @@ +import { + AsyncPipe, + isPlatformBrowser, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnInit, + PLATFORM_ID, +} from '@angular/core'; +import { + ActivatedRoute, + Params, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { + combineLatest, + Observable, + of, +} from 'rxjs'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../../core/shared/operators'; +import { SearchService } from '../../core/shared/search/search.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { hasValue } from '../../shared/empty.util'; +import { GeospatialMapComponent } from '../../shared/geospatial-map/geospatial-map.component'; +import { FacetValues } from '../../shared/search/models/facet-values.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; + +@Component({ + selector: 'ds-browse-by-geospatial-data', + templateUrl: './browse-by-geospatial-data.component.html', + styleUrls: ['./browse-by-geospatial-data.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [GeospatialMapComponent, NgIf, AsyncPipe, TranslateModule], + standalone: true, +}) +/** + * Component displaying a large 'browse map', which is really a geolocation few of the 'point' facet defined + * in the geospatial discovery configuration. + * The markers are clustered by location, and each individual marker will link to a search page for that point value + * as a filter. + * + * @author Kim Shepherd + */ +export class BrowseByGeospatialDataComponent implements OnInit { + + protected readonly isPlatformBrowser = isPlatformBrowser; + + public facetValues$: Observable = of(null); + + constructor( + @Inject(PLATFORM_ID) public platformId: string, + private searchConfigurationService: SearchConfigurationService, + private searchService: SearchService, + protected route: ActivatedRoute, + ) {} + + public scope$: Observable ; + + ngOnInit(): void { + this.scope$ = this.route.queryParams.pipe( + map((params: Params) => params.scope), + ); + this.facetValues$ = this.getFacetValues(); + } + + /** + * Get facet values for use in rendering 'browse by' geospatial map + */ + getFacetValues(): Observable { + return combineLatest([this.scope$, this.searchConfigurationService.getConfig( + // If the geospatial configuration is not found, default will be returned and used + '', environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration).pipe( + getFirstCompletedRemoteData(), + getFirstSucceededRemoteDataPayload(), + filter((searchFilterConfigs) => hasValue(searchFilterConfigs)), + take(1), + map((searchFilterConfigs) => searchFilterConfigs[0]), + filter((searchFilterConfig) => hasValue(searchFilterConfig))), + ], + ).pipe( + switchMap(([scope, searchFilterConfig]) => { + // Get all points in one page, if possible + searchFilterConfig.pageSize = 99999; + const searchOptions: PaginatedSearchOptions = Object.assign({ + 'configuration': environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration, + 'scope': scope, + 'facetLimit': 99999, + }); + return this.searchService.getFacetValuesFor(searchFilterConfig, 1, searchOptions, + null, true); + }), + getFirstCompletedRemoteData(), + getFirstSucceededRemoteDataPayload(), + ); + } +} diff --git a/src/app/browse-by/browse-by-page-routes.ts b/src/app/browse-by/browse-by-page-routes.ts index 3843a50f6e..7c625afc1f 100644 --- a/src/app/browse-by/browse-by-page-routes.ts +++ b/src/app/browse-by/browse-by-page-routes.ts @@ -1,6 +1,8 @@ import { Route } from '@angular/router'; +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { browseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver'; +import { BrowseByGeospatialDataComponent } from './browse-by-geospatial-data/browse-by-geospatial-data.component'; import { browseByGuard } from './browse-by-guard'; import { browseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver'; import { BrowseByPageComponent } from './browse-by-page/browse-by-page.component'; @@ -12,6 +14,12 @@ export const ROUTES: Route[] = [ breadcrumb: browseByDSOBreadcrumbResolver, }, children: [ + { + path: 'map', + component: BrowseByGeospatialDataComponent, + resolve: { breadcrumb: i18nBreadcrumbResolver }, + data: { title: 'browse.map.page', breadcrumbKey: 'browse.metadata.map' }, + }, { path: ':id', component: BrowseByPageComponent, diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts index 9c2f499b3d..a1f56a11f3 100644 --- a/src/app/core/shared/view-mode.model.ts +++ b/src/app/core/shared/view-mode.model.ts @@ -8,4 +8,5 @@ export enum ViewMode { DetailedListElement = 'detailed', StandalonePage = 'standalone', Table = 'table', + GeospatialMap = 'geospatial-map' } diff --git a/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.html b/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.html new file mode 100644 index 0000000000..8382fdcb36 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.html @@ -0,0 +1,12 @@ +@if (isNotEmpty(points) || isNotEmpty(bboxes)) { +
+ + + + +
+} diff --git a/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.spec.ts new file mode 100644 index 0000000000..c3412d777c --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.spec.ts @@ -0,0 +1,75 @@ +import { + ChangeDetectionStrategy, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { MockStore } from '@ngrx/store/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { + APP_CONFIG, + APP_DATA_SERVICES_MAP, +} from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; +import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; +import { ITEM } from '../../../../../core/shared/item.resource-type'; +import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; +import { GeospatialItemPageFieldComponent } from './geospatial-item-page-field.component'; + +let comp: GeospatialItemPageFieldComponent; +let fixture: ComponentFixture; + +const mockValue = 'Point ( +174.000000 -042.000000 )'; +const mockField = 'dcterms.spatial'; +const mockLabel = 'Test location'; +const mockFields = [mockField]; + +const mockDataServiceMap: any = new Map([ + [ITEM.value, () => import('../../../../../shared/testing/test-data-service.mock').then(m => m.TestDataService)], +]); +describe('GeospatialItemPageFieldComponent', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [GeospatialItemPageFieldComponent, MetadataValuesComponent, TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + })], + providers: [ + { provide: APP_CONFIG, useValue: environment }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: Store, useValue: MockStore }, + { provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(GeospatialItemPageFieldComponent, { + set: { changeDetection: ChangeDetectionStrategy.OnPush }, + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(GeospatialItemPageFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue); + comp.fields = mockFields; + comp.label = mockLabel; + + fixture.detectChanges(); + })); + + it('should initialize a map from passed points', () => { + expect(fixture.nativeElement.querySelector('ds-geospatial-map[ng-reflect-coordinates="Point ( +174.000000 -042.00000"]')).toBeTruthy(); + }); +}); diff --git a/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.ts new file mode 100644 index 0000000000..28da9de276 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component.ts @@ -0,0 +1,86 @@ +import { NgIf } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { Item } from '../../../../../core/shared/item.model'; +import { + hasValue, + isNotEmpty, +} from '../../../../../shared/empty.util'; +import { GeospatialMapComponent } from '../../../../../shared/geospatial-map/geospatial-map.component'; +import { MetadataFieldWrapperComponent } from '../../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; + +@Component({ + selector: 'ds-geospatial-item-page-field', + templateUrl: './geospatial-item-page-field.component.html', + imports: [ + MetadataFieldWrapperComponent, + GeospatialMapComponent, + TranslateModule, + NgIf, + ], + standalone: true, +}) +/** + * This component can be used to represent metadata on a simple item page. + * It is the most generic way of displaying metadata values + * It expects 4 parameters: The item, a separator, the metadata keys and an i18n key + */ +export class GeospatialItemPageFieldComponent extends ItemPageFieldComponent implements OnInit { + + /** + * The item to display metadata for + */ + @Input() item: Item; + + /** + * Label i18n key for the rendered metadata + */ + @Input() label: string; + + /** + * List of fields to parse for WKT points + */ + @Input() pointFields = ['dcterms.spatial']; + + /** + * List of fields to parse for bounding box GeoJSON + */ + @Input() bboxFields = ['dcterms.spatial']; + + /** + * Whether to cluster markers into groups + */ + @Input() cluster = false; + + bboxes: string[]; + points: string[]; + + protected readonly hasValue = hasValue; + protected readonly isNotEmpty = isNotEmpty; + + /** + * On init, fetch point and bounding box metadata values for the given fields + */ + ngOnInit() { + if (hasValue(this.item)) { + // Read all point values from all fields passed and flatten into a simple array of strings + this.points = this.pointFields + .map(f => this.item?.allMetadataValues(f)) + .reduce((acc, val) => acc.concat(val), []) + .filter(Boolean); + // Read all bounding box values from all fields passed and flatten into a simple array of strings + this.bboxes = this.bboxFields + .map(f => this.item?.allMetadataValues(f)) + .reduce((acc, val) => acc.concat(val), []) + .filter(Boolean); + } + } + + +} diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index b70aa7e0b9..31bb741a9f 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -106,6 +106,15 @@ [fields]="['datacite.relation.isReferencedBy']" [label]="'item.page.referenced'"> + @if (geospatialItemPageFieldsEnabled) { + + + }
{{"item.page.link.full" | translate}} diff --git a/src/app/item-page/simple/item-types/publication/publication.component.ts b/src/app/item-page/simple/item-types/publication/publication.component.ts index 276d9d3534..3a9a1443c4 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.ts @@ -19,6 +19,7 @@ import { ThemedFileSectionComponent } from '../../field-components/file-section/ import { ItemPageAbstractFieldComponent } from '../../field-components/specific-field/abstract/item-page-abstract-field.component'; import { ItemPageDateFieldComponent } from '../../field-components/specific-field/date/item-page-date-field.component'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { GeospatialItemPageFieldComponent } from '../../field-components/specific-field/geospatial/geospatial-item-page-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../field-components/specific-field/title/themed-item-page-field.component'; import { ItemPageUriFieldComponent } from '../../field-components/specific-field/uri/item-page-uri-field.component'; import { ThemedMetadataRepresentationListComponent } from '../../metadata-representation-list/themed-metadata-representation-list.component'; @@ -36,7 +37,7 @@ import { ItemComponent } from '../shared/item.component'; templateUrl: './publication.component.html', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ThemedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, RouterLink, AsyncPipe, TranslateModule], + imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ThemedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, RouterLink, AsyncPipe, TranslateModule, GeospatialItemPageFieldComponent], }) export class PublicationComponent extends ItemComponent { diff --git a/src/app/item-page/simple/item-types/shared/item.component.ts b/src/app/item-page/simple/item-types/shared/item.component.ts index b93b7215c5..9812f83612 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.ts @@ -75,9 +75,15 @@ export class ItemComponent implements OnInit { mediaViewer; + /** + * Enables display of geospatial item page fields + */ + geospatialItemPageFieldsEnabled = false; + constructor(protected routeService: RouteService, protected router: Router) { this.mediaViewer = environment.mediaViewer; + this.geospatialItemPageFieldsEnabled = environment.geospatialMapViewer.enableItemPageFields; } /** diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index c74be549a8..d941bb6327 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -71,6 +71,15 @@ [fields]="['dc.identifier.citation']" [label]="'item.page.citation'"> + @if (geospatialItemPageFieldsEnabled) { + + + } diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 1402831c33..815a86525f 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -21,6 +21,7 @@ import { ItemPageAbstractFieldComponent } from '../../field-components/specific- import { ItemPageCcLicenseFieldComponent } from '../../field-components/specific-field/cc-license/item-page-cc-license-field.component'; import { ItemPageDateFieldComponent } from '../../field-components/specific-field/date/item-page-date-field.component'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { GeospatialItemPageFieldComponent } from '../../field-components/specific-field/geospatial/geospatial-item-page-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../field-components/specific-field/title/themed-item-page-field.component'; import { ItemPageUriFieldComponent } from '../../field-components/specific-field/uri/item-page-uri-field.component'; import { ThemedMetadataRepresentationListComponent } from '../../metadata-representation-list/themed-metadata-representation-list.component'; @@ -56,6 +57,7 @@ import { ItemComponent } from '../shared/item.component'; AsyncPipe, TranslateModule, ItemPageCcLicenseFieldComponent, + GeospatialItemPageFieldComponent, ], }) export class UntypedItemComponent extends ItemComponent {} diff --git a/src/app/menu-resolver.service.ts b/src/app/menu-resolver.service.ts index 31210883ae..bc00afa312 100644 --- a/src/app/menu-resolver.service.ts +++ b/src/app/menu-resolver.service.ts @@ -18,6 +18,7 @@ import { take, } from 'rxjs/operators'; +import { environment } from '../environments/environment'; import { PUBLICATION_CLAIMS_PATH } from './admin/admin-notifications/admin-notifications-routing-paths'; import { AuthService } from './core/auth/auth.service'; import { BrowseService } from './core/browse/browse.service'; @@ -110,6 +111,7 @@ export class MenuResolverService { link: `/community-list`, } as LinkMenuItemModel, }, + ]; // Read the different Browse-By types from config and add them to the browse menu this.browseService.getBrowseDefinitions() @@ -143,6 +145,25 @@ export class MenuResolverService { }, ); } + /* Add "Browse by Geolocation" map if enabled in configuration, with index = length to put it at the end of the list */ + if (environment.geospatialMapViewer.enableBrowseMap) { + menuList.push( + { + id: `browse_global_geospatial_map`, + parentID: 'browse_global', + active: false, + visible: true, + index: menuList.length, + model: { + type: MenuItemType.LINK, + text: `menu.section.browse_global_geospatial_map`, + link: `/browse/map`, + disabled: false, + } as LinkMenuItemModel, + }, + ); + } + menuList.forEach((menuSection) => this.menuService.addSection(MenuID.PUBLIC, Object.assign(menuSection, { shouldPersistOnRouteChange: true, }))); diff --git a/src/app/shared/geospatial-map/geospatial-map.component.html b/src/app/shared/geospatial-map/geospatial-map.component.html new file mode 100644 index 0000000000..0f1bd73323 --- /dev/null +++ b/src/app/shared/geospatial-map/geospatial-map.component.html @@ -0,0 +1,6 @@ +
+
+
+
+
+ diff --git a/src/app/shared/geospatial-map/geospatial-map.component.scss b/src/app/shared/geospatial-map/geospatial-map.component.scss new file mode 100644 index 0000000000..4e0f35b41c --- /dev/null +++ b/src/app/shared/geospatial-map/geospatial-map.component.scss @@ -0,0 +1,57 @@ +.map-container { + //position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: 0; + height: 400px; + width: 400px; + + @media screen and (min-width: map-get($grid-breakpoints, sm)){ + width: 450px; + } + + + @media screen and (min-width: map-get($grid-breakpoints, md)){ + width: 500px; + } + + + @media screen and (min-width: map-get($grid-breakpoints, lg)) { + width: 600px; + } + + @media screen and (min-width: map-get($grid-breakpoints, xl)) { + width: 700px; + } + + +} + +.map-container img { + max-height: none; +} + +.map-frame { + border: 2px solid black; + height: 100%; +} + +/* browse */ +.browse-map-container { + height: 800px; + width: 100%; + max-height: none; +} + +.browse-map-frame { + border: 2px solid black; + height: 100%; + width: 100%; +} + +.map { + height: 100%; + width: auto; +} diff --git a/src/app/shared/geospatial-map/geospatial-map.component.spec.ts b/src/app/shared/geospatial-map/geospatial-map.component.spec.ts new file mode 100644 index 0000000000..447d8204e8 --- /dev/null +++ b/src/app/shared/geospatial-map/geospatial-map.component.spec.ts @@ -0,0 +1,215 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { getMockTranslateService } from '../mocks/translate.service.mock'; +import { GeospatialMapComponent } from './geospatial-map.component'; + +let elRef: ElementRef; + +describe('GeospatialMapComponent', () => { + let component: GeospatialMapComponent; + let fixture: ComponentFixture; + + beforeEach( waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [GeospatialMapComponent, TranslateModule.forRoot()], + providers: [{ provide: TranslateService, useValue: getMockTranslateService() }], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GeospatialMapComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('component should be created', () => { + expect(component).toBeTruthy(); + }); + + it('component should call ngOnInit and ngAfterViewInit', () => { + const spy = spyOn(component, 'ngOnInit').and.callThrough(); + const spy2 = spyOn(component, 'ngAfterViewInit').and.callThrough(); + component.ngOnInit(); + component.ngAfterViewInit(); + expect(spy).toHaveBeenCalled(); + expect(spy2).toHaveBeenCalled(); + }); + + it('component should create leaflet map on ngAfterViewInit', () => { + component.ngAfterViewInit(); + expect(component.leafletMap).toBeTruthy(); + }); + + describe('GeospatialMapComponent for metadata values', () => { + // Mock data + const bboxData = ['{east=169.975931486457, south=-46.125330124375715, north=-46.11633647562429, west=169.96295731354297, accuracyLevel=0}']; + const pointData = ['Point ( +174.000000 -042.000000 )']; + beforeEach(() => { + fixture = TestBed.createComponent(GeospatialMapComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + // Assign mock data + component.coordinates = pointData; + component.bbox = bboxData; + elRef = { + nativeElement: jasmine.createSpyObj('nativeElement', { + querySelector: {}, + }), + }; + }); + + it('metadata value map should parse coordinates on initialization', () => { + component.ngOnInit(); + // Original input was ['Point ( +174.000000 -042.000000 )']; - this should be parsed properly + const testGeoJSONPoint = Object({ type: 'Point', coordinates: [ 174, -42 ] }); + expect(component.parsedCoordinates).toEqual([testGeoJSONPoint]); + component.ngAfterViewInit(); + + }); + + it('metadata value map should have the expected map container element', () => { + const el = elRef.nativeElement.querySelector('div.geospatial-map'); + expect(el).toBeTruthy(); + }); + + it('metadata value map should parse bounding boxes on initialization', () => { + component.ngOnInit(); + expect(component.parsedBoundingBoxes).toEqual(bboxData); + }); + + it('metadata value map should have 4 layers rendered', () => { + component.ngOnInit(); + component.ngAfterViewInit(); + let layers = []; + let layerCount = 0; + component.leafletMap.eachLayer(function(layer) { + expect(layer).toBeTruthy(); + layerCount++; + layers.push(layer); + }); + // Tile layer, initial centre layer, single marker layer, bounding box layer + expect(layerCount).toEqual(4); + }); + + }); + + describe('GeospatialMapComponent for search results', () => { + beforeEach(() => { + fixture = TestBed.createComponent(GeospatialMapComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + // Assign mock data + component.mapInfo = Object.assign([ + { + route: 'test route', + title: 'test title', + points: [ + { latitude: 32, longitude: -2, url: 'test url 1', title: 'test title 1' }, + { latitude: -52, longitude: 12, url: 'test url 2', title: 'test title 2' }, + ], + }, + ]); + component.cluster = true; + + elRef = { + nativeElement: jasmine.createSpyObj('nativeElement', { + querySelector: {}, + }), + }; + }); + + it('search results map should have the expected map container element', () => { + const el = elRef.nativeElement.querySelector('div.geospatial-map'); + expect(el).toBeTruthy(); + }); + + it('search results map should have 6 layers rendered', () => { + component.ngOnInit(); + component.ngAfterViewInit(); + let layers = []; + let layerCount = 0; + component.leafletMap.eachLayer(function(layer) { + expect(layer).toBeTruthy(); + layerCount++; + layers.push(layer); + }); + // Tile layer, initial centre layer, marker group, feature group, marker 1, marker 2 + expect(layerCount).toEqual(6); + }); + + }); + + describe('GeospatialMapComponent for browse facets', () => { + beforeEach(() => { + fixture = TestBed.createComponent(GeospatialMapComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + // Assign mock data + const mockFacetValues = Object.assign({ + page: [ + { + label: 'label 1', + value: 'Point ( +174.000000 -042.000000 )', + count: 10, + }, + { + label: 'label 2', + value: 'Point ( +104.000000 -012.000000 )', + count: 3, + }, + ], + }); + component.facetValues = of(mockFacetValues); + component.cluster = true; + + elRef = { + nativeElement: jasmine.createSpyObj('nativeElement', { + querySelector: {}, + }), + }; + + // Init map + component.ngOnInit(); + component.ngAfterViewInit(); + + }); + + it('browse facets map should have the expected map container element', () => { + const el = elRef.nativeElement.querySelector('div.geospatial-map'); + expect(el).toBeTruthy(); + }); + + it('browse facets map should have 6 layers rendered', () => { + let layers = []; + let layerCount = 0; + component.leafletMap.eachLayer(function(layer) { + expect(layer).toBeTruthy(); + layerCount++; + layers.push(layer); + }); + // Tile layer, initial centre layer, marker group, feature group, marker 1, marker 2 + // Facets are handled async so we have to wait for them to be fully drawn before testing layer count + waitForAsync(() => { + expect(layerCount).toEqual(6); + }); + }); + + }); + + +}); diff --git a/src/app/shared/geospatial-map/geospatial-map.component.ts b/src/app/shared/geospatial-map/geospatial-map.component.ts new file mode 100644 index 0000000000..8f9cc76355 --- /dev/null +++ b/src/app/shared/geospatial-map/geospatial-map.component.ts @@ -0,0 +1,408 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + AfterViewInit, + Component, + ElementRef, + Inject, + Input, + OnDestroy, + OnInit, + PLATFORM_ID, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { wktToGeoJSON } from '@terraformer/wkt'; +import { + Observable, + Subscription, +} from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { + hasValue, + isEmpty, + isNotEmpty, +} from '../empty.util'; +import { FacetValue } from '../search/models/facet-value.model'; +import { FacetValues } from '../search/models/facet-values.model'; +import { GeospatialMapDetail } from './models/geospatial-map-detail.model'; + +@Component({ + selector: 'ds-geospatial-map', + templateUrl: './geospatial-map.component.html', + styleUrls: ['./geospatial-map.component.scss'], + standalone: true, +}) +/** + * Component to draw points and polygons on a tiled map using leaflet.js + * This component can be used by item page fields, the browse-by geospatial component, and the geospatial search + * view mode to render related places of an item (e.g. metadata on a page), or items *as* places (e.g. browse / search) + */ +export class GeospatialMapComponent implements AfterViewInit, OnInit, OnDestroy { + + /** + * Leaflet map object + * @private + */ + private map; + + /** + * Lat / lng coordinate data to render on the map as markers + */ + @Input() coordinates?: string[]; + + /** + * Bounding boxes to render on the map as rectangles + */ + @Input() bbox?: string[]; + + /** + * Whether to cluster markers in groups + */ + @Input() cluster = false; + + /** + * Parsed, flattened, filtered list of coordinates + */ + parsedCoordinates: any[] = []; + + /** + * Parsed, flattened, filtered list of bounding boxes + */ + parsedBoundingBoxes: any[] = []; + + /** + * Facet values and current scope used by browse-by components + */ + @Input() facetValues?: Observable; + + /** + * Current search scope, if any (for marker click links) + */ + @Input() currentScope?: string; + + /** + * Map info constructed from search results, and points + */ + @Input() mapInfo?: GeospatialMapDetail[]; + + /** + * Layout info - "item", "browse", or "search" + * @private + */ + @Input() layout = 'item'; + + DEFAULT_CENTRE_POINT = [environment.geospatialMapViewer.defaultCentrePoint.lat, environment.geospatialMapViewer.defaultCentrePoint.lng]; + + private subs: Subscription[] = []; + + constructor(private elRef: ElementRef, + @Inject(PLATFORM_ID) private platformId: string, + private router: Router, + private translateService: TranslateService) { + } + + ngOnInit() { + // Filter out missing or undefined / null values from inputs + if (hasValue(this.coordinates)) { + this.parsedCoordinates = this.coordinates.map(c => this.parseAndValidatePoint(c)).filter(Boolean); + } + if (hasValue(this.bbox)) { + this.parsedBoundingBoxes = this.bbox.map(b => b).filter(Boolean); + } + } + + ngAfterViewInit(): void { + // Only initialize the map in browser mode + if (isPlatformBrowser(this.platformId)) { + if (hasValue(this.map)) { + this.map.remove(); + } + this.initMap(); + } + } + + ngOnDestroy() { + this.subs.forEach((sub: Subscription) => sub.unsubscribe()); + } + + /** + * Initialize map component, tile providers, and draw markers depending on context + * + * @private + */ + private initMap(): void { + // 'Import' leaflet packages in a browser-mode-only way to avoid issues with SSR + const L = require('leaflet'); require('leaflet.markercluster'); require('leaflet-providers'); + // Set better default icons + L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'assets/images/marker-icon-2x.png', + iconUrl: 'assets/images/marker-icon-2x.png', + shadowUrl: 'assets/images/marker-shadow.png', + }); + // Define map object + this.map = L.map; + + // Get map by query selector - this is important NOT to use an id like 'map' because we might draw + // many maps within a single page + const el = this.elRef.nativeElement.querySelector('div.geospatial-map'); + // Defaults are London - we update this after drawing markers to zoom and fit based on data + this.map = L.map(el, { + center: this.DEFAULT_CENTRE_POINT, + zoom: 11, + }); + const tileProviders = environment.geospatialMapViewer.tileProviders; + for (let i = 0; i < tileProviders.length; i++) { + // Add tiles to the map + const tiles = L.tileLayer.provider(tileProviders[i], { + maxZoom: 18, + minZoom: 3, + }); + tiles.addTo(this.map); + } + + // Call add markers function as appropriate (metadata values, facet results, search results) + if (hasValue(this.coordinates)) { + this.drawSimpleValueMarkers(L); + } else if (hasValue(this.facetValues)) { + // Subscribe to facet values and call draw when they fire + this.subs.push(this.facetValues.subscribe((f) => { + this.drawFacetValueMarkers(L, f.page); + })); + } else if (hasValue(this.mapInfo)) { + this.drawSearchResultMarkers(L); + } + + } + + /** + * Draw markers and bounding boxes given the parsed inputs + * + * @param L + * @private + */ + private drawSimpleValueMarkers(L) { + let bounds = this.map.getBounds(); + // Construct GeoJSON points, iterate and add markers to the map or cluster + const points = this.parsedCoordinates; + const markers = L.markerClusterGroup(); + points.forEach(point => { + const marker = L.marker([point.coordinates[1], point.coordinates[0]], { + icon: new L.Icon.Default(), + }); + if (this.cluster) { + markers.addLayer(marker); + } else { + this.map.addLayer(marker); + } + }); + if (this.cluster) { + this.map.addLayer(markers); + } + + // Set bounds based on farthest points + bounds = L.latLngBounds(points.map(p => [p.coordinates[1], p.coordinates[0]])); + + // Draw bounding boxes, if present + let bboxBounds; + if (isNotEmpty(this.parsedBoundingBoxes)) { + this.parsedBoundingBoxes.forEach(b => { + if (hasValue(b)) { + bboxBounds = this.renderBoundingBox(L, b); + } + }); + } + + // Map bounds / zoom fitting tends to be smoother when done after a short delay + setTimeout(() => { + if (bboxBounds && this.coordinates.length === 1) { + // One point, at least one bbox, use its bounds. Otherwise, use the calculation based on points. + bounds = bboxBounds; + } + this.map.invalidateSize(true); + this.map.fitBounds(bounds); + }, 500); + } + + /** + * Draw markers (parsed from facet values) to map using leaflet L and facet values f, with tooltips + * and click events + * + * @param L leaflet library + * @param f array of facet values + * @private + */ + private drawFacetValueMarkers(L, f: FacetValue[]) { + if (!hasValue(f)) { + return null; + } + const filter = 'f.' + environment.geospatialMapViewer.spatialPointFilterName; + const points = f.map((facetValue) => { + const point = this.parseAndValidatePoint(facetValue.value); + if (!hasValue(point)) { + return false; + } + // Set point display values based on facet + point.label = facetValue.label; + point.value = facetValue.value; + point.count = facetValue.count; + point.url = '/search'; + return point; + }).filter((point) => hasValue(point) && hasValue(point.coordinates) && point.coordinates.length === 2); + // If there are no points to draw, instead zoom out and show a tooltip and return early + if (isEmpty(points)) { + this.map.setZoom(1); + const marker = new L.marker(this.DEFAULT_CENTRE_POINT, { opacity: 0 }); + marker.bindTooltip('' + this.translateService.instant('search.results.geospatial-map.empty') + '', { permanent: true, offset: [0, 0], direction: 'top' }); + this.map.addLayer(marker); + return; + } + // We have >0 markers, so construct links and tooltips for each + const markers = L.markerClusterGroup(); + for (let i = 0; i < points.length; i++) { + // GeoJSON coordinates are [x, y] or [longitude, latitude] or [eastings, northings] + const point = points[i]; + const longitude = point.coordinates[0]; + const latitude = point.coordinates[1]; + // Basic tooltip here just shows label and count + const marker = L.marker([latitude, longitude], { + icon: new L.Icon.Default(), + }).bindTooltip(point.label + '
(' + point.count + ' ' + this.translateService.instant('browse.metadata.map.count.items') + + ')', { + permanent: false, + direction: 'top', + }).on('click', () => { + // On click, make a filtered search using the point filter ('f.point' by default) + this.router.navigate([point.url], + { queryParams: { 'spc.page': 1, [filter]: point.value + ',equals', 'scope': this.currentScope } }); + }); + markers.addLayer(marker); + } + + // Map bounds / zoom fitting tends to be smoother when done after a short delay + setTimeout(() => { + this.map.addLayer(markers); + const bounds = L.latLngBounds(points.map(point => [point.coordinates[1], point.coordinates[0]])); + this.map.invalidateSize(true); + this.map.fitBounds(bounds); + }, 500); + } + + /** + * Draw search result markers from the passed mapInfo input, with tooltips and click events + * + * @param L + */ + private drawSearchResultMarkers(L) { + // Now iterate points and build cluster + if (this.mapInfo && this.mapInfo.length > 0) { + const points = this.mapInfo.reduce((acc, mapDetail) => + acc.concat(mapDetail.points), []); + const markers = L.markerClusterGroup(); + points.forEach(point => { + const marker = L.marker([point.latitude, point.longitude], { + icon: new L.Icon.Default(), + }).bindTooltip(point.title, { + permanent: false, + direction: 'top', + }).on('click', () => { + this.router.navigate([point.url]); + }); + markers.addLayer(marker); + }); + this.map.addLayer(markers); + const bounds = L.latLngBounds(points.map(point => [point.latitude, point.longitude])); + this.map.fitBounds(bounds); + } else { + // If there are no points to draw, instead zoom out and show a tooltip + this.map.setZoom(1); + const marker = new L.marker(this.DEFAULT_CENTRE_POINT, { opacity: 0 }); + marker.bindTooltip('' + this.translateService.instant('search.results.geospatial-map.empty') + '', { permanent: true, offset: [0, 0], direction: 'top' }); + this.map.addLayer(marker); + } + } + + /** + * Render a bounding box and return its bounds / rectangle elements + * + * @param L + * @param bbox + * @private + */ + private renderBoundingBox(L, bbox: string): number[][] { + if (hasValue(bbox)) { + let parsedBbox = bbox.replace(/[{} ]/g, ''); + parsedBbox = parsedBbox.replace(/[^=,]+=/g, ''); + const parsedBboxParts = parsedBbox.split(',', 4); + // Validate that we have exactly 4 parts + if (parsedBboxParts.length !== 4) { + console.error('Invalid bounding box format: expected 4 coordinates but got ' + parsedBboxParts.length); + return; + } + // Convert to numbers and validate + const coordinates = parsedBboxParts.map(part => parseFloat(part)); + if (coordinates.some(isNaN)) { + console.error('Invalid bounding box: contains non-numeric values', parsedBboxParts); + return; + } + // Create bounds array with proper structure [[lat1, lng1], [lat2, lng2]] + const bounds = [[coordinates[1], coordinates[0]], [coordinates[2], coordinates[3]]]; + // Validate latitude values (-90 to 90) + if (bounds[0][0] < -90 || bounds[0][0] > 90 || bounds[1][0] < -90 || bounds[1][0] > 90) { + console.error('Invalid bounding box: latitude values must be between -90 and 90', bounds); + return; + } + // Validate longitude values (-180 to 180) + if (bounds[0][1] < -180 || bounds[0][1] > 180 || bounds[1][1] < -180 || bounds[1][1] > 180) { + console.error('Invalid bounding box: longitude values must be between -180 and 180', bounds); + return; + } + const boundingBox = L.rectangle(bounds, { color: '#5574BB', weight: 2, fill: false }); + this.map.addLayer(boundingBox); + // return bounds so the map can be centred / zoomed appropriately + return bounds; + } + } + + /** + * Handle parsing and some simple error validation for WKT point to GeoJSON + * + * @param coordinates + * @private + */ + private parseAndValidatePoint(coordinates): any { + // Parse WKT Point to GeoJSON + const point = this.parseGeoJsonFromMetadataValue(coordinates); + // Do some simple validation and log a console error if not valid + if (!hasValue(point) || point.type !== 'Point' + || !hasValue(point.coordinates) + || point.coordinates.length < 2) { + console.error('Could not parse point from WKT string: ' + coordinates); + return; + } + return point; + } + + /** + * Parse a GeoJSON point from a metadata value containing a WKT string + * @param value + * @private + */ + private parseGeoJsonFromMetadataValue(value): any { + if (hasValue(value)) { + const point = undefined; + value = value.replace(/\+/g, ''); + try { + return wktToGeoJSON(value.toUpperCase()); + } catch (e) { + console.warn(`Could not parse point from WKT string: ${value.points}, error: ${(e as Error).message}`); + } + return point; + } + } + + get leafletMap() { + return this.map; + } + +} diff --git a/src/app/shared/geospatial-map/models/geospatial-map-detail.model.ts b/src/app/shared/geospatial-map/models/geospatial-map-detail.model.ts new file mode 100644 index 0000000000..1555a5d615 --- /dev/null +++ b/src/app/shared/geospatial-map/models/geospatial-map-detail.model.ts @@ -0,0 +1,11 @@ +import { GeospatialMarkerDetail } from './geospatial-marker-detail.model'; + +/** + * This class is used by the ObjectGeospatialMap list view, to supply point data as well as + * routing and title data for the DSO associated with the geospatial points. + */ +export class GeospatialMapDetail { + points: GeospatialMarkerDetail[] = []; + route: string; + title: string; +} diff --git a/src/app/shared/geospatial-map/models/geospatial-marker-detail.model.ts b/src/app/shared/geospatial-map/models/geospatial-marker-detail.model.ts new file mode 100644 index 0000000000..c75758a371 --- /dev/null +++ b/src/app/shared/geospatial-map/models/geospatial-marker-detail.model.ts @@ -0,0 +1,10 @@ +/** + * This class is used by GeospatialMapDetail, to keep richer data for a list of points + * so that they can be rendered as links and alt-text or hover text when drawn on a map + */ +export class GeospatialMarkerDetail { + latitude: number; + longitude: number; + url: string; + title: string; +} diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index efed2d968b..10378ba3f5 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -1,4 +1,9 @@ -@if ((currentMode$ | async) === viewModeEnum.ListElement) { +@if ((currentMode$ | async) === viewModeEnum.GeospatialMap) { + + +} + +@if ((currentMode$ | async) === viewModeEnum.ListElement || (currentMode$ | async) === viewModeEnum.GeospatialMap) { +} diff --git a/src/app/shared/object-geospatial-map/object-geospatial-map.component.scss b/src/app/shared/object-geospatial-map/object-geospatial-map.component.scss new file mode 100644 index 0000000000..4254c7d29f --- /dev/null +++ b/src/app/shared/object-geospatial-map/object-geospatial-map.component.scss @@ -0,0 +1,29 @@ +:host ::ng-deep { + --ds-wrapper-grid-spacing: calc(var(--bs-spacer) / 2); + + div.card { + margin-top: var(--ds-wrapper-grid-spacing); + margin-bottom: var(--ds-wrapper-grid-spacing); + + div.thumbnail > .thumbnail-content { + height: var(--ds-card-thumbnail-height); + width: 100%; + display: block; + min-width: 100%; + min-height: 100%; + object-fit: cover; + object-position: 50% 15%; + } + } +} + +.card-columns { + margin-left: calc(-1 * var(--ds-wrapper-grid-spacing)); + margin-right: calc(-1 * var(--ds-wrapper-grid-spacing)); + column-gap: 0; + + .card-column { + padding-left: var(--ds-wrapper-grid-spacing); + padding-right: var(--ds-wrapper-grid-spacing); + } +} diff --git a/src/app/shared/object-geospatial-map/object-geospatial-map.component.spec.ts b/src/app/shared/object-geospatial-map/object-geospatial-map.component.spec.ts new file mode 100644 index 0000000000..6101e0db6e --- /dev/null +++ b/src/app/shared/object-geospatial-map/object-geospatial-map.component.spec.ts @@ -0,0 +1,99 @@ +import { + ChangeDetectionStrategy, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { StoreModule } from '@ngrx/store'; +import { + TranslateLoader, + TranslateModule, + TranslateService, + TranslateStore, +} from '@ngx-translate/core'; + +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { Item } from '../../core/shared/item.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { GeospatialMapDetail } from '../geospatial-map/models/geospatial-map-detail.model'; +import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; +import { ItemSearchResult } from '../object-collection/shared/item-search-result.model'; +import { PaginationComponent } from '../pagination/pagination.component'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { ObjectGeospatialMapComponent } from './object-geospatial-map.component'; + +describe('ObjectGeospatialMapComponent', () => { + + // Expected geospatial map info parsed in component from search results + const expected = new GeospatialMapDetail(); + expected.points = [{ longitude: 104, latitude: -12, url: '/items', title: 'Test item title' }]; + expected.title = 'Test item title'; + expected.route = '/items'; + + // Mock search results + const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); + mockItemWithMetadata.hitHighlights = {}; + mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test item title', + }, + ], + 'dcterms.spatial': [ + { + language: null, + value: 'Point ( +104.000000 -012.000000 )', + }, + ], + }, + }); + const testObjects = [mockItemWithMetadata]; + const mockRD = { + payload: { + page: testObjects, + }, + } as any; + + let component: ObjectGeospatialMapComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ObjectGeospatialMapComponent, StoreModule.forRoot(), TranslateModule.forRoot({ + loader: { + useClass: TranslateLoaderMock, + provide: TranslateLoader, + }, + })], + providers: [TranslateService, TranslateStore, TranslateLoader, TranslateLoaderMock], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(ObjectGeospatialMapComponent, { + remove: { + imports: [PaginationComponent], + }, + add: { changeDetection: ChangeDetectionStrategy.Default }, + }).compileComponents(); + }); + + it('component is created successfully', () => { + component.objects = mockRD; + expect(component).toBeTruthy(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ObjectGeospatialMapComponent); + component = fixture.componentInstance; // SearchPageComponent test instance + component.objects = mockRD; + fixture.detectChanges(); + }); + + it('component parses search results into a map info array for map drawing', () => { + expect(component.mapInfo).toEqual([expected]); + }); + +}); diff --git a/src/app/shared/object-geospatial-map/object-geospatial-map.component.ts b/src/app/shared/object-geospatial-map/object-geospatial-map.component.ts new file mode 100644 index 0000000000..e27d91c456 --- /dev/null +++ b/src/app/shared/object-geospatial-map/object-geospatial-map.component.ts @@ -0,0 +1,122 @@ +import { + isPlatformBrowser, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + Input, + PLATFORM_ID, + ViewEncapsulation, +} from '@angular/core'; +import { Item } from 'src/app/core/shared/item.model'; +import { getItemPageRoute } from 'src/app/item-page/item-page-routing-paths'; + +import { environment } from '../../../environments/environment'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { ViewMode } from '../../core/shared/view-mode.model'; +import { fadeIn } from '../animations/fade'; +import { hasValue } from '../empty.util'; +import { GeospatialMapComponent } from '../geospatial-map/geospatial-map.component'; +import { GeospatialMapDetail } from '../geospatial-map/models/geospatial-map-detail.model'; +import { ItemSearchResult } from '../object-collection/shared/item-search-result.model'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; +import { parseGeoJsonFromMetadataValue } from '../utils/geospatial.functions'; + +@Component({ + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.Emulated, + selector: 'ds-object-geospatial-map', + styleUrls: ['./object-geospatial-map.component.scss'], + templateUrl: './object-geospatial-map.component.html', + animations: [fadeIn], + standalone: true, + imports: [ GeospatialMapComponent, NgIf ], +}) + +/** + * This component is used with the GeospatialMap ViewMode in search or browse results, and + * prepares geospatial data collection for display on the GeospatialMapComponent + */ +export class ObjectGeospatialMapComponent { + + /** + * The view mode of this component + */ + viewMode = ViewMode.GeospatialMap; + + /** + * Search result objects + */ + @Input() objects: RemoteData>; + + protected readonly isPlatformBrowser = isPlatformBrowser; + + constructor( + @Inject(PLATFORM_ID) public platformId: string, + ) {} + + /** + * Get current objects and extract geospatial metadata to use in the view + */ + get mapInfo(): GeospatialMapDetail[] { + const geospatialFields = environment.geospatialMapViewer.spatialMetadataFields; + const mapInfo: GeospatialMapDetail[] = []; + this.objects.payload.page.forEach(obj => { + for (let i = 0; i < geospatialFields.length; i++) { + const m = (obj as ItemSearchResult).indexableObject.metadata[geospatialFields[i]]; + if (m && m.length > 0) { + for (let j = 0; j < m.length; j++) { + const dso = (obj as ItemSearchResult).indexableObject as Item; + const value = m[j].value; + if (hasValue(value) && hasValue(dso)) { + const mapDetail = this.parseMapDetail(value, dso); + if (hasValue(mapDetail)) { + mapInfo.push(mapDetail); + } + } + } + } + } + }); + return mapInfo; + } + + /** + * Parse map detail needed to draw clickable markers on a map, from WKT strings + * + * @param value + * @param dso + * @private + */ + private parseMapDetail(value: string, dso: Item) { + try { + const geospatialMapDetail = new GeospatialMapDetail(); + geospatialMapDetail.route = getItemPageRoute(dso); + geospatialMapDetail.title = dso.name; + value = value.replace(/\+/g, ''); + const point = parseGeoJsonFromMetadataValue(value); + // Do some simple validation and log a console error if not valid + if (!hasValue(point) || point.type !== 'Point' + || !hasValue(point.coordinates) + || point.coordinates.length < 2) { + console.warn('Could not parse point from WKT string: ' + value); + } else { + // GeoJSON coordinates are [x, y] or [longitude, latitude] or [eastings, northings] + geospatialMapDetail.points.push({ + longitude: point.coordinates[0], + latitude: point.coordinates[1], + url: geospatialMapDetail.route, + title: geospatialMapDetail.title, + }); + return geospatialMapDetail; + } + } catch (e) { + console.warn(`Could not parse point from WKT string: ${value}, error: ${(e as Error).message}`); + } + return null; + } + +} diff --git a/src/app/shared/utils/geospatial.functions.ts b/src/app/shared/utils/geospatial.functions.ts new file mode 100644 index 0000000000..dd28d53736 --- /dev/null +++ b/src/app/shared/utils/geospatial.functions.ts @@ -0,0 +1,17 @@ +import { wktToGeoJSON } from '@terraformer/wkt'; + +/** + * Parse a GeoJSON point from a metadata value containing a WKT string + * @param value + * @private + */ +export function parseGeoJsonFromMetadataValue(value): any { + const point = undefined; + value = value.replace(/\+/g, ''); + try { + return wktToGeoJSON(value.toUpperCase()); + } catch (e) { + console.warn(`Could not parse point from WKT string: ${value.points}, error: ${(e as Error).message}`); + } + return point; +} diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.html b/src/app/shared/view-mode-switch/view-mode-switch.component.html index 79fa5f471a..ad73479fce 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.html +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.html @@ -41,4 +41,19 @@ } + @if (isToShow(viewModeEnum.GeospatialMap)) { + + }
diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.ts index cf94e62544..14f1248102 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.ts @@ -16,6 +16,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; import { SearchService } from '../../core/shared/search/search.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { @@ -77,6 +78,9 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { ngOnInit(): void { if (isEmpty(this.viewModeList)) { this.viewModeList = [ViewMode.ListElement, ViewMode.GridElement]; + if (environment.geospatialMapViewer.enableSearchViewMode) { + this.viewModeList.push(ViewMode.GeospatialMap); + } } this.sub = this.searchService.getViewMode().pipe( diff --git a/src/assets/i18n/de.json5 b/src/assets/i18n/de.json5 index b68b4f009f..feb1b40a4f 100644 --- a/src/assets/i18n/de.json5 +++ b/src/assets/i18n/de.json5 @@ -1562,6 +1562,12 @@ // "browse.metadata.title.breadcrumbs": "Browse by Title", "browse.metadata.title.breadcrumbs": "Titel", + "browse.metadata.map": "Stöbern in Kartenansicht", + + "browse.metadata.map.breadcrumbs": "Stöbern in Kartenansicht", + + "browse.metadata.map.count.items": "Items", + // "pagination.next.button": "Next", "pagination.next.button": "Weiter", @@ -3992,6 +3998,9 @@ // "item.search.title": "Item Search", "item.search.title": "Item-Suche", + //"item.page.dcterms.spatial": "Geospatial point", + "item.page.dcterms.spatial": "Geostandpunkt", + // "item.truncatable-part.show-more": "Show more", "item.truncatable-part.show-more": "Mehr anzeigen", @@ -4871,6 +4880,9 @@ // "menu.section.browse_global_communities_and_collections": "Communities & Collections", "menu.section.browse_global_communities_and_collections": "Bereiche & Sammlungen", + // "menu.section.browse_global_geospatial_map": "By Geolocation (Map)", + "menu.section.browse_global_geospatial_map": "Nach Ort (Kartenansicht)", + // "menu.section.control_panel": "Control Panel", "menu.section.control_panel": "Kontrollfeld", @@ -6962,6 +6974,9 @@ // "search.view-switch.show-list": "Show as list", "search.view-switch.show-list": "Als Liste anzeigen", + //"search.view-switch.show-geospatialMap": "Show as map" + "search.view-switch.show-geospatialMap": "Als Karte anzeigen", + // "selectable-list-item-control.deselect": "Deselect item", "selectable-list-item-control.deselect": "Itemauswahl zurücknehmen", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index ca5f7def52..c4cdb2b553 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1054,6 +1054,12 @@ "browse.metadata.title.breadcrumbs": "Browse by Title", + "browse.metadata.map": "Browse by Geolocation", + + "browse.metadata.map.breadcrumbs": "Browse by Geolocation", + + "browse.metadata.map.count.items": "items", + "pagination.next.button": "Next", "pagination.previous.button": "Previous", @@ -2732,6 +2738,8 @@ "item.page.volume-title": "Volume Title", + "item.page.dcterms.spatial": "Geospatial point", + "item.search.results.head": "Item Search Results", "item.search.title": "Item Search", @@ -3348,6 +3356,8 @@ "menu.section.browse_global_communities_and_collections": "Communities & Collections", + "menu.section.browse_global_geospatial_map": "By Geolocation (Map)", + "menu.section.control_panel": "Control Panel", "menu.section.curation_task": "Curation Task", @@ -4443,6 +4453,7 @@ "search.filters.applied.f.original_bundle_filenames": "File name", "search.filters.applied.f.original_bundle_descriptions": "File description", + "search.filters.applied.f.has_geospatial_metadata": "Has geographical location", "search.filters.applied.f.itemtype": "Type", @@ -4476,6 +4487,8 @@ "search.filters.applied.operator.query": "", + "search.filters.applied.f.point": "Coordinates", + "search.filters.filter.title.head": "Title", "search.filters.filter.title.placeholder": "Title", @@ -4552,6 +4565,8 @@ "search.filters.filter.original_bundle_filenames.head": "File name", + "search.filters.filter.has_geospatial_metadata.head": "Has geographical location", + "search.filters.filter.original_bundle_filenames.placeholder": "File name", "search.filters.filter.original_bundle_filenames.label": "Search File name", @@ -4666,6 +4681,10 @@ "search.filters.has_content_in_original_bundle.false": "No", + "search.filters.has_geospatial_metadata.true": "Yes", + + "search.filters.has_geospatial_metadata.false": "No", + "search.filters.discoverable.true": "No", "search.filters.discoverable.false": "Yes", @@ -4718,6 +4737,8 @@ "search.results.empty": "Your search returned no results.", + "search.results.geospatial-map.empty": "No results on this page with geospatial locations", + "search.results.view-result": "View", "search.results.response.500": "An error occurred during query execution, please try again later", @@ -4756,6 +4777,8 @@ "search.view-switch.show-list": "Show as list", + "search.view-switch.show-geospatialMap": "Show as map", + "selectable-list-item-control.deselect": "Deselect item", "selectable-list-item-control.select": "Select item", @@ -6403,6 +6426,8 @@ "item.page.endorsement": "Endorsement", + "item.page.places": "Related places", + "item.page.review": "Review", "item.page.referenced": "Referenced By", diff --git a/src/assets/images/marker-icon-2x.png b/src/assets/images/marker-icon-2x.png new file mode 100644 index 0000000000..e4abba3b51 Binary files /dev/null and b/src/assets/images/marker-icon-2x.png differ diff --git a/src/assets/images/marker-shadow.png b/src/assets/images/marker-shadow.png new file mode 100644 index 0000000000..d1e773c715 Binary files /dev/null and b/src/assets/images/marker-shadow.png differ diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index a52cd6be35..94c7bd9f7d 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -19,6 +19,7 @@ import { Config } from './config.interface'; import { DiscoverySortConfig } from './discovery-sort.config'; import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { FormConfig } from './form-config.interfaces'; +import { GeospatialMapConfig } from './geospatial-map-config'; import { HomeConfig } from './homepage-config.interface'; import { InfoConfig } from './info-config.interface'; import { ItemConfig } from './item-config.interface'; @@ -68,6 +69,7 @@ interface AppConfig extends Config { notifyMetrics: AdminNotifyMetricsRow[]; liveRegion: LiveRegionConfig; matomo?: MatomoConfig; + geospatialMapViewer: GeospatialMapConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 3da131c814..3dbcc8a326 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -14,6 +14,7 @@ import { CommunityPageConfig } from './community-page-config.interface'; import { DiscoverySortConfig } from './discovery-sort.config'; import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { FormConfig } from './form-config.interfaces'; +import { GeospatialMapConfig } from './geospatial-map-config'; import { HomeConfig } from './homepage-config.interface'; import { InfoConfig } from './info-config.interface'; import { ItemConfig } from './item-config.interface'; @@ -606,4 +607,25 @@ export class DefaultAppConfig implements AppConfig { }; matomo: MatomoConfig = {}; + + // Leaflet tile providers and other configurable attributes + geospatialMapViewer: GeospatialMapConfig = { + spatialMetadataFields: [ + 'dcterms.spatial', + ], + spatialFacetDiscoveryConfiguration: 'geospatial', + spatialPointFilterName: 'point', + enableItemPageFields: false, + enableSearchViewMode: false, + enableBrowseMap: false, + tileProviders: [ + 'OpenStreetMap.Mapnik', + ], + // Starting centre point for maps (before drawing and zooming to markers) + // Defaults to Istanbul + defaultCentrePoint: { + lat: 41.015137, + lng: 28.979530, + }, + }; } diff --git a/src/config/geospatial-map-config.ts b/src/config/geospatial-map-config.ts new file mode 100644 index 0000000000..7922e391ce --- /dev/null +++ b/src/config/geospatial-map-config.ts @@ -0,0 +1,48 @@ +import { Config } from './config.interface'; + +export class GeospatialMapConfig implements Config { + + /** + * The metadata fields which hold WKT points, to use when drawing a map + */ + public spatialMetadataFields: string[]; + + /** + * Discovery search configuration which will return facets of geospatial points + */ + public spatialFacetDiscoveryConfiguration: string; + + /** + * Discovery filter for geospatial point + */ + public spatialPointFilterName: string; + + /** + * A simple switch to test for inclusion of geospatial item page fields in templates + */ + public enableItemPageFields: boolean; + + /** + * Include the map view mode in the list of view modes provided in a search results page + */ + public enableSearchViewMode: boolean; + + /** + * Include a Browse By Geographic Location map in the browse menu links + */ + public enableBrowseMap: boolean; + + /** + * The url string tempalte for a tile provider, e.g. https://tile.openstreetmap.org/{z}/{x}/{y}.png + * to pass to TileLayer when initialising a leaflet map + */ + public tileProviders: string[]; + + /** + * Starting centre point for maps (before drawing and zooming to markers) + * Takes a lat and lng float value as coordinates + * Defaults to Istanbul + */ + public defaultCentrePoint: { lat: number, lng: number }; + +} diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index abe4790b2e..b6348f949d 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -436,4 +436,23 @@ export const environment: BuildConfig = { messageTimeOutDurationMs: 30000, isVisible: false, }, + + // Leaflet tile providers and other configurable attributes + geospatialMapViewer: { + spatialMetadataFields: [ + 'dcterms.spatial', + ], + spatialFacetDiscoveryConfiguration: 'geospatial', + spatialPointFilterName: 'point', + enableItemPageFields: true, + enableSearchViewMode: true, + enableBrowseMap: true, + tileProviders: [ + 'OpenStreetMap.Mapnik', + ], + defaultCentrePoint: { + lat: 41.015137, + lng: 28.979530, + }, + }, }; diff --git a/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts b/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts index fec0873c6a..7025de3e76 100644 --- a/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts @@ -15,6 +15,7 @@ import { ThemedFileSectionComponent } from '../../../../../../../app/item-page/s import { ItemPageAbstractFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component'; import { ItemPageDateFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/date/item-page-date-field.component'; import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { GeospatialItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; import { PublicationComponent as BaseComponent } from '../../../../../../../app/item-page/simple/item-types/publication/publication.component'; @@ -39,7 +40,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the templateUrl: '../../../../../../../app/item-page/simple/item-types/publication/publication.component.html', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ThemedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, RouterLink, AsyncPipe, TranslateModule], + imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ThemedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, RouterLink, AsyncPipe, TranslateModule, GeospatialItemPageFieldComponent], }) export class PublicationComponent extends BaseComponent { diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 57d7c1598c..f6d19320c6 100644 --- a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -17,6 +17,7 @@ import { ItemPageAbstractFieldComponent } from '../../../../../../../app/item-pa import { ItemPageCcLicenseFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; import { ItemPageDateFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/date/item-page-date-field.component'; import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { GeospatialItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; import { UntypedItemComponent as BaseComponent } from '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component'; @@ -61,6 +62,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the AsyncPipe, TranslateModule, ItemPageCcLicenseFieldComponent, + GeospatialItemPageFieldComponent, ], }) export class UntypedItemComponent extends BaseComponent {}