Commit 1fd7d7f0 authored by Jean-Baptiste Nizet's avatar Jean-Baptiste Nizet Committed by Exbrayat Cédric
Browse files

feat: maps

parent 951b746f
package fr.inra.urgi.faidare.utils;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* Utilities for sites
* @author JB Nizet
*/
public class Sites {
public static String siteIdToLocationId(String siteId) {
return Base64.getUrlEncoder().encodeToString(("urn:URGI/location/" + siteId).getBytes(StandardCharsets.US_ASCII));
}
}
package fr.inra.urgi.faidare.web.germplasm;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
......@@ -105,7 +106,6 @@ public class GermplasmController {
createXref("bazbing")
);
sortDonors(germplasm);
sortPopulations(germplasm);
sortCollections(germplasm);
......@@ -117,8 +117,7 @@ public class GermplasmController {
faidareProperties.getByUri(germplasm.getSourceUri()),
attributes,
pedigree,
crossReferences
)
crossReferences)
);
}
......@@ -205,8 +204,23 @@ public class GermplasmController {
SiteVO originSite = new SiteVO();
originSite.setSiteId("1234");
originSite.setSiteName("Le Moulon");
originSite.setSiteType("Origin site");
originSite.setLatitude(47.0F);
originSite.setLongitude(12.0F);
result.setOriginSite(originSite);
List<SiteVO> evaluationSites = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SiteVO evaluationSite = new SiteVO();
evaluationSite.setSiteId(Integer.toString(12347 + i));
evaluationSite.setSiteType("Evaluation site");
evaluationSite.setSiteName("Site " + i);
evaluationSite.setLatitude(46.0F + i);
evaluationSite.setLongitude(13.0F + i);
evaluationSites.add(evaluationSite);
}
result.setEvaluationSites(evaluationSites);
result.setGenus("Genus 1");
result.setSpecies("Species 1");
result.setSpeciesAuthority("Species Auth");
......@@ -241,7 +255,13 @@ public class GermplasmController {
collector.setAccessionNumber("567");
result.setCollector(collector);
result.setCollectingSite(originSite);
SiteVO collectingSite = new SiteVO();
collectingSite.setSiteId("1235");
collectingSite.setSiteName("St Just");
collectingSite.setSiteType("Collecting site");
collectingSite.setLatitude(48.0F);
collectingSite.setLongitude(13.0F);
result.setCollectingSite(collectingSite);
result.setAcquisitionDate("In the summer");
GermplasmInstituteVO breeder = new GermplasmInstituteVO();
......
package fr.inra.urgi.faidare.web.germplasm;
import java.util.ArrayList;
import java.util.List;
import fr.inra.urgi.faidare.domain.brapi.v1.data.BrapiGermplasmAttributeValue;
import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmInstituteVO;
import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmVO;
import fr.inra.urgi.faidare.domain.data.germplasm.PedigreeVO;
import fr.inra.urgi.faidare.domain.data.germplasm.SiteVO;
import fr.inra.urgi.faidare.domain.datadiscovery.data.DataSource;
import fr.inra.urgi.faidare.domain.xref.XRefDocumentVO;
import fr.inra.urgi.faidare.web.site.MapLocation;
import org.apache.logging.log4j.util.Strings;
/**
......@@ -136,4 +139,19 @@ public final class GermplasmModel {
|| Strings.isNotBlank(this.pedigree.getCrossingYear())
|| Strings.isNotBlank(this.pedigree.getFamilyCode()));
}
public List<MapLocation> getMapLocations() {
List<SiteVO> sites = new ArrayList<>();
if (germplasm.getCollectingSite() != null) {
sites.add(germplasm.getCollectingSite());
}
if (germplasm.getOriginSite() != null) {
sites.add(germplasm.getOriginSite());
}
if (germplasm.getEvaluationSites() != null) {
sites.addAll(germplasm.getEvaluationSites());
}
return MapLocation.sitesToDisplayableMapLocations(sites);
}
}
package fr.inra.urgi.faidare.web.site;
import java.util.List;
import java.util.stream.Collectors;
import fr.inra.urgi.faidare.domain.data.LocationVO;
import fr.inra.urgi.faidare.domain.data.germplasm.SiteVO;
import fr.inra.urgi.faidare.utils.Sites;
/**
* An object that can be serialized to JSON to serve as a map marker.
* @author JB Nizet
*/
public final class MapLocation {
private final String locationDbId;
private final String locationType;
private final String locationName;
private final double latitude;
private final double longitude;
public MapLocation(String locationDbId,
String locationType,
String locationName,
double latitude,
double longitude) {
this.locationDbId = locationDbId;
this.locationType = locationType;
this.locationName = locationName;
this.latitude = latitude;
this.longitude = longitude;
}
public MapLocation(LocationVO site) {
this(site.getLocationDbId(),
site.getLocationType(),
site.getLocationName(),
site.getLatitude(),
site.getLongitude());
}
public MapLocation(SiteVO site) {
this(Sites.siteIdToLocationId(site.getSiteId()),
site.getSiteType(),
site.getSiteName(),
site.getLatitude(),
site.getLongitude());
}
public static List<MapLocation> locationsToDisplayableMapLocations(List<LocationVO> locations) {
return locations.stream()
.filter(location -> location.getLatitude() != null && location.getLongitude() != null)
.map(MapLocation::new)
.collect(Collectors.toList());
}
public static List<MapLocation> sitesToDisplayableMapLocations(List<SiteVO> sites) {
return sites.stream()
.filter(site -> site.getLatitude() != null && site.getLongitude() != null)
.map(MapLocation::new)
.collect(Collectors.toList());
}
public String getLocationDbId() {
return locationDbId;
}
public String getLocationType() {
return locationType;
}
public String getLocationName() {
return locationName;
}
public double getLatitude() {
return latitude;
}
public double getLongitude() {
return longitude;
}
}
......@@ -111,4 +111,8 @@ public final class SiteModel {
public List<XRefDocumentVO> getCrossReferences() {
return crossReferences;
}
public List<MapLocation> getMapLocations() {
return MapLocation.locationsToDisplayableMapLocations(Collections.singletonList(this.site));
}
}
......@@ -12,16 +12,20 @@ import com.google.common.collect.Lists;
import fr.inra.urgi.faidare.api.NotFoundException;
import fr.inra.urgi.faidare.config.FaidareProperties;
import fr.inra.urgi.faidare.domain.criteria.GermplasmPOSTSearchCriteria;
import fr.inra.urgi.faidare.domain.data.LocationVO;
import fr.inra.urgi.faidare.domain.data.TrialVO;
import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmVO;
import fr.inra.urgi.faidare.domain.data.study.StudyDetailVO;
import fr.inra.urgi.faidare.domain.data.variable.ObservationVariableVO;
import fr.inra.urgi.faidare.domain.xref.XRefDocumentVO;
import fr.inra.urgi.faidare.repository.es.GermplasmRepository;
import fr.inra.urgi.faidare.repository.es.LocationRepository;
import fr.inra.urgi.faidare.repository.es.StudyRepository;
import fr.inra.urgi.faidare.repository.es.TrialRepository;
import fr.inra.urgi.faidare.repository.es.XRefDocumentRepository;
import fr.inra.urgi.faidare.repository.file.CropOntologyRepository;
import fr.inra.urgi.faidare.web.site.MapLocation;
import org.apache.logging.log4j.util.Strings;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
......@@ -42,19 +46,22 @@ public class StudyController {
private final GermplasmRepository germplasmRepository;
private final CropOntologyRepository cropOntologyRepository;
private final TrialRepository trialRepository;
private final LocationRepository locationRepository;
public StudyController(StudyRepository studyRepository,
FaidareProperties faidareProperties,
XRefDocumentRepository xRefDocumentRepository,
GermplasmRepository germplasmRepository,
CropOntologyRepository cropOntologyRepository,
TrialRepository trialRepository) {
TrialRepository trialRepository,
LocationRepository locationRepository) {
this.studyRepository = studyRepository;
this.faidareProperties = faidareProperties;
this.xRefDocumentRepository = xRefDocumentRepository;
this.germplasmRepository = germplasmRepository;
this.cropOntologyRepository = cropOntologyRepository;
this.trialRepository = trialRepository;
this.locationRepository = locationRepository;
}
@GetMapping("/{studyId}")
......@@ -77,6 +84,11 @@ public class StudyController {
List<GermplasmVO> germplasms = getGermplasms(study);
List<ObservationVariableVO>variables = getVariables(study);
List<TrialVO> trials = getTrials(study);
LocationVO location = getLocation(study);
// TODO remove this
location.setLatitude(34.0);
location.setLongitude(14.0);
return new ModelAndView("study",
"model",
......@@ -86,11 +98,19 @@ public class StudyController {
germplasms,
variables,
trials,
crossReferences
crossReferences,
location
)
);
}
private LocationVO getLocation(StudyDetailVO study) {
if (Strings.isBlank(study.getLocationDbId())) {
return null;
}
return locationRepository.getById(study.getLocationDbId());
}
private List<GermplasmVO> getGermplasms(StudyDetailVO study) {
if (study.getGermplasmDbIds() == null || study.getGermplasmDbIds().isEmpty()) {
return Collections.emptyList();
......@@ -125,6 +145,8 @@ public class StudyController {
.collect(Collectors.toList());
}
private XRefDocumentVO createXref(String name) {
XRefDocumentVO xref = new XRefDocumentVO();
xref.setName(name);
......
package fr.inra.urgi.faidare.web.study;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import fr.inra.urgi.faidare.domain.data.LocationVO;
......@@ -15,6 +12,7 @@ import fr.inra.urgi.faidare.domain.data.study.StudyDetailVO;
import fr.inra.urgi.faidare.domain.data.variable.ObservationVariableVO;
import fr.inra.urgi.faidare.domain.datadiscovery.data.DataSource;
import fr.inra.urgi.faidare.domain.xref.XRefDocumentVO;
import fr.inra.urgi.faidare.web.site.MapLocation;
/**
* The model used by the study page
......@@ -27,6 +25,7 @@ public final class StudyModel {
private final List<ObservationVariableVO> variables;
private final List<TrialVO> trials;
private final List<XRefDocumentVO> crossReferences;
private final LocationVO location;
private final List<Map.Entry<String, Object>> additionalInfoProperties;
public StudyModel(StudyDetailVO study,
......@@ -34,13 +33,15 @@ public final class StudyModel {
List<GermplasmVO> germplasms,
List<ObservationVariableVO> variables,
List<TrialVO> trials,
List<XRefDocumentVO> crossReferences) {
List<XRefDocumentVO> crossReferences,
LocationVO location) {
this.study = study;
this.source = source;
this.germplasms = germplasms;
this.variables = variables;
this.trials = trials;
this.crossReferences = crossReferences;
this.location = location;
Map<String, Object> additionalInfo =
study.getAdditionalInfo() == null ? Collections.emptyMap() : study.getAdditionalInfo().getProperties();
......@@ -79,4 +80,11 @@ public final class StudyModel {
public List<Map.Entry<String, Object>> getAdditionalInfoProperties() {
return additionalInfoProperties;
}
public List<MapLocation> getMapLocations() {
if (this.location == null) {
return Collections.emptyList();
}
return MapLocation.locationsToDisplayableMapLocations(Collections.singletonList(this.location));
}
}
......@@ -9,8 +9,11 @@ import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import fr.inra.urgi.faidare.domain.data.germplasm.CollPopVO;
import fr.inra.urgi.faidare.domain.data.germplasm.TaxonSourceVO;
import fr.inra.urgi.faidare.utils.Sites;
import org.apache.logging.log4j.util.Strings;
/**
......@@ -21,6 +24,7 @@ public class FaidareExpressions {
private static final Map<String, Function<String, String>> TAXON_ID_URL_FACTORIES_BY_SOURCE_NAME =
createTaxonIdUrlFactories();
private static final ObjectMapper objectMapper = new ObjectMapper();
private static Map<String, Function<String, String>> createTaxonIdUrlFactories() {
Map<String, Function<String, String>> result = new HashMap<>();
......@@ -38,7 +42,7 @@ public class FaidareExpressions {
}
public String toSiteParam(String siteId) {
return Base64.getUrlEncoder().encodeToString(("urn:URGI/location/" + siteId).getBytes(StandardCharsets.US_ASCII));
return Sites.siteIdToLocationId(siteId);
}
public String collPopTitle(CollPopVO collPopVO) {
......@@ -55,6 +59,15 @@ public class FaidareExpressions {
return urlFactory != null ? urlFactory.apply(taxonSource.getTaxonId()) : null;
}
public String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
}
catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
}
private String collPopTitle(CollPopVO collPopVO, Function<String, String> nameTransformer) {
if (Strings.isBlank(collPopVO.getType())) {
return nameTransformer.apply(collPopVO.getName());
......
const faidare = (() => {
function initializePopovers() {
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
popoverTriggerList.forEach(popoverTriggerEl => {
const options = {};
const contentSelector = popoverTriggerEl.dataset.bsElement;
if (contentSelector) {
const content = document.querySelector(contentSelector);
if (content) {
options.content = () => {
const element = document.createElement('div');
element.innerHTML = content.innerHTML;
return element;
};
options.html = true;
} else {
throw new Error('element with selector ' + contentSelector + ' not found');
}
}
return new bootstrap.Popover(popoverTriggerEl, options);
});
}
function markerColor(location) {
switch (location.locationType) {
case 'Origin site':
return 'red';
case 'Collecting site':
return 'blue';
case 'Evaluation site':
return 'green';
}
return 'purple';
}
function markerIconUrl(contextPath, location) {
return `${contextPath}/assets/images/marker-icon-${markerColor(location)}.png`;
}
function initializeMap(options) {
if (!options.locations.length) {
return;
}
const mapContainerElement = document.querySelector('#map-container');
mapContainerElement.classList.remove("d-none");
const mapElement = document.querySelector('#map');
const map = L.map(mapElement);
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, ' +
'Esri China (Hong Kong), Esri (Thailand), TomTom, 2012'
}).addTo(map);
const firstLocation = options.locations[0];
map.setView([firstLocation.latitude, firstLocation.longitude], 5);
const markers = L.markerClusterGroup();
const mapMarkers = [];
for (const location of options.locations) {
const icon = L.icon({
iconUrl: markerIconUrl(options.contextPath, location),
iconAnchor: [12, 41], // point of the icon which will correspond to marker's location
});
const popupElement = document.createElement('div');
const titleElement = document.createElement('strong');
titleElement.innerText = location.locationName;
const typeElement = document.createElement('div');
typeElement.innerText = location.locationType;
const linkElement = document.createElement('a');
linkElement.innerText = 'Details';
linkElement.href = `${options.contextPath}/sites/${location.locationDbId}`;
popupElement.appendChild(titleElement);
popupElement.appendChild(typeElement);
popupElement.appendChild(linkElement);
const marker = L.marker(
[location.latitude, location.longitude],
{ icon: icon }
);
markers.addLayer(marker.bindPopup(popupElement));
mapMarkers.push(marker);
}
const initialZoom = map.getZoom();
map.fitBounds(L.featureGroup(mapMarkers).getBounds());
const markerZoom = map.getZoom();
setTimeout(() => {
map.setZoom(Math.min(initialZoom, markerZoom));
map.addLayer(markers);
}, 100);
}
return {
initializePopovers,
initializeMap
};
})();
......@@ -5,3 +5,11 @@
.popover {
max-width: min(80vw, 600px);
}
#map {
height: min(400px, 60vh);
}
.map-legend img {
height: 1.5rem;
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!--
Reusable fragment displaying a map and its legend.
The map is initially hidden. The JavaScript displays it if there are locations
to display
-->
<div th:fragment="map" id="map-container" class="d-none">
<div id="map" class="border rounded"></div>
<div class="map-legend mt-1">
<img th:src="@{/assets/images/marker-icon-red.png}" id="red"/>
<label for="red" class="me-2">Origin site</label>
<img th:src="@{/assets/images/marker-icon-blue.png}" id="blue"/>
<label for="blue" class="me-2">Collecting site</label>
<img th:src="@{/assets/images/marker-icon-green.png}" id="green"/>
<label for="green" class="me-2">Evaluation site</label>
<img th:src="@{/assets/images/marker-icon-purple.png}" id="purple"/>
<label for="purple">Multi-purpose site</label>
</div>
</div>
......@@ -2,7 +2,7 @@
<html
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/main :: layout(title=~{::title}, content=~{::main})}"
th:replace="~{layout/main :: layout(title=~{::title}, content=~{::main}, script=~{::script})}"
>
<head>
<title>Germplasm: <th:block th:text="${model.germplasm.germplasmName}" /></title>
......@@ -18,6 +18,8 @@
</div>
</div>
<div th:replace="fragments/map::map"></div>
<div class="row align-items-center justify-content-center">
<div class="col-auto field" th:if="${model.germplasm.photo != null && model.germplasm.photo.thumbnailFile != null}">
<template id="photo-popover">
......@@ -414,5 +416,13 @@
<div th:replace="fragments/xrefs::xrefs(crossReferences=${model.crossReferences})"></div>
</main>
<script th:inline="javascript">
faidare.initializePopovers();
faidare.initializeMap({
contextPath: [[${#request.getContextPath()}]],
locations: /*[[${model.mapLocations}]]*/ []
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="fr" th:fragment="layout (title, content)" xmlns:th="http://www.thymeleaf.org">
<html lang="fr" th:fragment="layout (title, content, script)" xmlns:th="http://www.thymeleaf.org">
<head>
<title th:replace="${title}">Layout Title</title>
......@@ -8,6 +8,11 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
<link th:href="@{/assets/style.css}" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="