mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-06 17:44:11 +00:00
Geospatial maps for item pages, search, browse
This commit is contained in:
@@ -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": "/"
|
||||
|
@@ -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
30
package-lock.json
generated
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
});
|
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
@@ -8,4 +8,5 @@ export enum ViewMode {
|
||||
DetailedListElement = 'detailed',
|
||||
StandalonePage = 'standalone',
|
||||
Table = 'table',
|
||||
GeospatialMap = 'geospatial-map'
|
||||
}
|
||||
|
@@ -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>
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
@@ -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}}
|
||||
|
@@ -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 {
|
||||
|
||||
|
@@ -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'">
|
||||
|
@@ -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 {}
|
||||
|
@@ -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[] = [];
|
||||
|
@@ -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,
|
||||
})));
|
||||
|
@@ -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>
|
||||
|
61
src/app/shared/geospatial-map/geospatial-map.component.scss
Normal file
61
src/app/shared/geospatial-map/geospatial-map.component.scss
Normal 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;
|
||||
}
|
215
src/app/shared/geospatial-map/geospatial-map.component.spec.ts
Normal file
215
src/app/shared/geospatial-map/geospatial-map.component.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
371
src/app/shared/geospatial-map/geospatial-map.component.ts
Normal file
371
src/app/shared/geospatial-map/geospatial-map.component.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
import { GeospatialMarkerDetail } from './geospatial-marker-detail.model';
|
||||
|
||||
export class GeospatialMapDetail {
|
||||
points: GeospatialMarkerDetail[] = [];
|
||||
route: string;
|
||||
title: string;
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
export class GeospatialMarkerDetail {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
@@ -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>
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,5 @@
|
||||
<ng-container *ngIf="isPlatformBrowser(platformId)">
|
||||
<ds-geospatial-map [mapInfo]="mapInfo"></ds-geospatial-map>
|
||||
</ng-container>
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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]);
|
||||
});
|
||||
|
||||
});
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
17
src/app/shared/utils/geospatial.functions.ts
Normal file
17
src/app/shared/utils/geospatial.functions.ts
Normal 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;
|
||||
}
|
@@ -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>
|
||||
|
@@ -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(
|
||||
|
@@ -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",
|
||||
|
||||
|
@@ -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",
|
||||
|
BIN
src/assets/images/marker-icon-2x.png
Normal file
BIN
src/assets/images/marker-icon-2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
src/assets/images/marker-shadow.png
Normal file
BIN
src/assets/images/marker-shadow.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 797 B |
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
38
src/config/geospatial-map-config.ts
Normal file
38
src/config/geospatial-map-config.ts
Normal 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[];
|
||||
|
||||
|
||||
|
||||
}
|
@@ -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',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
@@ -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 {
|
||||
|
||||
|
@@ -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 {}
|
||||
|
Reference in New Issue
Block a user