1/* 2 * Copyright (c) 2013-2023 NIBIO. 3 * 4 * This file is part of VIPSWeb 5 * (see https://gitlab.nibio.no/VIPS/VIPSWeb). 6 * 7 * This program is free software: you can redistribute it and/or modify 8 * it under the terms of the GNU Affero General Public License as 9 * published by the Free Software Foundation, either version 3 of the 10 * License, or (at your option) any later version. 11 * 12 * This program is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU Affero General Public License for more details. 16 * 17 * You should have received a copy of the GNU Affero General Public License 18 * along with this program. If not, see <http://www.gnu.org/licenses/>. 19 * 20 */ 21 22/** 23 * @copyright 2018 <a href="http://www.nibio.no/">NIBIO</a> 24 * @author Tor-Einar Skog <[email protected]> 25 */ 26 27/* ----------------------- Global elements ------------------- */ 28var map; 29var FORECAST_LAYER = "forecastLayer"; 30var OBSERVATION_LAYER = "observationLayer"; 31//Using Bootstrap's popover plugin. See http://getbootstrap.com/javascript/#popovers 32var poiDetails = $("#popover"); 33//Using Bootstrap's tooltip plugin. See http://getbootstrap.com/javascript/#tooltips 34var poiTitle = $('#tooltip'); 35poiTitle.tooltip({ 36 animation: false, 37 trigger: 'manual', 38 html: false 39}); 40//Layer for popup 41var popOverlay = new ol.Overlay({ 42 element: document.getElementById("popover") 43}); 44//State variable 45var currentClickedFeature = false; 46var maxMapZoomForObservations = 7; 47var maxMapZoomForForecasts = 10; 48 49/* ------------- Forecasts related ------------------------------------- */ 50var forecastLayer; 51/* ------------------------------------------------------------------------ */ 52 53/* ------------- Observations related ------------------------------------- */ 54//var allObservations = []; // Populated asynchronously 55var drawnFeatures = []; // Populated asynchronously 56var observationLayer; 57var currentDateInMillis; 58/* ------------------------------------------------------------------------ */ 59 60/* - Relax restrictions on HTML in popup (https://getbootstrap.com/docs/3.4/javascript/#js-sanitizer) - */ 61var myWhiteList = $.fn.tooltip.Constructor.DEFAULTS.whiteList; 62myWhiteList.table=[]; 63myWhiteList.tbody=[]; 64myWhiteList.tr=["onclick"]; 65myWhiteList.td=["colspan"]; 66myWhiteList.span=["onclick"] 67 68/* ------------------------------------------------------------------------ */ 69 70/** 71 * Initializes the front page map, including layers for forecasts and observations 72 * @param {ol.Coordinate} lonLat - coordinates for the map's center (WGS84) 73 * @param {int} zoom - the zoom level (1-15, 1 is world wide view, 15 is greatest zoom) 74 * @param {string} mapAttribution - The text in the corner of the map giving credits where it's due 75 */ 76function initFrontpageMap(lonLat, zoomLevel, mapAttribution) 77{ 78 79 if(settings.vipslogicServerName === undefined) 80 { 81 alert(gettext("Source hostname not defined.")); 82 return; 83 } 84 85 // ---------- Background layer is OpenStreetMap -------------- 86 var backgroundLayer = new ol.layer.Tile({ 87 source: new ol.source.OSM({ 88 attributions: [mapAttribution] 89 }) 90 }); 91 // Bing Maps example 92 /*var backgroundLayer = new ol.layer.Tile({ 93 source: new ol.source.BingMaps({ 94 key: 'AgBNAZzyzjb-tmdpNGxAQGtvVQryg5Vm3ZekaYVk2iwWNmu41CVxdPYjTXrKQ9ac', 95 // imagerySet: "Aerial" 96 //imagerySet: 'Road' 97 imagerySet: "AerialWithLabels" 98 }) 99 }) 100 */ 101 102 // ----------- The forecast layer is a KML layer, pure and simple -------- 103 forecastLayer = new ol.layer.Vector({ 104 source: null 105 }); 106 107 // ------------ The observations layer (GeoJSON) --------------------- 108 currentDateInMillis = moment().add(settings.systemTimeOffsetMonths,"months").format("X") * 1000; // Reference for coloring the observations 109 var observationFeatures = new ol.Collection(); // Starting on empty 110 initAgeStyles(); 111 observationLayer = new ol.layer.Vector({ 112 source: new ol.source.Vector({ 113 features: observationFeatures 114 }), 115 style: getCorrectStyle // Defined further down 116 }); 117 118 initObservationLegend(); 119 120 // Creating the map 121 map = new ol.Map({ 122 target: 'map', 123 layers: [backgroundLayer, forecastLayer, observationLayer], 124 overlays: [popOverlay], 125 renderer: 'canvas', 126 view: new ol.View({ 127 center: ol.proj.fromLonLat(lonLat, 'EPSG:3857'), 128 zoom:zoomLevel, 129 maxZoom: getSelectedMapLayer() == FORECAST_LAYER ? maxMapZoomForForecasts : maxMapZoomForObservations 130 }) 131 }); 132 133 // Getting data and rendering the selected layer 134 selectMapLayer(getSelectedMapLayer()); 135 /*if(getSelectedMapLayer() == FORECAST_LAYER) 136 { 137 updateForecastLayers(); 138 } 139 else 140 { 141 initObservationLayer(); 142 }*/ 143 144 // Attempting to adjust map to small screen 145 if(map.getSize()[0] < 500) 146 { 147 var zoom = map.getView().getZoom(); 148 map.getView().setZoom(zoom -1 ); 149 } 150 151 // ---------- MAP EVENTS ------------ 152 153 // On mouseover, display name of POI in tooltip 154 // TODO: Does this work? Doesn't seem so 155 map.on('mousemove', (evt) => { 156 var pixel = map.getEventPixel(evt.originalEvent); 157 displayFeatureName(pixel); 158 }); 159 160 // On click, display forecasts in popup 161 map.on('singleclick', (evt) => { 162 var pixel = map.getEventPixel(evt.originalEvent);
163 var coordinate = map.getEventCoordinate(evt.originalEvent); 164 displayFeatureDetails(pixel, coordinate); 165 }); 166 167 map.on('moveend', (evt) => { 168 updateForecastSummaries(); 169 // Store map settings 170 var centerCoordinate = ol.proj.transform(map.getView().getCenter(), map.getView().getProjection().getCode(),'EPSG:4326'); 171 var settings = { 172 "longitude": centerCoordinate[0], 173 "latitude": centerCoordinate[1], 174 "zoomLevel": getCurrentMapZoomLevel() 175 }; 176 storeLocalSettings(settings); 177 }); 178 179 //For testing: adding mouseposition geographic control 180 /* 181 var mousePositionControl = new ol.control.MousePosition({ 182 coordinateFormat: ol.coordinate.createStringXY(4), 183 projection: 'EPSG:4326', 184 undefinedHTML: ' ' 185 }); 186 187 map.addControl(mousePositionControl); 188 */ 189 190} 191 192/** 193 * Error handling when collecting forecast information from server fails 194 * TODO: Not in use! 195 */ 196var handleAjaxError = function(jqXHR,textStatus,errorThrown){ 197 var message = jqXHR.responseJSON !== undefined ? jqXHR.responseJSON.message : jqXHR.responseText; 198 alert ("Error: " + message); 199}; 200 201/** 202 * Initiated on mouseover (? doesn't seem like it does) 203 * Displays station name in a tooltip box 204 */ 205var displayFeatureName = function(pixel) { 206 poiTitle.css({ 207 left: pixel[0] + 'px', 208 top: (pixel[1] - 15) + 'px' 209 }); 210 var feature = map.forEachFeatureAtPixel(pixel, function(feature,layer){ 211 return feature; 212 }); 213 var title = poiTitle.data("bs.tooltip").options.title; 214 if (feature && feature != currentClickedFeature) { 215 poiTitle.tooltip('hide'); 216 poiTitle.data("bs.tooltip").options.title = feature.get("name"); 217 poiTitle.tooltip('show'); 218 } 219 else 220 { 221 if(! feature) 222 { 223 poiTitle.tooltip('hide'); 224 } 225 } 226 227}; 228 229/** 230 * Displays a feature(s) detail popup IF there is at least one feature where the user clicked 231 * @param pixel the point on the map that was clicked 232 * @param coordinate 233 */ 234var displayFeatureDetails = function(pixel, coordinate) { 235 // Collect the features 236 var features = []; 237 map.forEachFeatureAtPixel(pixel, function(feature,layer){ 238 features.push(feature) 239 }); 240 241 if(features.length > 0) 242 { 243 if(getSelectedMapLayer() == FORECAST_LAYER) 244 { 245 displayForecastDetails(features, coordinate); 246 } 247 else 248 { 249 displayObservationDetails(features, coordinate); 250 } 251 } 252 else 253 { 254 currentClickedFeature = null; 255 poiDetails.popover('destroy'); 256 } 257}; 258 259/** 260 * Displays details about a forecast 261 */ 262var displayForecastDetails = function(features, coordinate){ 263 // Assuming that there is only one feature being clicked 264 var feature = features[0]; 265 266 // Position the popup, and hiding it 267 // Resetting information from (possible) former popups 268 var geometry = feature.getGeometry(); 269 popOverlay.setPosition(coordinate); 270 poiTitle.tooltip('hide'); 271 poiDetails.popover('destroy'); 272 currentClickedFeature = feature; 273 var summaries = getForecastSummariesForPoi(feature.getId()); 274 var summariesHTML = ""; 275 if(summaries.length > 0) 276 { 277 summariesHTML = "<table class=\"table-responsive\" id=\"forecastSummariesPopoverTable\">" + 278 getforecastSummariesTableHTML(summaries,true) 279 + "</table>"; 280 } 281 else 282 { 283 summariesHTML = "<p>" + gettext("No forecasts found for selected crops") + "</p>"; 284 } 285 var externalResourceHTML = ""; 286 var externalResources = getExternalResourcesForPoi(feature.getId()); 287 // These external resources have been integrated into the icons and summaries and forecast details page, so we skip them here to avoid confusion 288 for(var i in externalResources){ 289 if(settings.externalResourcesToBeIgnoredInFrontend.includes(externalResources[i].pointOfInterestExternalResourcePK.externalResourceId)) 290 { 291 continue; 292 } 293 var resourceLink = externalResources[i].externalResourceUrl; 294 var resourceName = externalResources[i].externalResource.name; 295 externalResourceHTML += "<li><a href=\"" + resourceLink + "\" target=\"new\">" + resourceName + "</a></li>"; 296 } 297 298 if(externalResourceHTML != "") 299 { 300 externalResourceHTML = "<h3>" + gettext("External resources") + "</h2><ul class=\"popoverExternalResource\">" 301 + externalResourceHTML 302 + "</ul>"; 303 } 304 //console.log(feature);
305 poiDetails.popover({ 306 animation: true, 307 trigger: 'manual', 308 html: true, 309 placement: "auto top", 310 title: feature.get("stationName") + (feature.get("infoUri") != undefined ? 311 " <a href='" + feature.get("infoUri") + "' target='new'><span class='wi wi-day-cloudy'></span></a>" 312 :""), 313 content: summariesHTML + externalResourceHTML 314 }); 315 316 poiDetails.popover('show'); 317}; 318 319/** 320 * Displays details about one or more observations 321 */ 322var displayObservationDetails = function(features, coordinate) { 323 var titleHTML = gettext("Observation(s) found at location"); 324 var observationsHTML = "<ul style='list-style-type: none;margin: 0; padding: 0;'>"; 325 326 var observations = []; 327 for(var i in features) 328 { 329 observations.push(getObservation(parseInt(features[i].get("observationId")))); 330 } 331 // Sort observations in descending order 332 observations.sort(function(a,b){ 333 return a.timeOfObservation >= b.timeOfObservation ? -1 : 1; 334 }); 335 for(var i in observations){ 336 var observation = observations[i]; 337 var obsDate = moment(observation.timeOfObservation).format("YYYY-MM-DD"); 338 observationsHTML += "<li><i style='color: " + getObservationAgeColor(observation.timeOfObservation) + ";' 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>"; 339 } 340 observationsHTML += "</ul>"; 341 //console.info(features[0].get("observationId")); 342 //console.info(coordinate); 343 // Removing whatever was visible previously 344 poiTitle.tooltip('hide'); 345 poiDetails.popover('destroy'); 346 // Placing and displaying the overlay 347 popOverlay.setPosition(coordinate); 348 poiDetails.popover({ 349 animation: true, 350 trigger: 'manual', 351 html: true, 352 placement: "auto top", 353 title: titleHTML, 354 content: observationsHTML 355 }); 356 357 poiDetails.popover('show'); 358}; 359 360/** 361 * Given the layer Id, displays the correct map layer 362 * @param layerId the Id (string) of the layer to be selected 363 */ 364var selectMapLayer = function(layerId) 365{ 366 // Remove feature popup 367 currentClickedFeature = null; 368 poiDetails.popover('destroy'); 369 370 // Adjust zoom if necessary 371 map.getView().setMaxZoom(layerId == FORECAST_LAYER ? maxMapZoomForForecasts : maxMapZoomForObservations); 372 373 // Set layer and legend visibility 374 forecastLayer.setVisible(layerId == FORECAST_LAYER); // Constant at top of this file 375 document.getElementById("warningLegend").style.display = layerId == FORECAST_LAYER ? "block" : "none"; 376 observationLayer.setVisible(layerId == OBSERVATION_LAYER); // Constant at top of this file 377 document.getElementById("observationLegend").style.display = layerId == OBSERVATION_LAYER ? "block" : "none"; 378 379 // Store selection for later sessions 380 storeSelectedMapLayer(layerId); 381 382 // Init features for selected layer (if it hasn't already been done) 383 if(layerId == FORECAST_LAYER && (forecastLayer == null || forecastLayer == undefined || forecastLayer.getSource() == null)) 384 { 385 updateForecastLayers(); 386 } 387 388} 389 390/** 391 * 392 * @returns the current zoom level of the map 393 */ 394function getCurrentMapZoomLevel() 395{ 396 return map.getView().getZoom(); 397} 398 399 400 401/** 402 * Loads new KML info from backend, renders forecast layer 403 */ 404function updateForecastLayers() 405{ 406 map.removeLayer(forecastLayer); 407 var cropCategoryIdStr = buildPathParamString("cropCategoryId", getSelectedCropCategoryIds()); 408 // If empty, make sure we send a number anyway 409 if(cropCategoryIdStr === "") 410 { 411 cropCategoryIdStr = "&cropCategoryId=-1"; 412 } 413 414 forecastLayer = new ol.layer.Vector({ 415 source: new ol.source.Vector({ 416 //url: settings.vipslogicProtocol + "://" + settings.vipslogicServerName + "/rest/forecastresults/aggregate/" + settings.vipsOrganizationId + "?" + cropCategoryIdStr, 417 url: "/vipslogicproxy/rest/forecastresults/aggregate/" + 418 ( 419 settings.includeOrganizationIds != null && settings.includeOrganizationIds.length > 0 ? 420 "orgspan?organizationId=" + settings.includeOrganizationIds.join("&organizationId=") + cropCategoryIdStr 421 : settings.vipsOrganizationId + "?" + cropCategoryIdStr 422 ) 423 + 424 ( 425 settings.userUuid != null ? "&userUUID=" + settings.userUuid : "" 426 ) 427 , 428 format: new ol.format.KML(), 429 projection: ol.proj.get('EPSG:3857') 430 }) 431 }); 432 forecastLayer.setVisible(getSelectedMapLayer() == FORECAST_LAYER); // Constant at top of this file 433 map.addLayer(forecastLayer); 434} 435 436 437 438 439/** 440 * Rendering the filteredObservations 441 */ 442var renderObservationFeatures = function(){ 443 var geoJSON = {"type":"FeatureCollection","features":[]}; 444 for(var i=0;i<filteredObservations.length;i++) 445 { 446 var observation = filteredObservations[i]; 447 var obsFeatures = null; 448 if(!observation.locationIsPrivate && observation.geoInfo !== null && observation.geoInfo.trim() !== "") 449 { 450 obsFeatures = JSON.parse(observation.geoInfo).features; 451 } 452 else 453 { 454 continue; 455 } 456 457 for(var j=0; j<obsFeatures.length; j++) 458 { 459 geoJSON.features.push(obsFeatures[j]); 460 } 461 462 } 463 464 var format = new ol.format.GeoJSON(); 465 drawnfeatures = format.readFeatures(geoJSON, { 466 dataProjection: 'EPSG:4326', 467 featureProjection: map.getView().getProjection().getCode() 468 }); 469 observationLayer.getSource().clear(); 470 observationLayer.getSource().addFeatures(drawnfeatures); 471} 472 473/** 474 * 475 * @param coordinate [longitude,latitude] 476 * @param projection 477 * @returns boolean 478 */ 479function isCoordinateOnVisibleMap(coordinate, projection){ 480 var mapExtent = map.getView().calculateExtent(map.getSize()); 481 var coordinate = ol.proj.transform(coordinate, 'EPSG:4326', map.getView().getProjection().getCode()) 482 return ol.extent.containsCoordinate(mapExtent,coordinate); 483} 484 485/** 486 * Get the correct style for an observation feature, based on observation age 487 * @param feature the ol feature object 488 */ 489var getCorrectStyle = function(feature){ 490 var age = getObservationFeatureRelativeAge(feature); 491 if(age == null) 492 { 493 return; 494 } 495 if(age < 0) 496 { 497 return styleInvisible; 498 } 499 for(var i in ageStyles) 500 { 501 if(age < ageStyles[i][0]) 502 { 503 return ageStyles[i][1]; 504 } 505 } 506 return styleOld; 507}; 508 509/** 510 * How many days old is the observation connected to this map feature? 511 */ 512var getObservationFeatureRelativeAge = function(feature) 513{ 514 var observation = getObservation(feature.get("observationId")); // TODO include observations timestamp in feature. Speeds things up 515 if(observation == null) 516 { 517 //console.info("No obs with id=" + feature.get("observationId")); 518 return null; // Means invisible 519 } 520 return getDaysSince(observation.timeOfObservation); 521}; 522 523/** 524 * Using current system time, counting days since this day 525 */ 526var getDaysSince = function(JSONDate) 527{ 528 return Math.floor((currentDateInMillis - getUnixTimestampFromJSON(JSONDate)) / (1000 * 60 * 60 * 24)) + 1; 529}; 530 531/** 532 * Pull the observation from global list 533 */ 534var getObservation = function(observationId) 535{ 536 for(var i=0; i<allObservations.length;i++) 537 { 538 if(allObservations[i].observationId == parseInt(observationId)) 539 { 540 return allObservations[i]; 541 } 542 } 543 //console.info("Could not find observation with ID=" + observationId) 544 return null; 545}; 546 547/** 548 * Using config at top of file for dynamic style creation 549 */ 550var initAgeStyles = function(){ 551 ageStyles = []; 552 for(var i in ageColors) 553 { 554 ageStyles.push([ 555 ageColors[i][0], 556 new ol.style.Style({ 557 fill: new ol.style.Fill({ 558 color: ageColors[i][1].replace("1.0","0.2") 559 }), 560 stroke: new ol.style.Stroke({ 561 color: ageColors[i][1], 562 width: 2 563 }), 564 image: new ol.style.Circle({ 565 radius: 6, 566 fill: new ol.style.Fill({ 567 color: ageColors[i][1] 568 }), 569 stroke: new ol.style.Stroke({ 570 color: '#000000', 571 width: 1 572 }) 573 }) 574 }) 575 ]); 576 } 577}; 578 579/** 580 * Dynamic init of the observation legend 581 * @returns 582 */ 583function initObservationLegend() 584{ 585 var lBox = document.getElementById("observationLegend"); 586 var html = "<div id='observationLegendHeading'>" + gettext("Days since observation") + "</div><ul>"; 587 for(var i in ageColors) 588 { 589 html += '<li><i style="color: ' + ageColors[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>'; 590 } 591 html += '<li><i style="color: ' + styleOld.getImage().getFill().getColor() + ';" class="fa fa-square" aria-hidden="true"></i> ' + gettext("Older") + '</li>'; 592 html += "</ul>"; 593 lBox.innerHTML = html; 594} 595 596/** 597 * Using configured ageColors 598 * @param aDate 599 * @returns 600 */ 601function getObservationAgeColor(aDate) 602{ 603 var age = getDaysSince(aDate); 604 if(age == null) 605 { 606 return; 607 } 608 if(age < 0) 609 {
610 return styleInvisible.getFill().getColor(); 611 } 612 for(var i in ageColors) 613 { 614 if(age < ageColors[i][0]) 615 { 616 return ageColors[i][1]; 617 } 618 } 619 return styleOld.getFill().getColor(); 620} 621 622/** 623 * Default coloring 624 * @type Array 625 */ 626var ageColors = [ 627 [7, 'rgba(233, 13, 0, 1.0)'], // Red #e90d00 628 [30, 'rgba(253, 202, 00, 1.0)'], // Orange #fdca00 629 [60, 'rgba(0, 120, 253, 1.0)'], // Blue #0078fd 630]; 631 632//Global configs 633var styleOld = 634 new ol.style.Style({ 635 fill: new ol.style.Fill({ 636 color: 'rgba(173, 173, 173, 0.5)' 637 }), 638 stroke: new ol.style.Stroke({ 639 color: '#000000', 640 width: 1 641 }), 642 image: new ol.style.Circle({ 643 radius: 6, 644 fill: new ol.style.Fill({ 645 color: '#adadad' 646 }), 647 stroke: new ol.style.Stroke({ 648 color: '#000000', 649 width: 1 650 }) 651 }) 652 }); 653 var styleInvisible = new ol.style.Style({ 654 fill: new ol.style.Fill({ 655 color: 'rgba(0, 0, 0, 0.0)' 656 }), 657 stroke: new ol.style.Stroke({ 658 color: 'rgba(0, 0, 0, 0.0)', 659 width: 0 660 }), 661 image: new ol.style.Circle({ 662 radius: 0, 663 fill: new ol.style.Fill({ 664 color: 'rgba(0, 0, 0, 0.0)' 665 }) 666 }) 667 }); 668
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.