Geospatial maps for item pages, search, browse

This commit is contained in:
Kim Shepherd
2024-09-10 08:55:19 +02:00
parent 8086c2242d
commit b6cdb7c6b1
44 changed files with 1606 additions and 10 deletions

View File

@@ -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": "/"

View File

@@ -538,7 +538,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
@@ -552,3 +551,14 @@ 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:
spatialMetadataFields:
- 'dcterms.spatial'
spatialFacetDiscoveryConfiguration: 'geospatial'
spatialPointFilterName: 'point'
enableBrowseMap: false
enableSearchViewMode: false
tileProviders:
- 'OpenStreetMap.Mapnik'

30
package-lock.json generated
View File

@@ -32,7 +32,10 @@
"@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@terraformer/wkt": "^2.2.1",
"@types/grecaptcha": "^3.0.4",
"altcha": "^0.9.0",
"angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0",
"axios": "^1.7.9",
"bootstrap": "^5.3",
@@ -58,6 +61,10 @@
"json5": "^2.2.3",
"jsonschema": "1.5.0",
"jwt-decode": "^3.1.2",
"klaro": "^0.7.18",
"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 +7590,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 +16153,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",

View File

@@ -114,7 +114,10 @@
"@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@terraformer/wkt": "^2.2.1",
"@types/grecaptcha": "^3.0.4",
"altcha": "^0.9.0",
"angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0",
"axios": "^1.7.9",
"bootstrap": "^5.3",
@@ -140,6 +143,10 @@
"json5": "^2.2.3",
"jsonschema": "1.5.0",
"jwt-decode": "^3.1.2",
"klaro": "^0.7.18",
"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",

View File

@@ -0,0 +1,11 @@
<div class="container">
<h1>{{ 'browse.metadata.map' | translate }}</h1>
<ng-container *ngIf="isPlatformBrowser(platformId)">
<ds-geospatial-map [facetValues]="facetValues$"
[currentScope]="this.scope$|async"
[layout]="'browse'"
style="width: 100%;">
</ds-geospatial-map>
</ng-container>
</div>

View File

@@ -0,0 +1,149 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
async,
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,
});
// 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<BrowseByGeospatialDataComponent>;
beforeEach(async(() => {
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();
});
// return this.searchService.getFacetValuesFor(searchFilterConfig, 1, searchOptions,
// null, true);
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);
});
}));
});
});

View File

@@ -0,0 +1,109 @@
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<FacetValues> = of(null);
constructor(
@Inject(PLATFORM_ID) public platformId: string,
private searchConfigurationService: SearchConfigurationService,
private searchService: SearchService,
protected route: ActivatedRoute,
) {}
public scope$: Observable<string> ;
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<FacetValues> {
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,
});
return this.searchService.getFacetValuesFor(searchFilterConfig, 1, searchOptions,
null, true);
}),
getFirstCompletedRemoteData(),
getFirstSucceededRemoteDataPayload(),
);
}
}

View File

@@ -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,

View File

@@ -8,4 +8,5 @@ export enum ViewMode {
DetailedListElement = 'detailed',
StandalonePage = 'standalone',
Table = 'table',
GeospatialMap = 'geospatial-map'
}

View File

@@ -0,0 +1,10 @@
<div class="item-page-field" *ngIf="isNotEmpty(points) || isNotEmpty(bboxes)">
<ds-metadata-field-wrapper [label]="label | translate">
<ds-geospatial-map [coordinates]="this.points"
[bbox]="this.bboxes"
[cluster]="this.cluster"
[layout]="'item'"
style="width: 100%;">
</ds-geospatial-map>
</ds-metadata-field-wrapper>
</div>

View File

@@ -0,0 +1,64 @@
import {
ChangeDetectionStrategy,
NO_ERRORS_SCHEMA,
} from '@angular/core';
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
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<GeospatialItemPageFieldComponent>;
const mockValue = 'Point ( +174.000000 -042.000000 )';
const mockField = 'dcterms.spatial';
const mockLabel = 'Test location';
const mockFields = [mockField];
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 },
],
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();
});
});

View File

@@ -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 = ['gnd.coordinates.bbox'];
/**
* 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);
}
}
}

View File

@@ -106,6 +106,15 @@
[fields]="['datacite.relation.isReferencedBy']"
[label]="'item.page.referenced'">
</ds-item-page-uri-field>
<!-- Below is an example of how to render one or more lat/lng points and/or bounding box rectangles
in a tiled map viewer. Set 'cluster' to true for marker clustering -->
<!-- <ds-geospatial-item-page-field [item]="object"-->
<!-- [label]="'item.page.places'"-->
<!-- [pointFields]="['dcterms.spatial']"-->
<!-- [bboxFields]="['external.spatial.bbox']"-->
<!-- [cluster]="true"-->
<!-- >-->
<!-- </ds-geospatial-item-page-field>-->
<div>
<a class="btn btn-outline-primary" role="button" [routerLink]="[itemPageRoute + '/full']">
<i class="fas fa-info-circle"></i> {{"item.page.link.full" | translate}}

View File

@@ -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 {

View File

@@ -71,6 +71,15 @@
[fields]="['dc.identifier.citation']"
[label]="'item.page.citation'">
</ds-generic-item-page-field>
<!-- Below is an example of how to render one or more lat/lng points and/or bounding box rectangles
in a tiled map viewer. Set 'cluster' to true for marker clustering -->
<!-- <ds-geospatial-item-page-field [item]="object"-->
<!-- [label]="'item.page.places'"-->
<!-- [pointFields]="['dcterms.spatial']"-->
<!-- [bboxFields]="['external.spatial.bbox']"-->
<!-- [cluster]="true"-->
<!-- >-->
<!-- </ds-geospatial-item-page-field>-->
<ds-item-page-uri-field [item]="object"
[fields]="['dc.identifier.uri']"
[label]="'item.page.uri'">

View File

@@ -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 {}

View File

@@ -6,6 +6,7 @@ import {
import { TranslateModule } from '@ngx-translate/core';
import {
Observable,
of,
zip as observableZip,
} from 'rxjs';
import { map } from 'rxjs/operators';
@@ -93,14 +94,17 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList
* @param metadata The list of all metadata values
* @param page The page to return representations for
*/
resolveMetadataRepresentations(metadata: MetadataValue[], page: number): Observable<MetadataRepresentation[]> {
resolveMetadataRepresentations(metadata: MetadataValue[], page: number): Observable<MetadataRepresentation[]|any[]> {
return observableZip(
...metadata
.slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy)
.map((metadatum: any) => Object.assign(new MetadataValue(), metadatum))
.map((metadatum: MetadataValue) => {
if (this.metadataService.isVirtual(metadatum)) {
if (this.metadataService.isVirtual(metadatum) && !metadatum.authority.includes('http')) {
return this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType);
} else if (this.metadataService.isVirtual(metadatum) && metadatum.authority.includes('http')) {
// TODO: we could do authority virtual handling here?
return of([]);
} else {
// Check for a configured browse link and return a standard metadata representation
let searchKeyArray: string[] = [];

View File

@@ -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,
})));

View File

@@ -0,0 +1,6 @@
<div class="map-container {{layout}}-map-container">
<div class="map-frame {{layout}}-map-frame">
<div class="geospatial-map" style="height:100%"></div>
</div>
</div>

View File

@@ -0,0 +1,61 @@
.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%;
}
//.map {
// 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;
}

View File

@@ -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<GeospatialMapComponent>;
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);
});
});
});
});

View File

@@ -0,0 +1,371 @@
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<FacetValues>;
/**
* 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';
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',
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: [51.505, -0.09],
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 (isNotEmpty(this.parsedBoundingBoxes) && 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 (isEmpty(points)) {
return;
}
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 + '<br/>(' + 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);
}
}
/**
* Render a bounding box and return its bounds / rectangle elements
*
* @param L
* @param bbox
* @private
*/
private renderBoundingBox(L, bbox: string): string[][] {
if (hasValue(bbox)) {
let parsedBbox = bbox.replace(/[{} ]/g, '');
parsedBbox = parsedBbox.replace(/[^=,]+=/g, '');
const parsedBboxParts = parsedBbox.split(',');
const bounds = [[parsedBboxParts[1], parsedBboxParts[0]], [parsedBboxParts[2], parsedBboxParts[3]]];
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;
}
}

View File

@@ -0,0 +1,7 @@
import { GeospatialMarkerDetail } from './geospatial-marker-detail.model';
export class GeospatialMapDetail {
points: GeospatialMarkerDetail[] = [];
route: string;
title: string;
}

View File

@@ -0,0 +1,6 @@
export class GeospatialMarkerDetail {
latitude: number;
longitude: number;
url: string;
title: string;
}

View File

@@ -1,7 +1,5 @@
<div class="simple-view-element" [class.d-none]="hideIfNoTextContent && content.textContent.trim().length === 0">
@if (label) {
<h2 class="simple-view-element-header">{{ label }}</h2>
}
<div class="simple-view-element" [class.d-none]="hideIfNoTextContent && hasNoContent">
<h2 class="simple-view-element-header" *ngIf="label">{{ label }}</h2>
<div #content class="simple-view-element-body">
<ng-content></ng-content>
</div>

View File

@@ -1,7 +1,9 @@
import {
Component,
ElementRef,
Input,
ViewChild,
} from '@angular/core';
/**
@@ -21,6 +23,12 @@ export class MetadataFieldWrapperComponent {
* The label (title) for the content
*/
@Input() label: string;
@ViewChild('content', { static: true }) contentElementRef: ElementRef;
@Input() hideIfNoTextContent = true;
get hasNoContent(): boolean{
return this.contentElementRef.nativeElement.textContent.trim().length === 0
&& this.contentElementRef.nativeElement.querySelector('img') === null;
}
}

View File

@@ -0,0 +1,5 @@
<ng-container *ngIf="isPlatformBrowser(platformId)">
<ds-geospatial-map [mapInfo]="mapInfo"></ds-geospatial-map>
</ng-container>

View File

@@ -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);
}
}

View File

@@ -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<ObjectGeospatialMapComponent>;
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]);
});
});

View File

@@ -0,0 +1,118 @@
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 ],
})
export class ObjectGeospatialMapComponent {
/**
* The view mode of this component
*/
viewMode = ViewMode.GeospatialMap;
/**
* Search result objects
*/
@Input() objects: RemoteData<PaginatedList<ListableObject>>;
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;
}
}

View File

@@ -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;
}

View File

@@ -39,6 +39,16 @@
class="btn btn-secondary"
[attr.data-test]="'detail-view' | dsBrowserOnly">
<i class="far fa-square"></i>
</button> <button *ngIf="isToShow(viewModeEnum.GeospatialMap)"
routerLink="."
[queryParams]="{view: 'geospatialMap'}"
queryParamsHandling="merge"
(click)="switchViewTo(viewModeEnum.GeospatialMap)"
routerLinkActive="active"
[class.active]="currentMode === viewModeEnum.GeospatialMap"
class="btn btn-secondary"
[attr.data-test]="'grid-view' | dsBrowserOnly">
<span class="fas fa-map"></span><span class="sr-only">{{'search.view-switch.show-geospatialMap' | translate}}</span>
</button>
}
</div>

View File

@@ -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(

View File

@@ -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",

View File

@@ -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",
@@ -2728,6 +2734,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",
@@ -3344,6 +3352,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",
@@ -4439,6 +4449,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",
@@ -4548,6 +4559,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",
@@ -4662,6 +4675,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",
@@ -4752,6 +4769,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",
@@ -6399,6 +6418,8 @@
"item.page.endorsement": "Endorsement",
"item.page.places": "Related places",
"item.page.review": "Review",
"item.page.referenced": "Referenced By",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

View File

@@ -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;
}
/**

View File

@@ -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';
@@ -604,4 +605,18 @@ export class DefaultAppConfig implements AppConfig {
};
matomo: MatomoConfig = {};
// Leaflet tile providers and other configurable attributes
geospatialMapViewer: GeospatialMapConfig = {
spatialMetadataFields: [
'dcterms.spatial',
],
spatialFacetDiscoveryConfiguration: 'geospatial',
spatialPointFilterName: 'point',
enableSearchViewMode: false,
enableBrowseMap: false,
tileProviders: [
'OpenStreetMap.Mapnik',
],
};
}

View File

@@ -0,0 +1,38 @@
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;
/**
* 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[];
}

View File

@@ -434,4 +434,18 @@ export const environment: BuildConfig = {
messageTimeOutDurationMs: 30000,
isVisible: false,
},
// Leaflet tile providers and other configurable attributes
geospatialMapViewer: {
spatialMetadataFields: [
'dcterms.spatial',
],
spatialFacetDiscoveryConfiguration: 'geospatial',
spatialPointFilterName: 'point',
enableSearchViewMode: true,
enableBrowseMap: true,
tileProviders: [
'OpenStreetMap.Mapnik',
],
},
};

View File

@@ -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 {

View File

@@ -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 {}