-
Lene Wasskog authoredLene Wasskog authored
observationList.js 22.46 KiB
/*
* Copyright (c) 2013-2023 NIBIO.
*
* This file is part of VIPSWeb
* (see https://gitlab.nibio.no/VIPS/VIPSWeb).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
var allObservations = []; // Populated asynchronously
var drawnFeatures = []; // Populated asynchronously
var currentDate; // Initialized in initMap
var map;
var observationLayer;
/**
* Default coloring
* @type Array
*/
let ageColors = [
[7, 'rgba(240, 86, 77, 1.0)'], // Red #F0564D
[30, 'rgba(228, 183, 1, 1.0)'], // Orange #e4b701
[60, 'rgba(255, 239, 179, 1.0)'], // Light orange #ffefb3
];
let negativeObsAgeColors = [
[7, 'rgba(59, 145, 102, 1.0)'], // Dark Green 40% #3b9166
[30, 'rgba(111, 196, 154, 1.0)'], // Medium green 60% #6FC49A
[60, 'rgba(183, 225, 204, 1.0)'], // Light green 80% #b7e1cc
];
let ageStyles = null;
let negativeObsAgeStyles = null;
var poiDetails, popOverlay;
/*
* Observation map
* @author Tor-Einar Skog <tor-einar.skog@nibio.no>
*/
var initMap = function(
center,
zoomLevel,
organizationId,
from,
to,
pestId,
cropId,
cropCategoryId,
includeNegative,
customAgeColors
)
{
// Allows for dynamic configuration of observation age groups and styles
if(typeof customAgeColors !== 'undefined')
{
ageColors = customAgeColors;
}
initAgeStyles();
// This is the page wide artificial time state of the page
currentDateInMillis = moment(to).format("X") * 1000; // Reference for coloring the observations
// Background layer is OpenStreetMap
var backgroundLayer = new ol.layer.Tile({
source: new ol.source.OSM({
attributions: [
new ol.Attribution({
html: settings.MAP_ATTRIBUTION
})
]
})
});
currentDateInMillis = moment().add(settings.systemTimeOffsetMonths,"months").format("X") * 1000; // Reference for coloring the observations
var observationFeatures = new ol.Collection(); // Starting on empty
observationLayer = new ol.layer.Vector({
source: new ol.source.Vector({
features: observationFeatures
}),
style: getCorrectStyle // Defined further down
});
// Layer for popup
popOverlay = new ol.Overlay({
element: document.getElementById("popover")
});
// Creating the map
map = new ol.Map({
target: 'observationMap',
layers: [backgroundLayer, observationLayer],
overlays: [popOverlay],
renderer: 'canvas'
});
// For some reason, we have to wait with selecting the layer until after the map has
// been initialized. Otherwise the popover will not display.
// OpenLayers is probably doing something that changes the DOM object
// Using Bootstrap's popover plugin. See http://getbootstrap.com/javascript/#popovers
poiDetails = $("#popover");
// Setting zoom and center for the map (need to do this after creating map. so that we kan transform our
// center to correct map projection)
var centerPosition = ol.proj.transform(center, 'EPSG:4326', map.getView().getProjection().getCode());
var view = new ol.View({
center: centerPosition,
zoom:zoomLevel,
maxZoom:7
});
map.setView(view);
// Need to build the query string
var params = [];
params.push("locale=" + settings.currentLanguage);
if(from !== "")
{
params.push("from=" + from);
}
if(to !== "")
{
params.push("to=" + to);
}
if(pestId !== null)
{
params.push("pestId=" + pestId);
}
if(cropId !== null)
{
params.push("cropId=" + cropId);
}
if(cropCategoryId !== null)
{
params.push("cropCategoryId=" + cropCategoryId);
}
if(includeNegative == null || !includeNegative)
{
params.push("isPositive=true");
}
if(settings.userUuid != null)
{
params.push("userUUID=" + settings.userUuid);
}
// Get observations from backend
$.getJSON( "/vipslogicproxy/rest/observation/list/filter/" + organizationId + (params.length > 0 ? "?" + params.join("&") : ""), function( data ) {
allObservations = data;
renderObservationTable(data);
renderObservationFeatures();
});
// Clicking on a part of the map with one or more features will result in
// the observation features being displayed
map.on('singleclick', function(evt) {
var pixel = map.getEventPixel(evt.originalEvent);
var coordinate = map.getEventCoordinate(evt.originalEvent);
displayFeatureDetails(pixel, coordinate);
});
};
/**
* When user click on the map: check if there are observation features at that point,
* and them display information about them in a popup window
*/
var displayFeatureDetails = function(pixel, coordinate) {
var features = [];
map.forEachFeatureAtPixel(pixel, function(feature,layer){
features.push(feature);
});
if (features.length > 0) {
titleHTML = gettext("Observation(s) found at location");
var observationsHTML = "<ul style='list-style-type: none; margin: 0; padding: 0;'>";
var observations = [];
for(var i in features)
{
observations.push(getObservation(parseInt(features[i].get("observationId"))));
}
// Sort observations in descending order
observations.sort(function(a,b){
return a.timeOfObservation >= b.timeOfObservation ? -1 : 1;
});
// Create HTML list with observations
for(var i in observations){
var observation = observations[i];
var obsDate = moment(observation.timeOfObservation).format("YYYY-MM-DD");
observationsHTML += "<li><i style='color: " + getObservationAgeColor(observation.timeOfObservation, observation.isPositive) + ";' class='fa fa-square' aria-hidden='true'></i> [" + obsDate + "] <a href='/observations/" + observation.observationId + "' target='new'>" + observation.organismName + " " + gettext ("in") + " " + observation.cropOrganismName.toLowerCase() + "</a></li>";
}
observationsHTML += "</ul>";
// Position the popup, and hiding it
poiDetails.popover('destroy');
// Placing and displaying the overlay
popOverlay.setPosition(coordinate);
poiDetails.popover({
animation: true,
trigger: 'manual',
html: true,
placement: "auto top",
title: titleHTML,
content: observationsHTML
});
poiDetails.popover('show');
} else {
// If no features at clicked point, hide popup (if any)
poiDetails.popover('destroy');
}
};
/**
* Shows the observations on the map
*/
var renderObservationFeatures = function()
{
var geoJSON = {"type":"FeatureCollection","features":[]};
for(var i=0;i<allObservations.length;i++)
{
var observation = allObservations[i];
var obsFeatures = null;
// Showing only publicly shared observations with geolocation information
if(observation.locationIsPrivate)
{
continue;
}
// Have we got geoInfo to show?
if(observation.geoInfo !== null && observation.geoInfo.trim() !== "")
{
obsFeatures = JSON.parse(observation.geoInfo).features;
if(obsFeatures !== null)
{
// We have parsed geoinfo successfully.
// If the observation has registered a polygonService,
// that means that the location should be masked by this
// service (for privacy reasons). Otherwise: Add as-is
//if(observation.polygonService !== undefined && observation.polygonService !== null)
//{
// maskedFeatures.push(obsFeatures[0]); // Using only first feature for simplicity
//}
//else
//{
for(var j=0; j<obsFeatures.length; j++)
{
geoJSON.features.push(obsFeatures[j]);
}
//}
}
}
}
var format = new ol.format.GeoJSON();
drawnfeatures = format.readFeatures(geoJSON, {
dataProjection: 'EPSG:4326',
featureProjection: map.getView().getProjection().getCode()
});
observationLayer.getSource().clear();
observationLayer.getSource().addFeatures(drawnfeatures);
//console.info(maskedFeatures);
}
/**
* Pull the observation from global list
*/
var getObservation = function(observationId)
{
for(var i=0; i<allObservations.length;i++)
{
if(allObservations[i].observationId == parseInt(observationId))
{
return allObservations[i];
}
}
//console.info("Could not find observation with ID=" + observationId)
return null;
};
/**
* Show the table of observations
*/
var renderObservationTable = function(data)
{
var tbody = document.getElementById("observationTableBody");
var tbodyHTML = [];
for(var i=0; i<data.length;i++)
{
var obs = data[i];
var dSchema = JSON.parse(obs.observationDataSchema["dataSchema"])
var obsData = JSON.parse(obs.observationData)
/**
* HashMap for values in the dataSchema
*/
var dSchemaVal = {}
Object.keys(dSchema['properties']).forEach(function(key) {
dSchemaVal[key] = dSchema['properties'][key]['title']
});
/**
* HashMap over measures values
*/
var measuredVal = {}
if(obsData != null){
Object.keys(obsData).forEach(function (key) {
measuredVal[key] = obsData[key]
})
}
/**
* Create array that contains value for pop-up
* If no measured data default is used.
*/
var printVal = []
Object.keys(dSchemaVal).forEach(function (key) {
if(Object.keys(measuredVal).length === 0){
return;
} else {
Object.keys(measuredVal).forEach(function (defaultKey) {
if(key == defaultKey){
printVal.push(dSchemaVal[key] + ": " + measuredVal[defaultKey])
}
})
}
})
tbodyHTML.push("<tr>");
tbodyHTML.push("<td>" + moment(obs.timeOfObservation).format("YYYY-MM-DD") + "</td>");
tbodyHTML.push("<td>" + obs.organismName + "</td>");
tbodyHTML.push("<td>" + obs.cropOrganismName + "</td>");
tbodyHTML.push("<td>" + obs.observationHeading + "</td>");
if (obs.observationTimeSeriesId) {
tbodyHTML.push(
"<td><a href='/observations/timeseries/" + obs.observationTimeSeriesId + "' target='new'><i class='fa fa-list'/></a></td>"
);
} else {
tbodyHTML.push("<td></td>");
}
if(Object.keys(measuredVal).length != 0){
tbodyHTML.push("<td><a tabindex='0' class='btn btn-lg' role='button' data-toggle='popover' data-placement='left' data-trigger='focus' data-html='true' data-content='" + printVal.join("<br/>") + "' ><i class='fa fa-balance-scale' aria-hidden='true' </i></a></td>")
} else{
tbodyHTML.push("<td></td>")
}
tbodyHTML.push("<td><a href='/observations/" + obs.observationId + "' target='new'>" + gettext("Details") + "</a></td>");
tbodyHTML.push("</tr>");
}
tbody.innerHTML = tbodyHTML.join("\n");
$(tbody) > $('[data-toggle="popover"]').popover();
};
/**
* Render the select field for pest or crop
*/
var renderOrganismField = function(organismList, fieldId, selectedId)
{
// Sort alphabetically by local name
organismList.sort(function(a,b){
if (getLocalizedOrganismName(a) < getLocalizedOrganismName(b)) return -1;
if (getLocalizedOrganismName(a) > getLocalizedOrganismName(b)) return 1;
return 0;
});
var list = document.getElementById(fieldId);
list.options.length=0;
list.options[0] = new Option("",""); // For the chosenjs to print data-placeholder
for(var i=0;i<organismList.length;i++)
{
var organism = organismList[i];
var newOption = new Option(getLocalizedOrganismName(organism),organism.organismId);
if(organism.organismId === selectedId)
{
newOption.selected = true;
}
list.options[list.options.length] = newOption;
}
};
/**
* Render the crop category select field
*/
var renderCropCategoryField = function(cropCategoryList, selectedId)
{
// Sort by local name
cropCategoryList.sort(function(a,b){
if (getLocalizedCropCategoryName(a) < getLocalizedCropCategoryName(b)) return -1;
if (getLocalizedCropCategoryName(a) > getLocalizedCropCategoryName(b)) return 1;
return 0;
});
var list = document.getElementById("cropCategoryList");
list.options.length=0;
list.options[0] = new Option("",""); // For the chosenjs to print data-placeholder
for(var i=0;i<cropCategoryList.length;i++)
{
var cropCategory = cropCategoryList[i];
var newOption = new Option(getLocalizedCropCategoryName(cropCategory),cropCategory.cropCategoryId);
if(cropCategory.cropCategoryId === selectedId)
{
newOption.selected = true;
}
list.options[list.options.length] = newOption;
}
};
var initForm = function(organizationId,
pestId,
cropId,
cropCategoryId,
postRenderFormActions
)
{
$.getJSON( "/vipslogicproxy/rest/observation/pest/" + organizationId , function( pestList ) {
renderOrganismField(pestList, "observationPestList", pestId);
$.getJSON( "/vipslogicproxy/rest/observation/crop/" + organizationId , function( cropList ) {
renderOrganismField(cropList, "observationCropList", cropId);
$.getJSON( "/vipslogicproxy/rest/organism/cropcategory/" + organizationId , function( cropCategoryList ) {
renderCropCategoryField(cropCategoryList, cropCategoryId);
postRenderFormActions(); // Activate chosen.js
});
});
});
};
// Global configs
/**
* Default style for old (outdated) observations
*/
var styleOld =
new ol.style.Style({
fill: new ol.style.Fill({
color: 'rgba(173, 173, 173, 0.5)'
}),
stroke: new ol.style.Stroke({
color: '#000000',
width: 1
}),
image: new ol.style.Circle({
radius: 6,
fill: new ol.style.Fill({
color: '#adadad'
}),
stroke: new ol.style.Stroke({
color: '#000000',
width: 1
})
})
});
/**
* If an observation should be hidden entirely
*/
var styleInvisible = new ol.style.Style({
fill: new ol.style.Fill({
color: 'rgba(0, 0, 0, 0.0)'
}),
stroke: new ol.style.Stroke({
color: 'rgba(0, 0, 0, 0.0)',
width: 0
}),
image: new ol.style.Circle({
radius: 0,
fill: new ol.style.Fill({
color: 'rgba(0, 0, 0, 0.0)'
})
})
});
/**
* Using config at top of file (or customAgeColors passed in from web page) for dynamic style creation
*/
var initAgeStyles = function(){
ageStyles = [];
for(var i in ageColors)
{
ageStyles.push([
ageColors[i][0],
new ol.style.Style({
fill: new ol.style.Fill({
color: ageColors[i][1].replace("1.0","0.2")
}),
stroke: new ol.style.Stroke({
color: ageColors[i][1],
width: 1
}),
image: new ol.style.Circle({
radius: 6,
fill: new ol.style.Fill({
color: ageColors[i][1]
}),
stroke: new ol.style.Stroke({
color: '#000000',
width: 1
})
})
})
]);
}
negativeObsAgeStyles = [];
for(var i in negativeObsAgeColors)
{
negativeObsAgeStyles.push([
negativeObsAgeColors[i][0],
new ol.style.Style({
fill: new ol.style.Fill({
color: negativeObsAgeColors[i][1].replace("1.0","0.2")
}),
stroke: new ol.style.Stroke({
color: negativeObsAgeColors[i][1],
width: 1
}),
image: new ol.style.Circle({
radius: 6,
fill: new ol.style.Fill({
color: negativeObsAgeColors[i][1]
}),
stroke: new ol.style.Stroke({
color: '#ffffff',
width: 1
})
})
})
]);
}
};
/**
* Based on the observation's age relative to "currentDate", choose the appropriate style for map rendering
*/
var getCorrectStyle = function(feature){
var age = getObservationRelativeAge(feature);
if(age == null)
{
return;
}
if(age < 0)
{
return styleInvisible;
}
if(feature.get("isPositive"))
{
for(var i in ageStyles)
{
if(age < ageStyles[i][0])
{
return ageStyles[i][1];
}
}
}
else
{
for(var i in negativeObsAgeStyles)
{
if(age < negativeObsAgeStyles[i][0])
{
return negativeObsAgeStyles[i][1];
}
}
}
return styleOld;
};
/**
* Returns the observation's age in days relative to the "current date", which is defined by the value of the "dayInPeriod" range input
*/
var getObservationRelativeAge = function(feature)
{
observationAge = feature.get("timestamp");
return Math.floor(((currentDateInMillis - observationAge) / (1000 * 60 * 60 * 24)) + 1);
};
/**
* Using configured ageColors
* @param aDate
* @returns
*/
function getObservationAgeColor(aDate, isPositive)
{
var age = getDaysSince(aDate);
if(age == null)
{
return;
}
if(age < 0)
{
return styleInvisible.getFill().getColor();
}
let colors = (isPositive == false && isPositive != undefined) ? negativeObsAgeColors : ageColors;
for(var i in colors)
{
if(age < colors[i][0])
{
return colors[i][1];
}
}
return styleOld.getFill().getColor();
}
/**
* Using current system time, counting days since this day
*/
var getDaysSince = function(JSONDate)
{
return Math.floor((currentDateInMillis - getUnixTimestampFromJSON(JSONDate)) / (1000 * 60 * 60 * 24)) + 1;
};
/**
* Sets colors and values for the map legend
* @returns
*/
function initMapLegend(includeNegative)
{
var lBox = document.getElementById("legend");
var html = "<div><strong>" + gettext("Days since observation") + "</strong></div><ul>";
for(var i in ageColors)
{
html += '<li><i style="color: ' + ageColors[i][1] + ';" class="fa fa-square" aria-hidden="true"></i>' + (includeNegative ? '/<i style="color: ' + negativeObsAgeColors[i][1] + ';" class="fa fa-square" aria-hidden="true"></i> ' : ' ') + (i > 0 ? ageColors[i-1][0] + 1 : '0') + '-' + ageColors[i][0] + ' ' + gettext("Days").toLowerCase() + '</li>';
}
html += '<li><i style="color: black;" class="fa fa-square" aria-hidden="true"></i>' + (includeNegative ? '/<i style="color: black;" class="fa fa-square-o" aria-hidden="true"></i> ' : ' ') + gettext("Older") + '</li>';
html += "</ul>";
lBox.innerHTML = html;
}
var from = moment(document.getElementById("dateFrom").value);
/**
* Sets the current date in the observation "player"
* @param rangeBar
* @returns
*/
function updateCurrentDate(rangeBar){
currentDate = from.clone();
currentDate.add(parseInt(rangeBar.value)-1,"days");
document.getElementById("dayInPeriodDate").innerHTML=currentDate.format('YYYY-MM-DD');
currentDateInMillis = currentDate.format("X") * 1000;
observationLayer.getSource().changed();
//console.info(currentDate);
}
/**
* Updates the map observations given the date
* @param rangeBar
* @returns
*/
function updateMap(rangeBar){
currentDate = from.clone();
currentDate.add(parseInt(rangeBar.value)-1,"days");
currentDateInMillis = currentDate.format("X") * 1000;
observationLayer.getSource().changed();
}
/**
* Moves everything one day forward
*/
var moveCurrentDayForward = function(){
theRange = document.getElementById("dayInPeriod");
if(parseInt(theRange.value) < theRange.max)
{
theRange.value = parseInt(theRange.value) +1;
theRange.oninput();
}
else{
togglePlay(document.getElementById("playButton"));
}
}
// Placeholder for the JS timer function called for moving time ("dayInPeriod" range input) forward
var intervalId = null; // See below
/**
* Switches between play and pause
* @param theButton
* @returns
*/
function togglePlay(theButton){
if(intervalId == null)
{
intervalId = setInterval(moveCurrentDayForward, 250);
theButton.innerHTML = '<i class="fa fa-pause" aria-hidden="true"></i>';
}
else
{
clearInterval(intervalId);
intervalId = null;
theButton.innerHTML = '<i class="fa fa-play" aria-hidden="true"></i>';
}
}