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) {
+
+
+ }