Skip to content
Snippets Groups Projects
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>';
    }
}