Skip to content

Commit b6cdb7c

Browse files
committed
Geospatial maps for item pages, search, browse
1 parent 8086c22 commit b6cdb7c

44 files changed

Lines changed: 1606 additions & 10 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

angular.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@
5858
"input": "src/themes/dspace/styles/theme.scss",
5959
"inject": false,
6060
"bundleName": "dspace-theme"
61-
}
61+
},
62+
"node_modules/leaflet/dist/leaflet.css",
63+
"node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css",
64+
"node_modules/leaflet.markercluster/dist/MarkerCluster.css"
6265
],
6366
"scripts": [],
6467
"baseHref": "/"

config/config.example.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,6 @@ notifyMetrics:
538538
config: 'NOTIFY.outgoing.delivered'
539539
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
540540

541-
542541
# Live Region configuration
543542
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms:
544543
# Live regions are perceivable regions of a web page that are typically updated as a
@@ -552,3 +551,14 @@ liveRegion:
552551
messageTimeOutDurationMs: 30000
553552
# The visibility of the live region. Setting this to true is only useful for debugging purposes.
554553
isVisible: false
554+
555+
# Geospatial Map display options
556+
geospatialMapViewer:
557+
spatialMetadataFields:
558+
- 'dcterms.spatial'
559+
spatialFacetDiscoveryConfiguration: 'geospatial'
560+
spatialPointFilterName: 'point'
561+
enableBrowseMap: false
562+
enableSearchViewMode: false
563+
tileProviders:
564+
- 'OpenStreetMap.Mapnik'

package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@
114114
"@ngrx/store": "^18.1.1",
115115
"@ngx-translate/core": "^16.0.3",
116116
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
117+
"@terraformer/wkt": "^2.2.1",
118+
"@types/grecaptcha": "^3.0.4",
117119
"altcha": "^0.9.0",
120+
"angular-idle-preload": "3.0.0",
118121
"angulartics2": "^12.2.0",
119122
"axios": "^1.7.9",
120123
"bootstrap": "^5.3",
@@ -140,6 +143,10 @@
140143
"json5": "^2.2.3",
141144
"jsonschema": "1.5.0",
142145
"jwt-decode": "^3.1.2",
146+
"klaro": "^0.7.18",
147+
"leaflet": "^1.9.4",
148+
"leaflet-providers": "^2.0.0",
149+
"leaflet.markercluster": "^1.5.3",
143150
"lodash": "^4.17.21",
144151
"lru-cache": "^7.14.1",
145152
"markdown-it": "^13.0.1",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<div class="container">
2+
<h1>{{ 'browse.metadata.map' | translate }}</h1>
3+
<ng-container *ngIf="isPlatformBrowser(platformId)">
4+
<ds-geospatial-map [facetValues]="facetValues$"
5+
[currentScope]="this.scope$|async"
6+
[layout]="'browse'"
7+
style="width: 100%;">
8+
</ds-geospatial-map>
9+
</ng-container>
10+
</div>
11+

src/app/browse-by/browse-by-geospatial-data/browse-by-geospatial-data.component.scss

Whitespace-only changes.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { NO_ERRORS_SCHEMA } from '@angular/core';
2+
import {
3+
async,
4+
ComponentFixture,
5+
TestBed,
6+
waitForAsync,
7+
} from '@angular/core/testing';
8+
import { ActivatedRoute } from '@angular/router';
9+
import { StoreModule } from '@ngrx/store';
10+
import { TranslateModule } from '@ngx-translate/core';
11+
import { of as observableOf } from 'rxjs';
12+
13+
import { environment } from '../../../environments/environment';
14+
import { buildPaginatedList } from '../../core/data/paginated-list.model';
15+
import { PageInfo } from '../../core/shared/page-info.model';
16+
import { SearchService } from '../../core/shared/search/search.service';
17+
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
18+
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
19+
import { FacetValue } from '../../shared/search/models/facet-value.model';
20+
import { FilterType } from '../../shared/search/models/filter-type.model';
21+
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
22+
import { SearchFilterConfig } from '../../shared/search/models/search-filter-config.model';
23+
import { SearchServiceStub } from '../../shared/testing/search-service.stub';
24+
import { BrowseByGeospatialDataComponent } from './browse-by-geospatial-data.component';
25+
26+
// create route stub
27+
const scope = 'test scope';
28+
const activatedRouteStub = {
29+
queryParams: observableOf({
30+
scope: scope,
31+
}),
32+
};
33+
34+
// Mock search filter config
35+
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
36+
name: 'point',
37+
type: FilterType.text,
38+
hasFacets: true,
39+
isOpenByDefault: false,
40+
pageSize: 2,
41+
minValue: 200,
42+
maxValue: 3000,
43+
});
44+
45+
// Mock facet values with and without point data
46+
const facetValue: FacetValue = {
47+
label: 'test',
48+
value: 'test',
49+
count: 20,
50+
_links: {
51+
self: { href: 'selectedValue-self-link2' },
52+
search: { href: `` },
53+
},
54+
};
55+
const pointFacetValue: FacetValue = {
56+
label: 'test point',
57+
value: 'Point ( +174.000000 -042.000000 )',
58+
count: 20,
59+
_links: {
60+
self: { href: 'selectedValue-self-link' },
61+
search: { href: `` },
62+
},
63+
};
64+
const mockValues = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [facetValue]));
65+
const mockPointValues = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [pointFacetValue]));
66+
67+
// Expected search options used in getFacetValuesFor call
68+
const expectedSearchOptions: PaginatedSearchOptions = Object.assign({
69+
'configuration': environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration,
70+
'scope': scope,
71+
});
72+
73+
// Mock search config service returns mock search filter config on getConfig()
74+
const mockSearchConfigService = jasmine.createSpyObj('searchConfigurationService', {
75+
getConfig: createSuccessfulRemoteDataObject$([mockFilterConfig]),
76+
});
77+
let searchService: SearchServiceStub = new SearchServiceStub();
78+
79+
// initialize testing environment
80+
describe('BrowseByGeospatialDataComponent', () => {
81+
let component: BrowseByGeospatialDataComponent;
82+
let fixture: ComponentFixture<BrowseByGeospatialDataComponent>;
83+
84+
beforeEach(async(() => {
85+
TestBed.configureTestingModule({
86+
imports: [ TranslateModule.forRoot(), StoreModule.forRoot(), BrowseByGeospatialDataComponent],
87+
providers: [
88+
{ provide: SearchService, useValue: searchService },
89+
{ provide: SearchConfigurationService, useValue: mockSearchConfigService },
90+
{ provide: ActivatedRoute, useValue: activatedRouteStub },
91+
],
92+
schemas: [NO_ERRORS_SCHEMA],
93+
})
94+
.compileComponents();
95+
}));
96+
97+
beforeEach(() => {
98+
fixture = TestBed.createComponent(BrowseByGeospatialDataComponent);
99+
component = fixture.componentInstance;
100+
});
101+
102+
it('component should be created successfully', () => {
103+
expect(component).toBeTruthy();
104+
});
105+
// return this.searchService.getFacetValuesFor(searchFilterConfig, 1, searchOptions,
106+
// null, true);
107+
describe('BrowseByGeospatialDataComponent component with valid facet values', () => {
108+
beforeEach(() => {
109+
fixture = TestBed.createComponent(BrowseByGeospatialDataComponent);
110+
component = fixture.componentInstance;
111+
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockPointValues);
112+
component.scope$ = observableOf('');
113+
component.ngOnInit();
114+
fixture.detectChanges();
115+
});
116+
117+
it('should call searchConfigService.getConfig() after init', waitForAsync(() => {
118+
expect(mockSearchConfigService.getConfig).toHaveBeenCalled();
119+
}));
120+
121+
it('should call searchService.getFacetValuesFor() with expected parameters', waitForAsync(() => {
122+
component.getFacetValues().subscribe(() => {
123+
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, 1, expectedSearchOptions, null, true);
124+
});
125+
}));
126+
});
127+
128+
describe('BrowseByGeospatialDataComponent component with invalid facet values (no point data)', () => {
129+
beforeEach(() => {
130+
fixture = TestBed.createComponent(BrowseByGeospatialDataComponent);
131+
component = fixture.componentInstance;
132+
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
133+
component.scope$ = observableOf('');
134+
component.ngOnInit();
135+
fixture.detectChanges();
136+
});
137+
138+
it('should call searchConfigService.getConfig() after init', waitForAsync(() => {
139+
expect(mockSearchConfigService.getConfig).toHaveBeenCalled();
140+
}));
141+
142+
it('should call searchService.getFacetValuesFor() with expected parameters', waitForAsync(() => {
143+
component.getFacetValues().subscribe(() => {
144+
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, 1, expectedSearchOptions, null, true);
145+
});
146+
}));
147+
});
148+
149+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
AsyncPipe,
3+
isPlatformBrowser,
4+
NgIf,
5+
} from '@angular/common';
6+
import {
7+
ChangeDetectionStrategy,
8+
Component,
9+
Inject,
10+
OnInit,
11+
PLATFORM_ID,
12+
} from '@angular/core';
13+
import {
14+
ActivatedRoute,
15+
Params,
16+
} from '@angular/router';
17+
import { TranslateModule } from '@ngx-translate/core';
18+
import {
19+
combineLatest,
20+
Observable,
21+
of,
22+
} from 'rxjs';
23+
import {
24+
filter,
25+
map,
26+
switchMap,
27+
take,
28+
} from 'rxjs/operators';
29+
30+
import { environment } from '../../../environments/environment';
31+
import {
32+
getFirstCompletedRemoteData,
33+
getFirstSucceededRemoteDataPayload,
34+
} from '../../core/shared/operators';
35+
import { SearchService } from '../../core/shared/search/search.service';
36+
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
37+
import { hasValue } from '../../shared/empty.util';
38+
import { GeospatialMapComponent } from '../../shared/geospatial-map/geospatial-map.component';
39+
import { FacetValues } from '../../shared/search/models/facet-values.model';
40+
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
41+
42+
@Component({
43+
selector: 'ds-browse-by-geospatial-data',
44+
templateUrl: './browse-by-geospatial-data.component.html',
45+
styleUrls: ['./browse-by-geospatial-data.component.scss'],
46+
changeDetection: ChangeDetectionStrategy.OnPush,
47+
imports: [GeospatialMapComponent, NgIf, AsyncPipe, TranslateModule],
48+
standalone: true,
49+
})
50+
/**
51+
* Component displaying a large 'browse map', which is really a geolocation few of the 'point' facet defined
52+
* in the geospatial discovery configuration.
53+
* The markers are clustered by location, and each individual marker will link to a search page for that point value
54+
* as a filter.
55+
*
56+
* @author Kim Shepherd
57+
*/
58+
export class BrowseByGeospatialDataComponent implements OnInit {
59+
60+
protected readonly isPlatformBrowser = isPlatformBrowser;
61+
62+
public facetValues$: Observable<FacetValues> = of(null);
63+
64+
constructor(
65+
@Inject(PLATFORM_ID) public platformId: string,
66+
private searchConfigurationService: SearchConfigurationService,
67+
private searchService: SearchService,
68+
protected route: ActivatedRoute,
69+
) {}
70+
71+
public scope$: Observable<string> ;
72+
73+
ngOnInit(): void {
74+
this.scope$ = this.route.queryParams.pipe(
75+
map((params: Params) => params.scope),
76+
);
77+
this.facetValues$ = this.getFacetValues();
78+
}
79+
80+
/**
81+
* Get facet values for use in rendering 'browse by' geospatial map
82+
*/
83+
getFacetValues(): Observable<FacetValues> {
84+
return combineLatest([this.scope$, this.searchConfigurationService.getConfig(
85+
// If the geospatial configuration is not found, default will be returned and used
86+
'', environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration).pipe(
87+
getFirstCompletedRemoteData(),
88+
getFirstSucceededRemoteDataPayload(),
89+
filter((searchFilterConfigs) => hasValue(searchFilterConfigs)),
90+
take(1),
91+
map((searchFilterConfigs) => searchFilterConfigs[0]),
92+
filter((searchFilterConfig) => hasValue(searchFilterConfig))),
93+
],
94+
).pipe(
95+
switchMap(([scope, searchFilterConfig]) => {
96+
// Get all points in one page, if possible
97+
searchFilterConfig.pageSize = 99999;
98+
const searchOptions: PaginatedSearchOptions = Object.assign({
99+
'configuration': environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration,
100+
'scope': scope,
101+
});
102+
return this.searchService.getFacetValuesFor(searchFilterConfig, 1, searchOptions,
103+
null, true);
104+
}),
105+
getFirstCompletedRemoteData(),
106+
getFirstSucceededRemoteDataPayload(),
107+
);
108+
}
109+
}

0 commit comments

Comments
 (0)