Skip to content

Commit

Permalink
Geospatial maps for item pages, search, browse
Browse files Browse the repository at this point in the history
  • Loading branch information
kshepherd committed Oct 23, 2024
1 parent 779ff47 commit e4f2451
Show file tree
Hide file tree
Showing 46 changed files with 1,626 additions and 15 deletions.
5 changes: 4 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,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": "/"
Expand Down
14 changes: 10 additions & 4 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,13 @@ notifyMetrics:
config: 'NOTIFY.outgoing.delivered'
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'





# Geospatial Map display options
geospatialMapViewer:
spatialMetadataFields:
- 'dcterms.spatial'
spatialFacetDiscoveryConfiguration: 'geospatial'
spatialPointFilterName: 'point'
enableBrowseMap: false
enableSearchViewMode: false
tileProviders:
- 'OpenStreetMap.Mapnik'
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"@ngrx/store": "^17.1.1",
"@ngx-translate/core": "^14.0.0",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@terraformer/wkt": "^2.2.1",
"@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0",
Expand Down Expand Up @@ -146,6 +147,9 @@
"jsonschema": "1.4.1",
"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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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>

Empty file.
Original file line number Diff line number Diff line change
@@ -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);
});
}));
});

});
Original file line number Diff line number Diff line change
@@ -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(),
);
}
}
8 changes: 8 additions & 0 deletions src/app/browse-by/browse-by-page-routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Route } from '@angular/router';

import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.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';
Expand All @@ -14,6 +16,12 @@ export const ROUTES: Route[] = [
menu: dsoEditMenuResolver,
},
children: [
{
path: 'map',
component: BrowseByGeospatialDataComponent,
resolve: { breadcrumb: i18nBreadcrumbResolver },
data: { title: 'browse.map.page', breadcrumbKey: 'browse.metadata.map' },
},
{
path: ':id',
component: BrowseByPageComponent,
Expand Down
Loading

0 comments on commit e4f2451

Please sign in to comment.