diff --git a/ipmd/static/ipmd/js/ipmdlib.js b/ipmd/static/ipmd/js/ipmdlib.js index c270c2750307b30b8c213db2b4454d5d16eb068d..8536862f8aa882cccc31b4f6b3a0c4a79ef163ff 100644 --- a/ipmd/static/ipmd/js/ipmdlib.js +++ b/ipmd/static/ipmd/js/ipmdlib.js @@ -18,16 +18,26 @@ */ /** + * Utilities and methods for using the IPM Decisions DSS and Weather APIs + * * Dependencies: * momentJS - * turf v6 + * turf 6 + * OpenLayers 4 */ +// TODO Refactor these to be set by the user of this library const ipmdDSSApiURL = "https://platform.ipmdecisions.net/api/dss/"; //const ipmdWeatherApiURL = "https://platform.ipmdecisions.net/api/wx/"; const ipmdWeatherApiURL = "http://ipmdlocal/api/wx/"; +/** + * + * @param {String} dssId The id of the DSS + * @param {String} modelId The id of the DSS model + * @returns {Json} metadata for the requested DSS model + */ async function getModelMetadata(dssId,modelId) { const response = await fetch(ipmdDSSApiURL + "rest/dss/" + dssId); const dss = await response.json(); @@ -42,22 +52,33 @@ async function getModelMetadata(dssId,modelId) { return null; } +/** + * + * @param {String} dssId The id of the DSS + * @param {String} modelId The id of the DSS model + * @returns the UI form components of the Json schema for input data for the requested model. Hidden fields are excluded. + */ async function getModelInputSchema(dssId,modelId) { const response = await fetch(ipmdDSSApiURL + "rest/model/" + dssId + "/" + modelId + "/input_schema/ui_form"); return await response.json(); } -/*async function getWeatherDatasource(weatherDatasourceId) -{ - const response = await fetch(ipmdWeatherApiURL + "rest/weatherdatasource/" + weatherDatasourceId); - return await response.json(); -}*/ - +/** + * + * @param {Json} weatherDatasource + * @returns The web service endpoint for the weather data source + */ function getWeatherDatasourceEndpoint(weatherDatasource) { return weatherDatasource.endpoint.replace("{WEATHER_API_URL}",ipmdWeatherApiURL); } +/** + * + * @param {Json} weatherDatasources The list of weather data sources + * @param {String} weatherDatasourceId the id of the requested weather data source + * @returns {Json} Metadata for the requested weather datasource + */ function getWeatherDatasource(weatherDatasources, weatherDatasourceId) { for(let i=0;i<weatherDatasources.length;i++) @@ -70,12 +91,21 @@ function getWeatherDatasource(weatherDatasources, weatherDatasourceId) return null; } +/** + * + * @returns {Json} list of all available weather datasources + */ async function getWeatherDatasources() { const response = await fetch(ipmdWeatherApiURL + "rest/weatherdatasource/"); return await response.json(); } +/** + * + * @param {String} weatherDatasource + * @returns {Array} Weather stations listed as part of this weather data source. Example list item: {"id":"5", "name":"Merrapanna"} + */ function getWeatherStationList(weatherDatasource){ geoJson = JSON.parse(weatherDatasource.spatial.geoJSON); let stationList = []; @@ -91,6 +121,11 @@ function getWeatherStationList(weatherDatasource){ return stationList; } +/** + * List all weather stations in list in the provided HTML Form select list + * @param {Array} stationList + * @param {HTMLElement} selectList + */ function renderWeatherStationSelectList(stationList, selectList){ selectList.options.length = 0; selectList.add(new Option("-- Please select weather station --", -1)); @@ -100,6 +135,16 @@ function renderWeatherStationSelectList(stationList, selectList){ } } +/** + * + * @param {String} endpoint URL to web service endpoint + * @param {String} weatherStationId Id of the weather station + * @param {Array} parameters List of weather parameters (IPM Decisions codes) + * @param {Int} interval log interval for the weather data, provided in seconds. E.g. 3600 = hourly, 86400 = daily + * @param {String} dateStart (YYYY-MM-DD) + * @param {String} dateEnd (YYYY-MM-DD) + * @returns weather data from a station based weather data source + */ async function getStationWeatherData(endpoint, weatherStationId, parameters, interval, dateStart, dateEnd){ const response = await fetch(endpoint + "?timeStart=" + dateStart @@ -111,6 +156,17 @@ async function getStationWeatherData(endpoint, weatherStationId, parameters, int return await response.json(); } +/** + * + * @param {String} endpoint URL to web service endpoint + * @param {Float} longitude Decimal degrees (WGS84) for the location + * @param {Float} latitude Decimal degrees (WGS84) for the location + * @param {Array} parameters List of weather parameters (IPM Decisions codes) + * @param {Int} interval log interval for the weather data, provided in seconds. E.g. 3600 = hourly, 86400 = daily + * @param {String} dateStart (YYYY-MM-DD) + * @param {String} dateEnd (YYYY-MM-DD) + * @returns weather data from a location based weather data source + */ async function getLocationWeatherData(endpoint, longitude, latitude, parameters, interval, dateStart, dateEnd){ const response = await fetch(endpoint + "?timeStart=" + dateStart @@ -123,6 +179,12 @@ async function getLocationWeatherData(endpoint, longitude, latitude, parameters, return await response.json(); } +/** + * Run and get results back from a DSS model + * @param {String} endpoint The web service endpont for that DSS model + * @param {Json} inputData as defined by the Json schema in the DSS model metadata property input_data + * @returns {Json} model results in IPM Decisions format + */ async function runModel(endpoint, inputData) { const response = await fetch(endpoint, { @@ -137,6 +199,13 @@ async function runModel(endpoint, inputData) return await response.json(); } +/** + * Util model for getting an array of consecutive dates + * @param {Integer} timestart UNIX epoch seconds + * @param {Integer} interval log interval in seconds. E.g. 3600 = hourly, 86400 = daily + * @param {Integer} length How many dates you want in the array + * @returns + */ function getDateArray(timestart, interval, length) { let dateArray = []; @@ -149,6 +218,11 @@ function getDateArray(timestart, interval, length) return dateArray; } +/** + * + * @param {Integer} weatherParameterId the parameter di + * @returns {Json} parameter metadata (name, description, unit, aggregation type (MIN/MAX/AVG)) + */ function getWeatherParameter(weatherParameterId){ for(let i=0;i<weatherParameterList.length;i++) { @@ -160,6 +234,12 @@ function getWeatherParameter(weatherParameterId){ return null; } +/** + * + * @param {Json} weatherDatasource Metadata for the weather datasource + * @param {String} weatherStationId the requested weather station + * @returns {Array} coordinate (Decimal degrees/WGS84) of the weather station. + */ function getWeatherStationCoordinate(weatherDatasource, weatherStationId) { geoJson = JSON.parse(weatherDatasource.spatial.geoJSON); @@ -177,6 +257,20 @@ function getWeatherStationCoordinate(weatherDatasource, weatherStationId) return null; } +/** + * Models may require weather parameters that the requested weather datasource + * can't provide. The difference between a requested parameter and available parameter + * may be insignificant. For instance, a model may require average temperature for an hour, + * and the data source may provide instantaneous temperature for that hour. + * + * This methods figures out which parameters are considered fallback parameters for each other, + * and returns the intersection of the requested parameters (including fallbacks) and the available + * parameters + * + * @param {Array} requestedParameters the parameters that the model requires + * @param {Array} availableParameters the parameters that the weather datasource provides + * @returns + */ function getPragmaticWeatherParameterList(requestedParameters, availableParameters) { let completeList = []; @@ -184,13 +278,15 @@ function getPragmaticWeatherParameterList(requestedParameters, availableParamete { completeList = completeList.concat(requestedParameters[i], fallbackParams[requestedParameters[i]]); } - //console.info(completeList); - //console.info(availableParameters); - return completeList.filter(param => availableParameters.includes(param)); - } +/** + * + * @param {Array} coordinate [longitude,latitude] decimal degrees (WGS84) + * @param {Json} datasource metadata + * @returns + */ async function isPointCoveredByDatasource(coordinate, datasource) { // If the geoJson is {"type":"Sphere"}, return true @@ -212,32 +308,31 @@ async function isPointCoveredByDatasource(coordinate, datasource) } /** - * Displays the weather data in a table - * @param {Object} weatherData the weatherData in IPM Decisions format - * @param {HTMLElement} container the element to render to - * @param {String} tableClass for styling the table, e.g. using Bootstrap - */ - function renderWeatherData(weatherData, container, tableClass){ - let dates = getDateArray(weatherData.timeStart, weatherData.interval, weatherData.locationWeatherData[0].length); - let html = "<table" + (tableClass != undefined ? " class=\"" + tableClass + "\"" : "") + "><thead><tr><th>Time</th>"; - weatherData.weatherParameters.forEach(function(weatherParameterId){ - html +="<th>" + getWeatherParameter(weatherParameterId).name + "</th>"; - }); - - html += "</tr></thead>" - html += "<tbody>"; - //console.info("Weatherdata length: " + weatherData.locationWeatherData[0].data.length); - for(let i=0;i< weatherData.locationWeatherData[0].data.length;i++) - { - html += "<tr><td>" + moment(dates[i]).format("YYYY-MM-DD HH:mm:ss") + "</td>"; - weatherData.locationWeatherData[0].data[i].forEach(function(val){html+="<td>" + val + "</td>";}) - html += "</tr>"; - } - html += "</tbody>"; - html += "</table>"; - container.innerHTML = html; - + * Displays the weather data in a table + * @param {Object} weatherData the weatherData in IPM Decisions format + * @param {HTMLElement} container the element to render to + * @param {String} tableClass for styling the table, e.g. using Bootstrap + */ +function renderWeatherData(weatherData, container, tableClass){ + let dates = getDateArray(weatherData.timeStart, weatherData.interval, weatherData.locationWeatherData[0].length); + let html = "<table" + (tableClass != undefined ? " class=\"" + tableClass + "\"" : "") + "><thead><tr><th>Time</th>"; + weatherData.weatherParameters.forEach(function(weatherParameterId){ + html +="<th>" + getWeatherParameter(weatherParameterId).name + "</th>"; + }); + + html += "</tr></thead>" + html += "<tbody>"; + for(let i=0;i< weatherData.locationWeatherData[0].data.length;i++) + { + html += "<tr><td>" + moment(dates[i]).format("YYYY-MM-DD HH:mm:ss") + "</td>"; + weatherData.locationWeatherData[0].data[i].forEach(function(val){html+="<td>" + val + "</td>";}) + html += "</tr>"; } + html += "</tbody>"; + html += "</table>"; + container.innerHTML = html; + +} /** * Merges the two datasets. Keeping the primaryData if both sets have data for same time and parameter @@ -273,32 +368,6 @@ function mergeWeatherData(primaryData, secondaryData) { mergedParams.push(primaryData.weatherParameters[j]); }; - /* - // Check for additional parameters in secondaryData <--- DON'T DO THIS - // Fallback parameters are not considered additional - for(let j=0;j<secondaryData.weatherParameters.length; j++) - { - let element = secondaryData.weatherParameters[j]; - if(!mergedParams.includes(element)) - { - let isNewForSure = true; - let fallbacks = fallbackParams[element]; - if(fallbacks != undefined) - { - for(let i=0; i<fallbacks.length; i++) - { - if(mergedParams.includes(fallbacks[i])) - { - isNewForSure = false; - } - } - } - if(isNewForSure) - { - mergedParams.push(element); - } - } - }*/ return mergedParams; }(), locationWeatherData: [ @@ -312,7 +381,6 @@ function mergeWeatherData(primaryData, secondaryData) // The parameter ordering may differ between primaryData and secondaryData // We solve this by mapping the parameter in secondaryData to an index in primaryData paramIndexes = {}; - //console.info(Object.keys(fallbackParams)); secondaryData.weatherParameters.forEach(element => { if(mergedData.weatherParameters.includes(element)) { @@ -331,21 +399,16 @@ function mergeWeatherData(primaryData, secondaryData) }); // Calculate dimensions of the merged data set - //console.info("timeStart=" + mergedData.timeStart.format("YYYY-MM-DD") + ", timeEnd=" + mergedData.timeEnd.format("YYYY-MM-DD")); let length = (getUnix(mergedData.timeEnd) - getUnix(mergedData.timeStart)) / mergedData.interval; let width = mergedData.weatherParameters.length; //console.info(length + "*" + width); let dataSet = Array.from(Array(length), () => new Array(width)); - //console.info(dataSet); // Keeping track of offsets (if dataset does not cover the entire combined period of the merging datasets) let primaryOffset = (getUnix(primaryData.timeStart) - getUnix(mergedData.timeStart)) / mergedData.interval; let secondaryOffset = (getUnix(secondaryData.timeStart) - getUnix(mergedData.timeStart)) / mergedData.interval; let primaryDataSet = primaryData.locationWeatherData[0].data; let secondaryDataSet = secondaryData.locationWeatherData[0].data; - //console.info("primaryOffset=" + primaryOffset); - //console.info(primaryDataSet); - // Finally: Merge the data! for(let i=0;i<dataSet.length;i++) { @@ -362,7 +425,6 @@ function mergeWeatherData(primaryData, secondaryData) } if(primaryOffset <= i && i-primaryOffset < primaryDataSet.length ) { - //console.info(i-primaryOffset); for(let j=0; j<primaryData.weatherParameters.length;j++) { if(!isEmpty(primaryDataSet[i-primaryOffset][j])) @@ -374,22 +436,28 @@ function mergeWeatherData(primaryData, secondaryData) } mergedData.locationWeatherData[0].data = dataSet; - - //console.info(paramIndexes); return mergedData; } +/** + * Util method if you don't know the type of object you're trying to get a Unix timestamp from + * @param {Object} aDate + * @returns {BigInt} the Unix timestamp + */ function getUnix(aDate) { - //console.info(typeof aDate); if(typeof aDate == "string") { aDate = moment(aDate); } return aDate.unix(); - //return aDate.getTime()/1000; } +/** + * Saves a bit of typing. Currently checks if the val is either undefined or null + * @param {Object} val + * @returns {Boolean} true if the value is considered empty + */ function isEmpty(val) { return val === undefined || val === null; @@ -397,6 +465,22 @@ function isEmpty(val) // Keeping track of maps var maps = {}; + +/** + * + * @param {String} containerId the container where the map to destroy is anchored + */ +function destroyDataSourceMap(containerId) +{ + let map = maps[containerId]; + // Delete whatever was there + if(map != undefined) + { + map.setTarget(undefined); + map = null; + } +} + /** * Requires OpenLayers v. 4 * @param {String} containerId @@ -416,7 +500,7 @@ async function initDataSourceMap(containerId, geoJson, countryCodeList, featureC } // If no data, no map - if((geoJson == undefined || geoJson == null || geoJson.features == undefined || geoJson.features.length == 0) && (countryCodeList == null || countryCodeList.length == 0)) + if((isEmpty(geoJson) || isEmpty(geoJson.features) || geoJson.features.length == 0) && (countryCodeList == null || countryCodeList.length == 0)) { document.getElementById(containerId).innerHTML = "NO GEODATA PROVIDED BY WEATHER DATA SOURCE"; return; @@ -426,15 +510,9 @@ async function initDataSourceMap(containerId, geoJson, countryCodeList, featureC document.getElementById(containerId).innerHTML = ""; } - + // Using OpenStreetMap as default layer var backgroundLayer = new ol.layer.Tile({ - source: new ol.source.OSM({ - attributions: [ - new ol.Attribution({ - html: "TEST" - }) - ] - }) + source: new ol.source.OSM() }); // Creating the map @@ -445,22 +523,20 @@ async function initDataSourceMap(containerId, geoJson, countryCodeList, featureC }); - - + // Adding it to list of maps, for bookkeeping maps[containerId] = map; + // Setting zoom and center for the map (need to do this after creating map. so that we can transform our // center to correct map projection) var view = new ol.View({ center: ol.proj.transform([10,65], 'EPSG:4326', map.getView().getProjection().getCode()), - zoom: 7//, - //maxZoom: 7 + zoom: 7 }); map.setView(view); + // Read the datasource's geoJson features let features = new ol.Collection(); - let format = new ol.format.GeoJSON(); - let drawnFeatures = await format.readFeatures( await getDatasourceFeatures(geoJson, countryCodeList), { @@ -469,11 +545,10 @@ async function initDataSourceMap(containerId, geoJson, countryCodeList, featureC } ); + // Add any features to the featureOverlay let featureOverlay = undefined; - if(drawnFeatures != undefined) { - //console.info(drawnFeatures); // Create an empty layer featureOverlay = new ol.layer.Vector({ source: new ol.source.Vector({ @@ -490,11 +565,8 @@ async function initDataSourceMap(containerId, geoJson, countryCodeList, featureC map.getView().fit(extent, map.getSize()); } - - /* - * A little bit of this and that with regards to feature selection - * to make it work as we want + * A little bit of this and that to make feature selection work the way we want * - Highlight a selected station (but only one station, multiple select is not allowed) * - If user clicks on multiple stations, zoom in to make it easy to pick the right one */ @@ -516,6 +588,8 @@ async function initDataSourceMap(containerId, geoJson, countryCodeList, featureC } }); + // We add this in order to detect if multiple features were clicked on, since the selectInteraction + // does not catch that map.on('singleclick', function(evt) { var pixel = map.getEventPixel(evt.originalEvent); var coordinate = map.getEventCoordinate(evt.originalEvent); @@ -539,6 +613,7 @@ async function getDatasourceFeatures(geoJson, countryCodeList) return await getCountryBoundaries(countryCodeList); } +// Default style for weather stations and areas const styleUnselected = new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(173, 173, 173, 0.5)' @@ -615,6 +690,10 @@ async function getCountryBoundaries(countryCodeList) } +/** + * List of fallback parameters to use if the weather data source does not provide the + * initially requested weather parameter + */ const fallbackParams = { 1001: [1002], 1002: [1001], @@ -626,8 +705,30 @@ const fallbackParams = { 4013: [4012,4003,4002] } +/** + * Util method for programmatically selecting an option in a list + * @param {HTMLElement} selectList + * @param {String} optionValue + */ +function setSelection(selectList, optionValue) +{ + for(let i=0;i<selectList.options.length; i++) + { + if(selectList.options[i].value == optionValue) + { + selectList.options[i].selected = true; + } + else + { + selectList.options[i].selected = false; + } + } +} +/** + * All the available weather parameters + */ const weatherParameterList = [ { "id": 1001, diff --git a/ipmd/templates/ipmd/saddlegallmidgeform.html b/ipmd/templates/ipmd/saddlegallmidgeform.html index 6035037401b7a71d27b7bcc3f5f6d4ec18dc7717..9183560386c45471ba87dd0bb7b5d33af0fe3954 100644 --- a/ipmd/templates/ipmd/saddlegallmidgeform.html +++ b/ipmd/templates/ipmd/saddlegallmidgeform.html @@ -161,6 +161,15 @@ initPage(); + /** + * + * When the user selects a weather datasource: + * - Displays the description from the weather datasources's metadata + * - Displays a map showing the stations or area OR nothing - depending on the metadata's "spatial" property + * + * + * @param {HTMLElement} weatherDatasourcelist - the select list + */ function handleWeatherDatasourceSelected(weatherDatasourceList){ currentWeatherDatasource = getWeatherDatasource( weatherDatasources, @@ -171,6 +180,7 @@ { sourceInfoPanel.style.display="none"; document.getElementById("stationFields").style.display = "none"; + destroyDataSourceMap("historicDatasourceMap"); return; } @@ -196,15 +206,23 @@ // Location based API // Display lat/lon input fields document.getElementById("stationFields").style.display = "none"; - // TODO: Add map? - } - //console.info(currentWeatherDatasource); } + /** + * This is a callback method passed on to impdlib.initDataSourceMap. When the user clicks on + * - For station based datasources: a weather station + * -> the correct station is selected in the station list + * -> The station's location is set in the latitude/longitude form fields + * + * - For gridded datasources: Anywhere inside the grid area + * -> The location selected by the user is set in the latitude/longitude form fields + * + * @param {String} id the weatherstation id NULL => gridded weather datasource + * @param {Array} [lon,lat] (decimal degrees) of the clicked location. NULL => station based weather datasource + */ function handleHistoricDatasourceMapClicked(id, coordinate) { - //console.info("Map clicked, station=" + id +", coordinate=" + coordinate + ". TODO: select station and/or populate lat/lon fields"); // id != null => station. Pull station coordinates from source list to avoid inacurracies in user's map click coordinate if(id !== null) { @@ -219,21 +237,12 @@ } } - function setSelection(selectList, optionValue) - { - for(let i=0;i<selectList.options.length; i++) - { - if(selectList.options[i].value == optionValue) - { - selectList.options[i].selected = true; - } - else - { - selectList.options[i].selected = false; - } - } - } + + /** + * Shows datasource info and displays map + * Alerts if the selected forecast data source does not cover the selected location + */ async function handleForecastSourceSelected(forecastSourceSelectList) { currentForecastWeatherDatasource = getWeatherDatasource( @@ -242,9 +251,11 @@ ); let sourceInfoPanel = document.getElementById("forecastSourceInfoPanel"); + // No datasource selected? Hide info panel and map and return if(currentForecastWeatherDatasource == null) { sourceInfoPanel.style.display="none"; + destroyDataSourceMap("forecastDatasourceMap"); return; } @@ -269,33 +280,43 @@ } + /** + * Pulls the coordinates of the selected weather station from the metadata, + * enters it in the form fields + */ function handleWeatherStationSelected(weatherStationSelectList) { let weatherStationId = selectList.options[selectList.selectedIndex].value; let stationCoordinate = getWeatherStationCoordinate(currentWeatherDatasource, weatherStationId); - //console.info(stationCoordinate); setLatLon(stationCoordinate); } + /** + * Populate the form fields with the given coordinate + */ function setLatLon(coordinate) { document.getElementById("longitude").value = coordinate[0]; document.getElementById("latitude").value = coordinate[1]; } + /** + * @returns {Array} [longitude,latitude] from the form fields + */ function getLatLon() { return [document.getElementById("longitude").value, document.getElementById("latitude").value]; } + /** + * Collects the input data into Json that conforms to the DSS model's Json schema + * Also collects weather data (which goes into the input data Json) + */ async function submitData(){ - //console.info("submitData!"); let inputData = editor.getValue(); - //console.info(inputData); // Add hidden parameters let fullSchema = JSON.parse(modelMetaData["execution"]["input_schema"]); const hiddenParameters = modelMetaData["execution"]["input_schema_categories"]["hidden"]; - //console.info(hiddenParameters); for(let i=0;i<hiddenParameters.length;i++) { let hiddenParameter = hiddenParameters[i]; @@ -304,7 +325,6 @@ // Check for weatherData element. Assuming it's at the root node if(fullSchema["properties"]["weatherData"] !== undefined) { - //console.info("Need to get weather data!"); let forecastData = undefined; // 1. Historic weather data if(currentWeatherDatasource != undefined) @@ -389,24 +409,22 @@ } } - inputData["weatherData"] = weatherData; - //console.info(weatherData); } // Ready to call server? - //console.info(JSON.stringify(inputData)); - //let result = await runModel(modelMetaData.execution.endpoint, inputData); let result = mockResult; - //console.info(result); displayResult(result); }; + /** + * Render results in a chart + */ function displayResult(result) { let chartData = []; // Generate dates let dates = getDateArray(result.timeStart, 86400, result.locationResult[0].length); - //console.info(dates); + for(let i=0; i< result.resultParameters.length;i++) { let dataset = {label:result.resultParameters[i], data: []}