diff --git a/index.html b/index.html index 03e64827f05f6e49eac15c182e2a5b6efaff42c8..c90ef6807d1c3f48a6acad9b4f327bf55df86278 100644 --- a/index.html +++ b/index.html @@ -73,7 +73,6 @@ <script defer type="module" src="js/appCesium.js"></script> <script defer type="module" src="js/appChart.js"></script> <script defer type="module" src="js/dropDownList.js"></script> - <script defer type="module" src="js/aggregate.js"></script> </head> <body class="sb-nav-fixed"> <nav class="sb-topnav navbar navbar-expand navbar-dark bg-dark"> diff --git a/public/js/appChart.js b/public/js/appChart.js index 181efd4e0907057aa476b5dd8b25aa49291fb15a..5c233ef06232be5b26b237c7a514d233d012d3cf 100644 --- a/public/js/appChart.js +++ b/public/js/appChart.js @@ -1,1180 +1,46 @@ "use strict"; -const BASE_URL = "http://193.196.39.91:8080/frost-icity-tp31/v1.1"; +import { BASE_URL, QUERY_PARAMS_COMBINED } from "./src_modules/createUrl.js"; -/** - * Retrieve the datastream ID that corresponds to a particular building - * @param {Number | String} buildingNumber Integer representing the building ID - * @param {String} phenomenon String representing the phenomenon of interest - * @param {String} samplingRate String representing the sampling rate of the observations - * @returns {Number} Datastream corresponding to the input building - */ -const getDatastreamIdFromBuildingNumber = function ( - buildingNumber, - phenomenon, - samplingRate -) { - const buildingToDatastreamMapping = { - 101: { - vl: { "15min": "69", "60min": "75" }, - rl: { "15min": "81", "60min": "87" }, - - // These Datastreams do not yet have Observations - // flow: { "15min": "93", "60min": "99" }, - // power: { "15min": "105", "60min": "111" }, - // energy: { "15min": "117", "60min": "123" }, - // energy_verb: { "15min": "129", "60min": "135" }, - }, - - 102: { - vl: { "15min": "70", "60min": "76" }, - rl: { "15min": "82", "60min": "88" }, - - // These Datastreams do not yet have Observations - // flow: { "15min": "94", "60min": "100" }, - // power: { "15min": "106", "60min": "112" }, - // energy: { "15min": "118", "60min": "124" }, - // energy_verb: { "15min": "130", "60min": "136" }, - }, - - 107: { - vl: { "15min": "71", "60min": "77" }, - rl: { "15min": "83", "60min": "89" }, - - // These Datastreams do not yet have Observations - // flow: { "15min": "95", "60min": "101" }, - // power: { "15min": "107", "60min": "113" }, - // energy: { "15min": "119", "60min": "125" }, - // energy_verb: { "15min": "131", "60min": "137" }, - }, - - "112, 118": { - vl: { "15min": "72", "60min": "78" }, - rl: { "15min": "84", "60min": "90" }, - - // These Datastreams do not yet have Observations - // flow: { "15min": "96", "60min": "102" }, - // power: { "15min": "108", "60min": "114" }, - // energy: { "15min": "120", "60min": "126" }, - // energy_verb: { "15min": "132", "60min": "138" }, - }, - - 125: { - vl: { "15min": "73", "60min": "79" }, - rl: { "15min": "85", "60min": "91" }, - - // These Datastreams do not yet have Observations - // flow: { "15min": "97", "60min": "103" }, - // power: { "15min": "109", "60min": "115" }, - // energy: { "15min": "121", "60min": "127" }, - // energy_verb: { "15min": "133", "60min": "139" }, - }, - - 225: { - vl: { "15min": "74", "60min": "80" }, - rl: { "15min": "86", "60min": "92" }, - flow: { "15min": "98", "60min": "104" }, - power: { "15min": "110", "60min": "116" }, - energy: { "15min": "122", "60min": "128" }, - energy_verb: { "15min": "134", "60min": "140" }, - }, - - weather_station_521: { - outside_temp: { "15min": "141", "60min": "142" }, - }, - }; - - if ( - buildingToDatastreamMapping?.[buildingNumber]?.[phenomenon]?.[ - samplingRate - ] === undefined - ) - return; - - return Number( - buildingToDatastreamMapping[buildingNumber][phenomenon][samplingRate] - ); -}; - -/** - * Create URL to fetch the details of single Datastream - * @param {String} baseUrl Base URL of the STA server - * @param {Number} datastreamID Integer representing the Datastream ID - * @returns {String} URL string for fetching a single Datastream - */ -const createDatastreamUrl = function (baseUrl, datastreamID) { - if (!datastreamID) return; - return `${baseUrl}/Datastreams(${datastreamID})`; -}; - -/** - * Create URL to fetch Observations - * @param {String} baseUrl Base URL of the STA server - * @param {Number} datastreamID Integer representing the Datastream ID - * @returns {String} URL string for fetching Observations - */ -const createObservationsUrl = function (baseUrl, datastreamID) { - if (!datastreamID) return; - return `${baseUrl}/Datastreams(${datastreamID})/Observations`; -}; - -/** - * Create a temporal filter string for the fetched Observations - * @param {String} dateStart Start date in YYYY-MM-DD format - * @param {String} dateStop Stop date in YYYY-MM-DD format - * @returns {String} Temporal filter string - */ -const createTemporalFilterString = function (dateStart, dateStop) { - if (!dateStart || !dateStop) return; - return `resultTime ge ${dateStart}T00:00:00.000Z and resultTime le ${dateStop}T00:00:00.000Z`; -}; - -/** - * Create a query parameter object that should be sent together with a HTTP GET request using the Axios library - * @param {String} dateStart Start date (for temporal filter) in YYYY-MM-DD format - * @param {String} dateStop Stop date (for temporal filter) in YYYY-MM-DD format - * @returns {Object} A query parameter object - */ -const createUrlParametersForGetRequest = function (dateStart, dateStop) { - const QUERY_PARAM_RESULT_FORMAT = "dataArray"; - const QUERY_PARAM_ORDER_BY = "phenomenonTime asc"; - const QUERY_PARAM_FILTER = createTemporalFilterString(dateStart, dateStop); - const QUERY_PARAM_SELECT = "result,phenomenonTime"; - - return { - "$resultFormat": QUERY_PARAM_RESULT_FORMAT, - "$orderBy": QUERY_PARAM_ORDER_BY, - "$filter": QUERY_PARAM_FILTER, - "$select": QUERY_PARAM_SELECT, - }; -}; - -const QUERY_PARAMS_COMBINED = createUrlParametersForGetRequest( - "2020-01-01", - "2021-01-01" -); - -/** - * Perform a GET request using the Axios library - * @param {String} urlObservations A URL that fetches Observations from an STA instance - * @param {Object} urlParamObj The URL parameters to be sent together with the GET request - * @returns {Promise} A promise that contains the first page of results when fulfilled - */ -const performGetRequestUsingAxios = function (urlObservations, urlParamObj) { - return axios.get(urlObservations, { - params: urlParamObj, - }); -}; - -/** - * Retrieve the metadata for a single datastream - * @async - * @param {String} urlDatastream A URL that fetches a Datastream from an STA instance - * @returns {Promise} A promise that contains a metadata object for a Datastream when fulfilled - */ -const getMetadataFromSingleDatastream = async function (urlDatastream) { - try { - // Extract properties of interest - const { - data: { description, name, unitOfMeasurement }, - } = await performGetRequestUsingAxios(urlDatastream); - - return { description, name, unitOfMeasurement }; - } catch (err) { - console.error(err); - } -}; - -/** - * Retrieve metadata from multiple datastreams - * @async - * @param {Array} datastreamsUrlArr An array that contains N Datastream URL strings - * @returns {Promise} A promise that contains an array of N Datastream metadata objects when fulfilled - */ -const getMetadataFromMultipleDatastreams = async function (datastreamsUrlArr) { - try { - // Array to store our final result - const datastreamMetadataArr = []; - - // Use for/of loop - we need to maintain the order of execution of the async operations - for (const datastreamUrl of datastreamsUrlArr) { - // Metadata from a single Datastream - const datastreamMetadata = await getMetadataFromSingleDatastream( - datastreamUrl - ); - datastreamMetadataArr.push(datastreamMetadata); - } - - return datastreamMetadataArr; - } catch (err) { - console.error(err); - } -}; - -/** - * Match the unitOfMeasurement's string representation of a symbol to an actual symbol, where necessary - * @param {String} unitOfMeasurementSymbolString String representation of the unitOfMeasurement's symbol - * @returns {String} The unitOfMeasurement's symbol - */ -const matchUnitOfMeasurementSymbolStringToSymbol = function ( - unitOfMeasurementSymbolString -) { - const unicodeCodePointDegreeSymbol = "\u00B0"; - const unicodeCodePointSuperscriptThree = "\u00B3"; - - if (unitOfMeasurementSymbolString === "degC") - return `${unicodeCodePointDegreeSymbol}C`; - - if (unitOfMeasurementSymbolString === "m3/h") - return `m${unicodeCodePointSuperscriptThree}/h`; - - // If no symbol exists - return unitOfMeasurementSymbolString; -}; - -/** - * Extract the phenomenon name from a Datastream's name - * @param {String} datastreamName A string representing the Datastream's name - * @returns {String} The extracted phenomenon name - */ -const extractPhenomenonNameFromDatastreamName = function (datastreamName) { - const regex = /\/ (.*) DS/; - return datastreamName.match(regex)[1]; // use second element in array -}; - -/** - * Format the response containing a Datastream's metadata from Sensorthings API - * @param {Object} datastreamMetadata An object containing a Datastream's metadata - * @returns {Object} An object containing the formatted metadata that is suitable for use in a line chart or heatmap - */ -const formatDatastreamMetadataForChart = function (datastreamMetadata) { - const { - description: datastreamDescription, - name: datastreamName, - unitOfMeasurement, - } = datastreamMetadata; - - // Extract phenomenon name from Datastream name - const phenomenonName = - extractPhenomenonNameFromDatastreamName(datastreamName); - - // Get the unitOfMeasurement's symbol - const unitOfMeasurementSymbol = matchUnitOfMeasurementSymbolStringToSymbol( - unitOfMeasurement.symbol - ); - - return { - datastreamDescription, - datastreamName, - phenomenonName, - unitOfMeasurementSymbol, - }; -}; - -/** - * Format the response from SensorThings API to make it suitable for use in a heatmap - * @param {Array} obsArray Array of observations (timestamp + value) that is response from SensorThings API - * @returns {Array} Array of formatted observations suitable for use in a heatmap - */ -const formatSensorThingsApiResponseForHeatMap = function (obsArray) { - if (!obsArray) return; - - const dataSTAFormatted = obsArray.map((obs) => { - // Get the date/time string; first element in input array; remove trailing "Z" - const obsDateTimeInput = obs[0].slice(0, -1); - // Get the "date" part of an observation - const obsDateInput = obs[0].slice(0, 10); - // Create Date objects - const obsDateTime = new Date(obsDateTimeInput); - const obsDate = new Date(obsDateInput); - // x-axis -> timestamp; will be the same for observations from the same date - const timestamp = Date.parse(obsDate); - // y-axis -> hourOfDay - const hourOfDay = obsDateTime.getHours(); - // value -> the observation's value; second element in input array - const value = obs[1]; - return [timestamp, hourOfDay, value]; - }); - - return dataSTAFormatted; -}; - -/** - * Calculate the minimum and maximum values for a heatmap's color axis - * @param {Array} formattedObsArrHeatmap Response from SensorThings API formatted for use in a heatmap - * @returns {Object} An object containing the minimum and maximum values - */ -const calculateMinMaxValuesForHeatmapColorAxis = function ( - formattedObsArrHeatmap -) { - // The observation value is the third element in array - const obsValueArr = formattedObsArrHeatmap.map((obs) => obs[2]); - - // Extract integer part - const minValue = Math.trunc(Math.min(...obsValueArr)); - const maxValue = Math.trunc(Math.max(...obsValueArr)); - - // Calculate the closest multiple of 5 - const minObsValue = minValue - (minValue % 5); - const maxObsValue = maxValue + (5 - (maxValue % 5)); - - return { minObsValue, maxObsValue }; -}; - -/** - * Draw a heatmap using Highcharts library - * @param {Array} formattedObsArrayForHeatmap Response from SensorThings API formatted for use in a heatmap - * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata - * @returns {undefined} undefined - */ -const drawHeatMapHighcharts = function ( - formattedObsArrayForHeatmap, - formattedDatastreamMetadata -) { - const { - datastreamDescription: DATASTREAM_DESCRIPTION, - datastreamName: DATASTREAM_NAME, - phenomenonName: PHENOMENON_NAME, - unitOfMeasurementSymbol: PHENOMENON_SYMBOL, - } = formattedDatastreamMetadata; - - const { - minObsValue: MINIMUM_VALUE_COLOR_AXIS, - maxObsValue: MAXIMUM_VALUE_COLOR_AXIS, - } = calculateMinMaxValuesForHeatmapColorAxis(formattedObsArrayForHeatmap); - - Highcharts.chart("chart-heatmap", { - chart: { - type: "heatmap", - zoomType: "x", - }, - - boost: { - useGPUTranslations: true, - }, - - title: { - text: DATASTREAM_DESCRIPTION, - align: "left", - x: 40, - }, - - subtitle: { - text: DATASTREAM_NAME, - align: "left", - x: 40, - }, - - xAxis: { - type: "datetime", - // min: Date.UTC(2017, 0, 1), - // max: Date.UTC(2017, 11, 31, 23, 59, 59), - labels: { - align: "left", - x: 5, - y: 14, - format: "{value:%B}", // long month - }, - showLastLabel: false, - tickLength: 16, - }, - - yAxis: { - title: { - text: null, - }, - labels: { - format: "{value}:00", - }, - minPadding: 0, - maxPadding: 0, - startOnTick: false, - endOnTick: false, - tickPositions: [0, 3, 6, 9, 12, 15, 18, 21, 24], - tickWidth: 1, - min: 0, - max: 23, - reversed: true, - }, - - colorAxis: { - stops: [ - [0, "#3060cf"], - [0.5, "#fffbbc"], - [0.9, "#c4463a"], - [1, "#c4463a"], - ], - min: MINIMUM_VALUE_COLOR_AXIS, - max: MAXIMUM_VALUE_COLOR_AXIS, - startOnTick: false, - endOnTick: false, - labels: { - // format: "{value}℃", - format: `{value}${PHENOMENON_SYMBOL}`, - }, - }, - - series: [ - { - data: formattedObsArrayForHeatmap, - boostThreshold: 100, - borderWidth: 0, - nullColor: "#525252", - colsize: 24 * 36e5, // one day - tooltip: { - headerFormat: `${PHENOMENON_NAME}<br/>`, - valueDecimals: 2, - pointFormat: - // "{point.x:%e %b, %Y} {point.y}:00: <b>{point.value} ℃</b>", - `{point.x:%e %b, %Y} {point.y}:00: <b>{point.value} ${PHENOMENON_SYMBOL}</b>`, - nullFormat: `{point.x:%e %b, %Y} {point.y}:00: <b>null</b>`, - }, - turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release - }, - ], - }); -}; - -/** - * Format the response from SensorThings API to make it suitable for use in a line chart - * @param {Array} obsArray Response from SensorThings API as array - * @returns {Array} Array of formatted observations suitable for use in a line chart - */ -const formatSensorThingsApiResponseForLineChart = function (obsArray) { - if (!obsArray) return; - - const dataSTAFormatted = obsArray.map((result) => { - const timestampObs = new Date(result[0].slice(0, -1)).getTime(); // slice() removes trailing "Z" character in timestamp - const valueObs = result[1]; - return [timestampObs, valueObs]; - }); - - return dataSTAFormatted; -}; - -/** - * Extract the properties that make up the formatted datastream metadata object(s) - * @param {Array} formattedDatastreamsMetadataArr An array of formatted metadata object(s) from one or more datastreams - * @returns {Object} An object that contains array(s) of formatted datastream metadata properties - */ -const extractPropertiesFromDatastreamMetadata = function ( - formattedDatastreamsMetadataArr -) { - // Create arrays from the properties of the formatted datastream metadata - const datastreamDescriptionsArr = formattedDatastreamsMetadataArr.map( - (datastreamMetadata) => datastreamMetadata.datastreamDescription - ); - - const datastreamNamesArr = formattedDatastreamsMetadataArr.map( - (datastreamMetadata) => datastreamMetadata.datastreamName - ); - - const phenomenonNamesArr = formattedDatastreamsMetadataArr.map( - (datastreamMetadata) => datastreamMetadata.phenomenonName - ); - - const unitOfMeasurementSymbolsArr = formattedDatastreamsMetadataArr.map( - (datastreamMetadata) => datastreamMetadata.unitOfMeasurementSymbol - ); - - return { - datastreamDescriptionsArr, - datastreamNamesArr, - phenomenonNamesArr, - unitOfMeasurementSymbolsArr, - }; -}; - -/** - * Extracts the sampling rate substring from a datastream name string - * @param {Array} datastreamNamesArr An array of datastream name(s) - * @returns {Array} An array containing the sampling rate substring(s) - */ -const extractSamplingRateFromDatastreamName = function (datastreamNamesArr) { - // The sampling rate string is the last word in the Datastream name string - return datastreamNamesArr.map((datastreamName) => - datastreamName.split(" ").pop() - ); -}; - -/** - * Concatenates metadata properties to create a string for either the title or subtitle of a line chart - * @param {Array} datastreamMetadataPropArr An array of metadata property strings - * @returns {String} A string of comma separated metadata property strings - */ -const createCombinedTextForLineChartTitles = function ( - datastreamMetadataPropArr -) { - return datastreamMetadataPropArr.join(", "); -}; - -/** - * Creates an options object for each series drawn in the line chart - * @param {Array} formattedObsArraysForLineChart An array of formatted observation array(s) from one or more datastreams - * @param {Array} phenomenonNamesArr An array of phenomenon name(s) - * @param {Array} phenomenonSymbolsArr An array of phenomenon symbol(s) - * @returns {Array} An array made up of series options object(s) - */ -const createSeriesOptionsForLineChart = function ( - formattedObsArraysForLineChart, - phenomenonNamesArr, - phenomenonSymbolsArr -) { - // An array of colors provided by the Highcharts object - const seriesColors = Highcharts.getOptions().colors; - - // Create an array of seriesOptions objects - // Assumes that the observation array of arrays, phenomenon names array and phenomenon symbols array are of equal length - // Use one of the arrays for looping - return formattedObsArraysForLineChart.map((formattedObsArray, i) => { - return { - name: `${phenomenonNamesArr[i]} (${phenomenonSymbolsArr[i]})`, - data: formattedObsArray, - color: seriesColors[i], - turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release - }; - }); -}; - -/** - * Draw a line chart using Highcharts library - * @param {Array} formattedObsArraysForLineChart An array made up of formatted observation array(s) suitable for use in a line chart - * @param {Object} formattedDatastreamMetadataArr An array made up of object(s) containing Datastream metadata - * @returns {undefined} undefined - */ -const drawLineChartHighcharts = function ( - formattedObsArraysForLineChart, - formattedDatastreamMetadataArr -) { - // Arrays of datastream properties - const { - datastreamNamesArr, - phenomenonNamesArr, - unitOfMeasurementSymbolsArr, - } = extractPropertiesFromDatastreamMetadata(formattedDatastreamMetadataArr); - - // Create the array of series options object(s) - const seriesOptionsArr = createSeriesOptionsForLineChart( - formattedObsArraysForLineChart, - phenomenonNamesArr, - unitOfMeasurementSymbolsArr - ); - - Highcharts.stockChart("chart-line", { - chart: { - zoomType: "x", - }, - - rangeSelector: { - selected: 5, - }, - - title: { - text: createCombinedTextForLineChartTitles(phenomenonNamesArr), - "align": "left", - }, - - subtitle: { - text: `Sampling rate(s): ${createCombinedTextForLineChartTitles( - extractSamplingRateFromDatastreamName(datastreamNamesArr) - )}`, - align: "left", - }, - - tooltip: { - pointFormat: - '<span style="color:{series.color}">{series.name}</span>: <b>{point.y}</b> <br/>', - valueDecimals: 2, - }, - - series: seriesOptionsArr, - }); -}; - -/** - * Determines the timestamps that are missing from a smaller set of observations. Based on the comparison of two observation arrays, where one array is larger than the other - * @param {Array} obsTimestampArrayOne An array of timestamps for the first set of observations - * @param {Array} obsTimestampArrayTwo An array of timstamps for the second set of observations - * @returns {Array} An array of timestamps missing from either set of observations - */ -const getSymmetricDifferenceBetweenArrays = function ( - obsTimestampArrayOne, - obsTimestampArrayTwo -) { - const differenceBetweenArrays = obsTimestampArrayOne - .filter((timestampOne) => !obsTimestampArrayTwo.includes(timestampOne)) - .concat( - obsTimestampArrayTwo.filter( - (timestampTwo) => !obsTimestampArrayOne.includes(timestampTwo) - ) - ); - - return differenceBetweenArrays; -}; - -/** - * Determines the indexes of timestamps that are unique to the larger set of observatiuons. Based on the comparison of two observation arrays, where one array is larger than the other - * @param {Array} uniqueTimestampsArr An array of timestamps unique to the larger set of observations - * @param {Array} largerObsTimestampArr An array of timestamps for the larger set of observations - * @returns {Array} An array of the indexes of the missing observations - */ -const getIndexesOfUniqueObservations = function ( - uniqueTimestampsArr, - largerObsTimestampArr -) { - const indexesMissingObs = uniqueTimestampsArr.map((index) => - largerObsTimestampArr.indexOf(index) - ); - - return indexesMissingObs; -}; - -/** - * Removes observations (by modifying array in place) that are unique to a larger set of observations. Based on the comparison of two observation arrays, where one array is larger than the other - * @param {Array} uniqueIndexesArr An array of the indexes unique to the larger set of observations - * @param {Array} largerObsArr The larger array of observations (timestamp + value) - * @returns {Array} The larger array with the unique indexes removed - */ -const removeUniqueObservationsFromLargerArray = function ( - uniqueIndexesArr, - largerObsArr -) { - // Create a reversed copy of the indexes array, so that the larger index is removed first - const reversedUniqueIndexesArr = uniqueIndexesArr.reverse(); - - // Create a copy the larger observation array, will be modified in place - const processedLargerObsArr = largerObsArr; - - reversedUniqueIndexesArr.forEach((index) => { - if (index > -1) { - processedLargerObsArr.splice(index, 1); - } - }); - - return processedLargerObsArr; -}; - -/** - * Compares the length of two input arrays to determine the larger one - * @param {Array} firstArr First input array - * @param {Array} secondArr Second input array - * @returns {Array} The larger array - */ -const getLargerArrayBetweenTwoInputArrays = function (firstArr, secondArr) { - if (firstArr.length === secondArr.length) return; - - if (firstArr.length > secondArr.length) return firstArr; - - if (firstArr.length < secondArr.length) return secondArr; -}; - -/** - * Compares the length of two input arrays to determine the smaller one - * @param {Array} firstArr First input array - * @param {Array} secondArr Second input array - * @returns {Array} The smaller array - */ -const getSmallerArrayBetweenTwoInputArrays = function (firstArr, secondArr) { - if (firstArr.length === secondArr.length) return; - - if (firstArr.length < secondArr.length) return firstArr; - - if (firstArr.length > secondArr.length) return secondArr; -}; - -/** - * Utility function for deleting the unique observations from a larger array - * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API - * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API - * @returns {Array} Two arrays of observations (timestamp + value) with matching timestamps and equal lengths - */ -const deleteUniqueObservationsFromLargerArray = function ( - obsArrayOne, - obsArrayTwo -) { - // Create arrays with timestamps only - const obsArrayOneTimestamp = obsArrayOne.map( - (obsTimeValue) => obsTimeValue[0] - ); - const obsArrayTwoTimestamp = obsArrayTwo.map( - (obsTimeValue) => obsTimeValue[0] - ); - - const missingTimestamp = getSymmetricDifferenceBetweenArrays( - obsArrayOneTimestamp, - obsArrayTwoTimestamp - ); - - // Determine the larger observation timestamp array - const biggerObsTimestampArr = getLargerArrayBetweenTwoInputArrays( - obsArrayOneTimestamp, - obsArrayTwoTimestamp - ); - - // Indexes of the missing observations - const indexesMissingObsArr = getIndexesOfUniqueObservations( - missingTimestamp, - biggerObsTimestampArr - ); - - // Determine the larger observation array - const biggerObsArr = getLargerArrayBetweenTwoInputArrays( - obsArrayOne, - obsArrayTwo - ); - - // Determine the smaller observation array - const smallerObsArr = getSmallerArrayBetweenTwoInputArrays( - obsArrayOne, - obsArrayTwo - ); - - // Remove the missing observation from the larger array of observations - const modifiedBiggerObsArr = removeUniqueObservationsFromLargerArray( - indexesMissingObsArr, - biggerObsArr - ); - - return [modifiedBiggerObsArr, smallerObsArr]; -}; - -/** - * Utility function for deleting the unique observations from a larger array AND ensuring the order of input arrays is maintained - * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API - * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API - * @returns {Array} Two arrays of observations (timestamp + value) with matching timestamps and equal lengths - */ -const checkForAndDeleteUniqueObservationsFromLargerArray = function ( - obsArrayOne, - obsArrayTwo -) { - if (obsArrayOne.length === obsArrayTwo.length) return; - - // Case 1: obsArrayOne.length < obsArrayTwo.length - if (obsArrayOne.length < obsArrayTwo.length) { - const [biggerObsArr, smallerObsArr] = - deleteUniqueObservationsFromLargerArray(obsArrayOne, obsArrayTwo); - - return [smallerObsArr, biggerObsArr]; - } - - // Case 2: obsArrayOne.length > obsArrayTwo.length - return deleteUniqueObservationsFromLargerArray(obsArrayOne, obsArrayTwo); -}; - -/** - * Extracts and combines observation values from two input observation arrays of equal length - * @param {Array} obsArrayOne First set of N observations (timestamp + value) - * @param {Array} obsArrayTwo Second set of N observations (timestamp + value) - * @returns {Array} A N*2 array of observation values from both input observation arrays - */ -const createCombinedObservationValues = function (obsArrayOne, obsArrayTwo) { - // Extract the values from the two observation arrays - const obsValuesOne = obsArrayOne.map((result) => result[1]); - const obsValuesTwo = obsArrayTwo.map((result) => result[1]); - - // Since the arrays are of equal length, we need only use one of the arrays for looping - const obsValuesOnePlusTwo = obsValuesOne.map((obsValOne, i) => { - return [obsValOne, obsValuesTwo[i]]; - }); - - return obsValuesOnePlusTwo; -}; - -/** - * Format the response from SensorThings API to make it suitable for use in a scatter plot - * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API - * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API - * @returns {Array} Array of formatted observations suitable for use in a scatter plot - */ -const formatSensorThingsApiResponseForScatterPlot = function ( - obsArrayOne, - obsArrayTwo -) { - // When our observation arrays have DIFFERENT lengths - if (obsArrayOne.length !== obsArrayTwo.length) { - const [obsArrayOneFinal, obsArrayTwoFinal] = - checkForAndDeleteUniqueObservationsFromLargerArray( - obsArrayOne, - obsArrayTwo - ); - - return createCombinedObservationValues(obsArrayOneFinal, obsArrayTwoFinal); - } - - // When our observation arrays already have SAME lengths - return createCombinedObservationValues(obsArrayOne, obsArrayTwo); -}; - -/** - * Draw a scatter plot using Highcharts library - * @param {Array} formattedObsArrayForSeriesOnePlusSeriesTwo Response from SensorThings API formatted for use in a scatter plot - * @param {Object} formattedDatastreamMetadataSeriesOne Object containing Datastream metadata for the first chart series - * @param {Object} formattedDatastreamMetadataSeriesTwo Object containing Datastream metadata for the second chart series - * @returns {undefined} - */ -const drawScatterPlotHighcharts = function ( - formattedObsArrayForSeriesOnePlusSeriesTwo, - formattedDatastreamMetadataSeriesOne, - formattedDatastreamMetadataSeriesTwo -) { - const { - datastreamDescription: DATASTREAM_DESCRIPTION_SERIES_1, - datastreamName: DATASTREAM_NAME_SERIES_1, - phenomenonName: PHENOMENON_NAME_SERIES_1, - unitOfMeasurementSymbol: PHENOMENON_SYMBOL_SERIES_1, - } = formattedDatastreamMetadataSeriesOne; - - const { - datastreamDescription: DATASTREAM_DESCRIPTION_SERIES_2, - datastreamName: DATASTREAM_NAME_SERIES_2, - phenomenonName: PHENOMENON_NAME_SERIES_2, - unitOfMeasurementSymbol: PHENOMENON_SYMBOL_SERIES_2, - } = formattedDatastreamMetadataSeriesTwo; - - // Order of axes - // Y-Axis -- Series 2 - // X-Axis -- Series 1 - - const CHART_TITLE = `${PHENOMENON_NAME_SERIES_2} Versus ${PHENOMENON_NAME_SERIES_1}`; - const CHART_SUBTITLE = `Source: ${DATASTREAM_NAME_SERIES_2} & ${DATASTREAM_NAME_SERIES_1}`; - - const SERIES_1_NAME = `${PHENOMENON_NAME_SERIES_1}`; - const SERIES_1_SYMBOL = `${PHENOMENON_SYMBOL_SERIES_1}`; - - const SERIES_2_NAME = `${PHENOMENON_NAME_SERIES_2}`; - const SERIES_2_SYMBOL = `${PHENOMENON_SYMBOL_SERIES_2}`; - - const SERIES_COMBINED_NAME = "Y, X"; - const SERIES_COMBINED_SYMBOL_COLOR_RGB_ELEMENTS = "223, 83, 83"; - const SERIES_COMBINED_SYMBOL_COLOR_OPACITY = ".3"; - const SERIES_COMBINED_SYMBOL_COLOR = `rgba(${SERIES_COMBINED_SYMBOL_COLOR_RGB_ELEMENTS}, ${SERIES_COMBINED_SYMBOL_COLOR_OPACITY})`; - - const MARKER_RADIUS = 2; - - Highcharts.chart("chart-scatter-plot", { - chart: { - type: "scatter", - zoomType: "xy", - }, - - boost: { - useGPUTranslations: true, - usePreAllocated: true, - }, - - title: { - text: CHART_TITLE, - }, - - subtitle: { - text: CHART_SUBTITLE, - }, - - xAxis: { - labels: { - format: `{value}`, - }, - title: { - enabled: true, - text: `${SERIES_1_NAME} [${SERIES_1_SYMBOL}]`, - }, - startOnTick: true, - endOnTick: true, - showLastLabel: true, - }, - - yAxis: [ - { - labels: { - format: `{value}`, - }, - title: { - text: `${SERIES_2_NAME} [${SERIES_2_SYMBOL}]`, - }, - }, - ], - - legend: { - enabled: false, - }, - - plotOptions: { - scatter: { - marker: { - radius: MARKER_RADIUS, - states: { - hover: { - enabled: true, - lineColor: "rgb(100,100,100)", - }, - }, - }, - states: { - hover: { - marker: { - enabled: false, - }, - }, - }, - tooltip: { - headerFormat: "{series.name}<br>", - pointFormat: `<b>{point.y:.2f} ${SERIES_1_SYMBOL}, {point.x:.2f} ${SERIES_2_SYMBOL}</b>`, - }, - }, - }, - - series: [ - { - name: SERIES_COMBINED_NAME, - color: SERIES_COMBINED_SYMBOL_COLOR, - data: formattedObsArrayForSeriesOnePlusSeriesTwo, - }, - ], - }); -}; - -/** - * Traverses all the pages that make up the response from a SensorThingsAPI instance. The link to the next page, if present, is denoted by the presence of a "@iot.nextLink" property in the response object. This function concatenates all the values so that the complete results are returned in one array. - * @async - * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request - * @returns {Promise} A promise that contains an object containing results from all the pages when fulfilled - */ -const combineResultsFromAllPages = async function (httpGetRequestPromise) { - try { - if (!httpGetRequestPromise) return; - - const lastSuccess = await httpGetRequestPromise; - - // The "success" objects contain a "data" object which in turn has a "value" property - // If the "data" object in turn has a "@iot.nextLink" property, then a next page exists - if (lastSuccess.data["@iot.nextLink"]) { - const nextLinkSuccess = await combineResultsFromAllPages( - axios.get(lastSuccess.data["@iot.nextLink"]) - ); - // The "data" object in turn has a "value" property - // The "value" property's value is an array - nextLinkSuccess.data.value = lastSuccess.data.value.concat( - nextLinkSuccess.data.value - ); - return nextLinkSuccess; - } else { - return lastSuccess; - } - } catch (err) { - console.error(err); - } -}; - -/** - * Traverses all the pages that make up the response from a SensorThingsAPI instance and extracts the combined Observations - * @async - * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request - * @returns {Promise} A promise that contains an array of Observations when fulfilled - */ -const extractCombinedObservationsFromAllPages = async function ( - httpGetRequestPromise -) { - try { - const successResponse = await combineResultsFromAllPages( - httpGetRequestPromise - ); - - // Extract value array from the success response object - const { - data, - data: { value: valueArr }, - } = successResponse; - - // Array that will hold the combined observations - const combinedObservations = []; - - valueArr.forEach((val) => { - // Each page of results will have a dataArray that holds the observations - const { dataArray } = val; - combinedObservations.push(...dataArray); - }); - - return new Promise((resolve, reject) => { - resolve(combinedObservations); - }); - } catch (err) { - console.error(err); - } -}; - -/** - * Retrieve all the Observations from an array of Observations promises - * @async - * @param {Promise} observationPromiseArray An array that contains N observation promises - * @returns {Promise} A promise that contains an array of Observations from multiple Datastreams when fulfilled - */ -const getObservationsFromMultipleDatastreams = async function ( - observationPromiseArray -) { - try { - // Array to store our final result - const observationsAllDatastreamsArr = []; - - // Use for/of loop - we need to maintain the order of execution of the async operations - for (const observationPromise of observationPromiseArray) { - // Observations from a single Datastream - const observations = await observationPromise; - observationsAllDatastreamsArr.push(observations); - } - - return observationsAllDatastreamsArr; - } catch (err) { - console.error(err); - } -}; - -/** - * Retrieve the metadata from a single Datastream or multiple Datastreams and the Observations corresponding to the Datastream(s) - * @param {String} baseUrl Base URL of the STA server - * @param {Object} urlParamObj The URL parameters to be sent together with the GET request - * @param {Array} bldgSensorSamplingRateArr A N*1 array (where N >= 1) containing a nested array of buildings, sensors & sampling rates as strings, i.e. [["101", "rl", "15min"]] or [["101", "rl", "15min"], ["102", "vl", "60min"]] or [["101", "rl", "15min"], ["102", "vl", "60min"], ["225", "vl", "60min"]], etc - * @returns {Promise} A promise that contains a 1*2 array (the first element is an array that contans N Observations arrays; and the second element is an array of N Datastream metadata objects) when fulfilled - */ -const getMetadataPlusObservationsFromSingleOrMultipleDatastreams = - async function (baseUrl, urlParamObj, bldgSensorSamplingRateArr) { - try { - if (!bldgSensorSamplingRateArr) return; - - // Datastreams IDs - const datastreamsIdsArr = bldgSensorSamplingRateArr.map( - (bldgSensorSamplingRate) => - getDatastreamIdFromBuildingNumber(...bldgSensorSamplingRate) - ); - - // Observations URLs - const observationsUrlArr = datastreamsIdsArr.map((datastreamId) => - createObservationsUrl(baseUrl, datastreamId) - ); - - // Datastreams URLs - const datastreamsUrlArr = datastreamsIdsArr.map((datastreamId) => - createDatastreamUrl(baseUrl, datastreamId) - ); - - // Promise objects - Observations - const observationsPromisesArr = observationsUrlArr.map((obsUrl) => - extractCombinedObservationsFromAllPages( - performGetRequestUsingAxios(obsUrl, urlParamObj) - ) - ); - - // Observations array - const observationsArr = await getObservationsFromMultipleDatastreams( - observationsPromisesArr - ); - - // Metadata array - const metadataArr = await getMetadataFromMultipleDatastreams( - datastreamsUrlArr - ); - - return [observationsArr, metadataArr]; - } catch (err) { - console.error(err); - } - }; - -/** - * Calculates the temperature difference, dT, between Vorlauf temperature [VL] and Rücklauf temperature [RL] (i.e., dT = VL - RL) - * @async - * @param {String} buildingId The building ID as a string - * @param {String} samplingRate The sampling rate as a string - * @returns {Promise} A promise that contains an array (that is made up of a temperature difference array and a metadata object) when fulfilled - */ -const calculateVorlaufMinusRuecklaufTemperature = async function ( - buildingId, - samplingRate -) { - try { - const bldgSensorSamplingRateArr = [ - [buildingId, "vl", samplingRate], - [buildingId, "rl", samplingRate], - ]; - - const BUILDING_ID = buildingId; - const SAMPLING_RATE = samplingRate; - - const observationsPlusMetadata = - await getMetadataPlusObservationsFromSingleOrMultipleDatastreams( - BASE_URL, - QUERY_PARAMS_COMBINED, - bldgSensorSamplingRateArr - ); - - // Extract Vorlauf temperature, Ruecklauf temperature and metadata - const [[vorlaufTemp, ruecklaufTemp], [metadataVorlauf, metadataRuecklauf]] = - observationsPlusMetadata; - - // Extract the temperature values - const vorlaufTempValues = vorlaufTemp.map((obs) => obs[1]); - const ruecklaufTempValues = ruecklaufTemp.map((obs) => obs[1]); - - // The arrays have equal length, we need only use one of them for looping - // Resulting array contains the following pairs (timestamp + dT) - const vorlaufMinusRuecklaufTemp = vorlaufTemp.map((obs, i) => [ - obs[0], - vorlaufTempValues[i] - ruecklaufTempValues[i], - ]); - - // From Vorlauf metadata, extract `name` and `unitOfMeasurement` - const { - name: datastreamNameVorlauf, - unitOfMeasurement: unitOfMeasurementVorlauf, - } = metadataVorlauf; - - // From Ruecklauf metadata, extract `name` - const { name: datastreamNameRuecklauf } = metadataRuecklauf; - - // Extract the phenomenon names from the Datastream names - const phenomenonNameVorlauf = extractPhenomenonNameFromDatastreamName( - datastreamNameVorlauf - ); - const phenomenonNameRuecklauf = extractPhenomenonNameFromDatastreamName( - datastreamNameRuecklauf - ); +import { + formatSensorThingsApiResponseForLineChart, + drawLineChartHighcharts, +} from "./src_modules/chartLine.js"; - // Create our custom datastream description text - // The resulting datastream description string has two `temperature` substrings; - // replace the first occurence with an empty string - const descriptionTempDifference = - `Computed dT: ${phenomenonNameVorlauf} minus ${phenomenonNameRuecklauf}`.replace( - "temperature", - "" - ); +import { + formatSensorThingsApiResponseForHeatMap, + drawHeatMapHighcharts, +} from "./src_modules/chartHeatmap.js"; - // Create our custom datastream name text - const nameTempDifference = `BOSCH_${BUILDING_ID} / dT Temperature difference (VL-RL) DS:${SAMPLING_RATE}`; +import { + formatSensorThingsApiResponseForScatterPlot, + drawScatterPlotHighcharts, +} from "./src_modules/chartScatterPlot.js"; - // The datastream object that we return needs to have these property names - const description = descriptionTempDifference; - const name = nameTempDifference; - const unitOfMeasurement = unitOfMeasurementVorlauf; +import { + formatDatastreamMetadataForChart, + getMetadataPlusObservationsFromSingleOrMultipleDatastreams, + calculateVorlaufMinusRuecklaufTemperature, +} from "./src_modules/fetchData.js"; - return [ - vorlaufMinusRuecklaufTemp, - { - description, - name, - unitOfMeasurement, - }, - ]; - } catch (err) { - console.error(err); - } -}; +import { + calculateSumOfObservationValuesWithinDatesInterval, + calculateSumOfObservationValuesWithinMonthInterval, + extractUniqueCalendarDatesFromTimestamp, + extractUniqueCalendarMonthsFromCalendarDates, +} from "./src_modules/aggregate.js"; /** * Test plotting of temp difference (dT) using heatmap */ const drawHeatmapHCUsingTempDifference = async function () { const [tempDifferenceObsArrBau225, tempDifferenceMetadataBau225] = - await calculateVorlaufMinusRuecklaufTemperature("225", "60min"); + await calculateVorlaufMinusRuecklaufTemperature( + BASE_URL, + QUERY_PARAMS_COMBINED, + "225", + "60min" + ); drawHeatMapHighcharts( formatSensorThingsApiResponseForHeatMap(tempDifferenceObsArrBau225), @@ -1251,17 +117,56 @@ const testLineChartMultipleSeries = async function () { drawLineChartHighcharts(formattedObservationsArr, formattedMetadataArr); }; +/** + * Test aggregation of observations from a single datastream + */ +const testAggregationSum = async function () { + const sensorOfInterestNestedArr = [["225", "vl", "60min"]]; + + const observationsPlusMetadata = + await getMetadataPlusObservationsFromSingleOrMultipleDatastreams( + BASE_URL, + QUERY_PARAMS_COMBINED, + sensorOfInterestNestedArr + ); + + // Extract the observations and metadata for each sensor + // Array elements in same order as input array + const [[obsSensorOneArr], [metadataSensorOne]] = observationsPlusMetadata; + + // Unique calendar dates + const uniqueCalendarDates = + extractUniqueCalendarDatesFromTimestamp(obsSensorOneArr); + + // Unique calendar months + const uniqueCalendarMonths = + extractUniqueCalendarMonthsFromCalendarDates(uniqueCalendarDates); + + // Calculate sum of values of observations - daily + const observationsBau225VLSumDaily = uniqueCalendarDates.map((calendarDate) => + calculateSumOfObservationValuesWithinDatesInterval( + obsSensorOneArr, + "60 min", + calendarDate, + calendarDate + ) + ); + + // Calculate sum of values of observations - monthly + const observationsBau225VLSumMonthly = uniqueCalendarMonths.map( + (calendarMonth) => + calculateSumOfObservationValuesWithinMonthInterval( + obsSensorOneArr, + "60 min", + calendarMonth + ) + ); + + console.log(observationsBau225VLSumDaily); + console.log(observationsBau225VLSumMonthly); +}; + // drawScatterPlotHCTest2(); // drawHeatmapHCUsingTempDifference(); // testLineChartMultipleSeries() - -export { - BASE_URL, - QUERY_PARAMS_COMBINED, - formatDatastreamMetadataForChart, - formatSensorThingsApiResponseForHeatMap, - drawHeatMapHighcharts, - formatSensorThingsApiResponseForLineChart, - drawLineChartHighcharts, - getMetadataPlusObservationsFromSingleOrMultipleDatastreams, -}; +// testAggregationSum(); diff --git a/public/js/dropDownList.js b/public/js/dropDownList.js index 44428a511095493510a9bfb001784744c98126d1..2addbdedc479e248f396cae29220d1498a74e7bf 100644 --- a/public/js/dropDownList.js +++ b/public/js/dropDownList.js @@ -1,15 +1,26 @@ "use strict"; +import { BASE_URL, QUERY_PARAMS_COMBINED } from "./src_modules/createUrl.js"; + import { - BASE_URL, - QUERY_PARAMS_COMBINED, formatDatastreamMetadataForChart, - formatSensorThingsApiResponseForHeatMap, - drawHeatMapHighcharts, + getMetadataPlusObservationsFromSingleOrMultipleDatastreams, +} from "./src_modules/fetchData.js"; + +import { formatSensorThingsApiResponseForLineChart, drawLineChartHighcharts, - getMetadataPlusObservationsFromSingleOrMultipleDatastreams, -} from "./appChart.js"; +} from "./src_modules/chartLine.js"; + +import { + formatSensorThingsApiResponseForHeatMap, + drawHeatMapHighcharts, +} from "./src_modules/chartHeatmap.js"; + +import { + showLoadingSpinner, + hideLoadingSpinner, +} from "./src_modules/loadingIndicator.js"; const buildingsAvailableSensorsArr = [ ["--Select--", "", ""], @@ -228,30 +239,6 @@ const getBuildingSensorSamplingRateAbbreviation = function ( return [buildingAbbrev, phenomenonAbbrev, samplingRateAbbrev]; }; -/** - * Show a loading indicator at the start of an async task. The indicator consists of a spinner and a transluscent mask placed on top of page elements - * @returns {undefined} - */ -const showLoadingSpinner = function () { - const loadingIndicatorMask = document.querySelector("#loadingIndicator"); - const loadingIconSpinner = document.querySelector("#loadingIcon"); - - loadingIndicatorMask.style.display = "block"; - loadingIconSpinner.style.display = "block"; -}; - -/** - * Hide the loading indicator after completion of the async tasks - * @returns {undefined} - */ -const hideLoadingSpinner = function () { - const loadingIndicatorMask = document.querySelector("#loadingIndicator"); - const loadingIconSpinner = document.querySelector("#loadingIcon"); - - loadingIndicatorMask.style.display = "none"; - loadingIconSpinner.style.display = "none"; -}; - /** * Callback function for chart selection using drop down list * @returns {undefined} diff --git a/public/js/aggregate.js b/public/js/src_modules/aggregate.js similarity index 51% rename from public/js/aggregate.js rename to public/js/src_modules/aggregate.js index 53148985a0498409e00b3d80e0b2f7f009b74d41..5c4bafbb096f706f745be793b6885bbd713a5009 100644 --- a/public/js/aggregate.js +++ b/public/js/src_modules/aggregate.js @@ -1,11 +1,5 @@ "use strict"; -import { - BASE_URL, - QUERY_PARAMS_COMBINED, - getMetadataPlusObservationsFromSingleOrMultipleDatastreams, -} from "./appChart.js"; - /** * Create 24-hour time strings for a time interval delimited by a start time and an end time. It is assumed that the start time is at "00:00:00" and the end time is at "23:45:00" (when the sampling rate of observations is 15 min) or "23:00:00" (when the sampling rate of observations is 60 min) * @param {String} phenomenonSamplingRate The sampling rate of the phenomenon of interest represented as a string, e.g. "15 min", "60 min" @@ -49,6 +43,15 @@ const createIso8601DateTimeString = function ( return `${inputCalendarDate}T${inputTwentyFourHourTime}.000Z`; }; +/** + * Check whether a year is a leap year or not + * @param {Number} year Integer representing the year + * @returns {Boolean} true if leap year, false if not + */ +const checkIfLeapYear = function (year) { + return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; +}; + /** * Calculate the index of a timestamp in an array of timestamps * @param {Array} inputTimestampArr An array of timestamps, extracted from an array of observations @@ -71,14 +74,14 @@ const getIndexOfTimestamp = function (inputTimestampArr, timestampOfInterest) { }; /** - * Aggregate observations within a time interval delimited by a start date and end date. The start date may be the same as the end date. + * Calculate the sum of observation values within a time interval delimited by a start date and end date. The start date may be the same as the end date. * @param {Array} obsArray An array of observations (timestamp + value) that is response from SensorThings API * @param {String} samplingRate The sampling rate of observations as a string, e.g. "15 min", "60 min" * @param {String} startDate A 24-hour date string representing the start date * @param {String} endDate A 24-hour date string representing the end date - * @returns {Number} A floating-point number representing the aggregated observation value + * @returns {Number} A floating-point number representing the sum of observation values */ -const aggregateObservationsWithinTimeInterval = function ( +const calculateSumOfObservationValuesWithinDatesInterval = function ( obsArray, samplingRate, startDate, @@ -120,41 +123,112 @@ const aggregateObservationsWithinTimeInterval = function ( indexEndTimestamp + 1 ); - // Calculate the aggregated observation value - const aggregatedObsValue = obsValuesForTimeIntervalArr.reduce( + return obsValuesForTimeIntervalArr.reduce( (accumulator, currentValue) => accumulator + currentValue ); - - return aggregatedObsValue; }; /** - * Test aggregation of observations from a single datastream + * Calculate the sum of observation values within a time interval delimited by the start date and end date of a calendar month + * @param {Array} obsArray An array of observations (timestamp + value) that is response from SensorThings API + * @param {String} samplingRate The sampling rate of observations as a string, e.g. "15 min", "60 min" + * @param {String} calendarMonthStr Calendar month string in "YYYY-MM" format + * @returns {Number} A floating-point number representing the sum of observation values */ -const testAggregation = async function () { - const sensorOfInterestNestedArr = [["225", "vl", "60min"]]; - - const observationsPlusMetadata = - await getMetadataPlusObservationsFromSingleOrMultipleDatastreams( - BASE_URL, - QUERY_PARAMS_COMBINED, - sensorOfInterestNestedArr +const calculateSumOfObservationValuesWithinMonthInterval = function ( + obsArray, + samplingRate, + calendarMonthStr +) { + // Extract year as integer + const yearNum = parseInt(calendarMonthStr.slice(0, 4), 10); + + // Extract month as integer + const monthNum = parseInt(calendarMonthStr.slice(-2), 10); + + if (monthNum < 1 || monthNum > 12) return; + + // All the months start on the first + const startDateStr = `${calendarMonthStr}-01`; + + // February + if (monthNum === 2) { + // Leap year + if (checkIfLeapYear(yearNum)) + return calculateSumOfObservationValuesWithinDatesInterval( + obsArray, + samplingRate, + startDateStr, + `${calendarMonthStr}-29` + ); + + // Non-leap year + return calculateSumOfObservationValuesWithinDatesInterval( + obsArray, + samplingRate, + startDateStr, + `${calendarMonthStr}-28` ); + } - // Extract the observations and metadata for each sensor - // Array elements in same order as input array - const [[obsSensorOneArr], [metadataSensorOne]] = observationsPlusMetadata; - - // Aggregated observations - const observationsBau225VLAggregated = - aggregateObservationsWithinTimeInterval( - obsSensorOneArr, - "60 min", - "2020-02-01", - "2020-03-31" + // Months with 30 days + if (monthNum === 4 || monthNum === 6 || monthNum === 9 || monthNum === 11) + return calculateSumOfObservationValuesWithinDatesInterval( + obsArray, + samplingRate, + startDateStr, + `${calendarMonthStr}-30` ); - // console.log(observationsBau225VLAggregated); + // Months with 31 days + return calculateSumOfObservationValuesWithinDatesInterval( + obsArray, + samplingRate, + startDateStr, + `${calendarMonthStr}-31` + ); +}; + +/** + * Extract unique calendar dates from date/time strings in ISO 8601 format + * @param {Array} obsArray An array of observations (timestamp + value) that is response from SensorThings API + * @returns {Array} An array of unique calendar date strings in "YYYY-MM-DD" format + */ +const extractUniqueCalendarDatesFromTimestamp = function (obsArray) { + // The timestamp is the first element of the observation array + const timestampArr = obsArray.map((obs) => obs[0]); + + // Extract the calendar date string from the timestamp string + const calendarDateArr = timestampArr.map((timestamp) => + timestamp.slice(0, 10) + ); + + // Use a set to remove duplicates + const uniqueCalendarDates = new Set(calendarDateArr); + + return [...uniqueCalendarDates]; }; -// testAggregation(); +/** + * Extract unique calendar months from calendar date strings + * @param {Array} calendarDatesArr An array of unique calendar date strings in "YYYY-MM-DD" format + * @returns {Array} An array of unique calendar month strings in "YYYY-MM" format + */ +const extractUniqueCalendarMonthsFromCalendarDates = function ( + calendarDatesArr +) { + // Extract the calendar month strings + const calendarMonthsArr = calendarDatesArr.map((date) => date.slice(0, 7)); + + // Use a set to remove duplicates + const uniqueCalendarMonths = new Set(calendarMonthsArr); + + return [...uniqueCalendarMonths]; +}; + +export { + calculateSumOfObservationValuesWithinDatesInterval, + calculateSumOfObservationValuesWithinMonthInterval, + extractUniqueCalendarDatesFromTimestamp, + extractUniqueCalendarMonthsFromCalendarDates, +}; diff --git a/public/js/src_modules/chartHeatmap.js b/public/js/src_modules/chartHeatmap.js new file mode 100644 index 0000000000000000000000000000000000000000..c1891121641946180a72179421c28541118c657f --- /dev/null +++ b/public/js/src_modules/chartHeatmap.js @@ -0,0 +1,167 @@ +"use strict"; + +/** + * Format the response from SensorThings API to make it suitable for use in a heatmap + * @param {Array} obsArray Array of observations (timestamp + value) that is response from SensorThings API + * @returns {Array} Array of formatted observations suitable for use in a heatmap + */ +const formatSensorThingsApiResponseForHeatMap = function (obsArray) { + if (!obsArray) return; + + const dataSTAFormatted = obsArray.map((obs) => { + // Get the date/time string; first element in input array; remove trailing "Z" + const obsDateTimeInput = obs[0].slice(0, -1); + // Get the "date" part of an observation + const obsDateInput = obs[0].slice(0, 10); + // Create Date objects + const obsDateTime = new Date(obsDateTimeInput); + const obsDate = new Date(obsDateInput); + // x-axis -> timestamp; will be the same for observations from the same date + const timestamp = Date.parse(obsDate); + // y-axis -> hourOfDay + const hourOfDay = obsDateTime.getHours(); + // value -> the observation's value; second element in input array + const value = obs[1]; + return [timestamp, hourOfDay, value]; + }); + + return dataSTAFormatted; +}; + +/** + * Calculate the minimum and maximum values for a heatmap's color axis + * @param {Array} formattedObsArrHeatmap Response from SensorThings API formatted for use in a heatmap + * @returns {Object} An object containing the minimum and maximum values + */ +const calculateMinMaxValuesForHeatmapColorAxis = function ( + formattedObsArrHeatmap +) { + // The observation value is the third element in array + const obsValueArr = formattedObsArrHeatmap.map((obs) => obs[2]); + + // Extract integer part + const minValue = Math.trunc(Math.min(...obsValueArr)); + const maxValue = Math.trunc(Math.max(...obsValueArr)); + + // Calculate the closest multiple of 5 + const minObsValue = minValue - (minValue % 5); + const maxObsValue = maxValue + (5 - (maxValue % 5)); + + return { minObsValue, maxObsValue }; +}; + +/** + * Draw a heatmap using Highcharts library + * @param {Array} formattedObsArrayForHeatmap Response from SensorThings API formatted for use in a heatmap + * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata + * @returns {undefined} undefined + */ +const drawHeatMapHighcharts = function ( + formattedObsArrayForHeatmap, + formattedDatastreamMetadata +) { + const { + datastreamDescription: DATASTREAM_DESCRIPTION, + datastreamName: DATASTREAM_NAME, + phenomenonName: PHENOMENON_NAME, + unitOfMeasurementSymbol: PHENOMENON_SYMBOL, + } = formattedDatastreamMetadata; + + const { + minObsValue: MINIMUM_VALUE_COLOR_AXIS, + maxObsValue: MAXIMUM_VALUE_COLOR_AXIS, + } = calculateMinMaxValuesForHeatmapColorAxis(formattedObsArrayForHeatmap); + + Highcharts.chart("chart-heatmap", { + chart: { + type: "heatmap", + zoomType: "x", + }, + + boost: { + useGPUTranslations: true, + }, + + title: { + text: DATASTREAM_DESCRIPTION, + align: "left", + x: 40, + }, + + subtitle: { + text: DATASTREAM_NAME, + align: "left", + x: 40, + }, + + xAxis: { + type: "datetime", + // min: Date.UTC(2017, 0, 1), + // max: Date.UTC(2017, 11, 31, 23, 59, 59), + labels: { + align: "left", + x: 5, + y: 14, + format: "{value:%B}", // long month + }, + showLastLabel: false, + tickLength: 16, + }, + + yAxis: { + title: { + text: null, + }, + labels: { + format: "{value}:00", + }, + minPadding: 0, + maxPadding: 0, + startOnTick: false, + endOnTick: false, + tickPositions: [0, 3, 6, 9, 12, 15, 18, 21, 24], + tickWidth: 1, + min: 0, + max: 23, + reversed: true, + }, + + colorAxis: { + stops: [ + [0, "#3060cf"], + [0.5, "#fffbbc"], + [0.9, "#c4463a"], + [1, "#c4463a"], + ], + min: MINIMUM_VALUE_COLOR_AXIS, + max: MAXIMUM_VALUE_COLOR_AXIS, + startOnTick: false, + endOnTick: false, + labels: { + // format: "{value}℃", + format: `{value}${PHENOMENON_SYMBOL}`, + }, + }, + + series: [ + { + data: formattedObsArrayForHeatmap, + boostThreshold: 100, + borderWidth: 0, + nullColor: "#525252", + colsize: 24 * 36e5, // one day + tooltip: { + headerFormat: `${PHENOMENON_NAME}<br/>`, + valueDecimals: 2, + pointFormat: + // "{point.x:%e %b, %Y} {point.y}:00: <b>{point.value} ℃</b>", + `{point.x:%e %b, %Y} {point.y}:00: <b>{point.value} ${PHENOMENON_SYMBOL}</b>`, + nullFormat: `{point.x:%e %b, %Y} {point.y}:00: <b>null</b>`, + }, + turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release + }, + ], + }); +}; + +export { formatSensorThingsApiResponseForHeatMap, drawHeatMapHighcharts }; diff --git a/public/js/src_modules/chartLine.js b/public/js/src_modules/chartLine.js new file mode 100644 index 0000000000000000000000000000000000000000..8a3d3ba93358c227818f1119173c6ddb1b12c59b --- /dev/null +++ b/public/js/src_modules/chartLine.js @@ -0,0 +1,159 @@ +"use strict"; + +/** + * Format the response from SensorThings API to make it suitable for use in a line chart + * @param {Array} obsArray Array of observations (timestamp + value) that is response from SensorThings API + * @returns {Array} Array of formatted observations suitable for use in a line chart + */ +const formatSensorThingsApiResponseForLineChart = function (obsArray) { + if (!obsArray) return; + + const dataSTAFormatted = obsArray.map((result) => { + const timestampObs = new Date(result[0].slice(0, -1)).getTime(); // slice() removes trailing "Z" character in timestamp + const valueObs = result[1]; + return [timestampObs, valueObs]; + }); + + return dataSTAFormatted; +}; + +/** + * Extract the properties that make up the formatted datastream metadata object(s) + * @param {Array} formattedDatastreamsMetadataArr An array of formatted metadata object(s) from one or more datastreams + * @returns {Object} An object that contains array(s) of formatted datastream metadata properties + */ +const extractPropertiesFromDatastreamMetadata = function ( + formattedDatastreamsMetadataArr +) { + // Create arrays from the properties of the formatted datastream metadata + const datastreamDescriptionsArr = formattedDatastreamsMetadataArr.map( + (datastreamMetadata) => datastreamMetadata.datastreamDescription + ); + + const datastreamNamesArr = formattedDatastreamsMetadataArr.map( + (datastreamMetadata) => datastreamMetadata.datastreamName + ); + + const phenomenonNamesArr = formattedDatastreamsMetadataArr.map( + (datastreamMetadata) => datastreamMetadata.phenomenonName + ); + + const unitOfMeasurementSymbolsArr = formattedDatastreamsMetadataArr.map( + (datastreamMetadata) => datastreamMetadata.unitOfMeasurementSymbol + ); + + return { + datastreamDescriptionsArr, + datastreamNamesArr, + phenomenonNamesArr, + unitOfMeasurementSymbolsArr, + }; +}; + +/** + * Extracts the sampling rate substring from a datastream name string + * @param {Array} datastreamNamesArr An array of datastream name(s) + * @returns {Array} An array containing the sampling rate substring(s) + */ +const extractSamplingRateFromDatastreamName = function (datastreamNamesArr) { + // The sampling rate string is the last word in the Datastream name string + return datastreamNamesArr.map((datastreamName) => + datastreamName.split(" ").pop() + ); +}; + +/** + * Concatenates metadata properties to create a string for either the title or subtitle of a line chart + * @param {Array} datastreamMetadataPropArr An array of metadata property strings + * @returns {String} A string of comma separated metadata property strings + */ +const createCombinedTextForLineChartTitles = function ( + datastreamMetadataPropArr +) { + return datastreamMetadataPropArr.join(", "); +}; + +/** + * Creates an options object for each series drawn in the line chart + * @param {Array} formattedObsArraysForLineChart An array of formatted observation array(s) from one or more datastreams + * @param {Array} phenomenonNamesArr An array of phenomenon name(s) + * @param {Array} phenomenonSymbolsArr An array of phenomenon symbol(s) + * @returns {Array} An array made up of series options object(s) + */ +const createSeriesOptionsForLineChart = function ( + formattedObsArraysForLineChart, + phenomenonNamesArr, + phenomenonSymbolsArr +) { + // An array of colors provided by the Highcharts object + const seriesColors = Highcharts.getOptions().colors; + + // Create an array of seriesOptions objects + // Assumes that the observation array of arrays, phenomenon names array and phenomenon symbols array are of equal length + // Use one of the arrays for looping + return formattedObsArraysForLineChart.map((formattedObsArray, i) => { + return { + name: `${phenomenonNamesArr[i]} (${phenomenonSymbolsArr[i]})`, + data: formattedObsArray, + color: seriesColors[i], + turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release + }; + }); +}; + +/** + * Draw a line chart using Highcharts library + * @param {Array} formattedObsArraysForLineChart An array made up of formatted observation array(s) suitable for use in a line chart + * @param {Object} formattedDatastreamMetadataArr An array made up of object(s) containing Datastream metadata + * @returns {undefined} undefined + */ +const drawLineChartHighcharts = function ( + formattedObsArraysForLineChart, + formattedDatastreamMetadataArr +) { + // Arrays of datastream properties + const { + datastreamNamesArr, + phenomenonNamesArr, + unitOfMeasurementSymbolsArr, + } = extractPropertiesFromDatastreamMetadata(formattedDatastreamMetadataArr); + + // Create the array of series options object(s) + const seriesOptionsArr = createSeriesOptionsForLineChart( + formattedObsArraysForLineChart, + phenomenonNamesArr, + unitOfMeasurementSymbolsArr + ); + + Highcharts.stockChart("chart-line", { + chart: { + zoomType: "x", + }, + + rangeSelector: { + selected: 5, + }, + + title: { + text: createCombinedTextForLineChartTitles(phenomenonNamesArr), + "align": "left", + }, + + subtitle: { + text: `Sampling rate(s): ${createCombinedTextForLineChartTitles( + extractSamplingRateFromDatastreamName(datastreamNamesArr) + )}`, + align: "left", + }, + + tooltip: { + pointFormat: + '<span style="color:{series.color}">{series.name}</span>: <b>{point.y}</b> <br/>', + valueDecimals: 2, + }, + + series: seriesOptionsArr, + }); +}; + +export { formatSensorThingsApiResponseForLineChart, drawLineChartHighcharts }; diff --git a/public/js/src_modules/chartScatterPlot.js b/public/js/src_modules/chartScatterPlot.js new file mode 100644 index 0000000000000000000000000000000000000000..00874c9caddbb20db087344e61a3cbe4659e29f1 --- /dev/null +++ b/public/js/src_modules/chartScatterPlot.js @@ -0,0 +1,349 @@ +"use strict"; + +/** + * Determines the timestamps that are missing from a smaller set of observations. Based on the comparison of two observation arrays, where one array is larger than the other + * @param {Array} obsTimestampArrayOne An array of timestamps for the first set of observations + * @param {Array} obsTimestampArrayTwo An array of timstamps for the second set of observations + * @returns {Array} An array of timestamps missing from either set of observations + */ +const getSymmetricDifferenceBetweenArrays = function ( + obsTimestampArrayOne, + obsTimestampArrayTwo +) { + const differenceBetweenArrays = obsTimestampArrayOne + .filter((timestampOne) => !obsTimestampArrayTwo.includes(timestampOne)) + .concat( + obsTimestampArrayTwo.filter( + (timestampTwo) => !obsTimestampArrayOne.includes(timestampTwo) + ) + ); + + return differenceBetweenArrays; +}; + +/** + * Determines the indexes of timestamps that are unique to the larger set of observatiuons. Based on the comparison of two observation arrays, where one array is larger than the other + * @param {Array} uniqueTimestampsArr An array of timestamps unique to the larger set of observations + * @param {Array} largerObsTimestampArr An array of timestamps for the larger set of observations + * @returns {Array} An array of the indexes of the missing observations + */ +const getIndexesOfUniqueObservations = function ( + uniqueTimestampsArr, + largerObsTimestampArr +) { + const indexesMissingObs = uniqueTimestampsArr.map((index) => + largerObsTimestampArr.indexOf(index) + ); + + return indexesMissingObs; +}; + +/** + * Removes observations (by modifying array in place) that are unique to a larger set of observations. Based on the comparison of two observation arrays, where one array is larger than the other + * @param {Array} uniqueIndexesArr An array of the indexes unique to the larger set of observations + * @param {Array} largerObsArr The larger array of observations (timestamp + value) + * @returns {Array} The larger array with the unique indexes removed + */ +const removeUniqueObservationsFromLargerArray = function ( + uniqueIndexesArr, + largerObsArr +) { + // Create a reversed copy of the indexes array, so that the larger index is removed first + const reversedUniqueIndexesArr = uniqueIndexesArr.reverse(); + + // Create a copy the larger observation array, will be modified in place + const processedLargerObsArr = largerObsArr; + + reversedUniqueIndexesArr.forEach((index) => { + if (index > -1) { + processedLargerObsArr.splice(index, 1); + } + }); + + return processedLargerObsArr; +}; + +/** + * Compares the length of two input arrays to determine the larger one + * @param {Array} firstArr First input array + * @param {Array} secondArr Second input array + * @returns {Array} The larger array + */ +const getLargerArrayBetweenTwoInputArrays = function (firstArr, secondArr) { + if (firstArr.length === secondArr.length) return; + + if (firstArr.length > secondArr.length) return firstArr; + + if (firstArr.length < secondArr.length) return secondArr; +}; + +/** + * Compares the length of two input arrays to determine the smaller one + * @param {Array} firstArr First input array + * @param {Array} secondArr Second input array + * @returns {Array} The smaller array + */ +const getSmallerArrayBetweenTwoInputArrays = function (firstArr, secondArr) { + if (firstArr.length === secondArr.length) return; + + if (firstArr.length < secondArr.length) return firstArr; + + if (firstArr.length > secondArr.length) return secondArr; +}; + +/** + * Utility function for deleting the unique observations from a larger array + * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API + * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API + * @returns {Array} Two arrays of observations (timestamp + value) with matching timestamps and equal lengths + */ +const deleteUniqueObservationsFromLargerArray = function ( + obsArrayOne, + obsArrayTwo +) { + // Create arrays with timestamps only + const obsArrayOneTimestamp = obsArrayOne.map( + (obsTimeValue) => obsTimeValue[0] + ); + const obsArrayTwoTimestamp = obsArrayTwo.map( + (obsTimeValue) => obsTimeValue[0] + ); + + const missingTimestamp = getSymmetricDifferenceBetweenArrays( + obsArrayOneTimestamp, + obsArrayTwoTimestamp + ); + + // Determine the larger observation timestamp array + const biggerObsTimestampArr = getLargerArrayBetweenTwoInputArrays( + obsArrayOneTimestamp, + obsArrayTwoTimestamp + ); + + // Indexes of the missing observations + const indexesMissingObsArr = getIndexesOfUniqueObservations( + missingTimestamp, + biggerObsTimestampArr + ); + + // Determine the larger observation array + const biggerObsArr = getLargerArrayBetweenTwoInputArrays( + obsArrayOne, + obsArrayTwo + ); + + // Determine the smaller observation array + const smallerObsArr = getSmallerArrayBetweenTwoInputArrays( + obsArrayOne, + obsArrayTwo + ); + + // Remove the missing observation from the larger array of observations + const modifiedBiggerObsArr = removeUniqueObservationsFromLargerArray( + indexesMissingObsArr, + biggerObsArr + ); + + return [modifiedBiggerObsArr, smallerObsArr]; +}; + +/** + * Utility function for deleting the unique observations from a larger array AND ensuring the order of input arrays is maintained + * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API + * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API + * @returns {Array} Two arrays of observations (timestamp + value) with matching timestamps and equal lengths + */ +const checkForAndDeleteUniqueObservationsFromLargerArray = function ( + obsArrayOne, + obsArrayTwo +) { + if (obsArrayOne.length === obsArrayTwo.length) return; + + // Case 1: obsArrayOne.length < obsArrayTwo.length + if (obsArrayOne.length < obsArrayTwo.length) { + const [biggerObsArr, smallerObsArr] = + deleteUniqueObservationsFromLargerArray(obsArrayOne, obsArrayTwo); + + return [smallerObsArr, biggerObsArr]; + } + + // Case 2: obsArrayOne.length > obsArrayTwo.length + return deleteUniqueObservationsFromLargerArray(obsArrayOne, obsArrayTwo); +}; + +/** + * Extracts and combines observation values from two input observation arrays of equal length + * @param {Array} obsArrayOne First set of N observations (timestamp + value) + * @param {Array} obsArrayTwo Second set of N observations (timestamp + value) + * @returns {Array} A N*2 array of observation values from both input observation arrays + */ +const createCombinedObservationValues = function (obsArrayOne, obsArrayTwo) { + // Extract the values from the two observation arrays + const obsValuesOne = obsArrayOne.map((result) => result[1]); + const obsValuesTwo = obsArrayTwo.map((result) => result[1]); + + // Since the arrays are of equal length, we need only use one of the arrays for looping + const obsValuesOnePlusTwo = obsValuesOne.map((obsValOne, i) => { + return [obsValOne, obsValuesTwo[i]]; + }); + + return obsValuesOnePlusTwo; +}; + +/** + * Format the response from SensorThings API to make it suitable for use in a scatter plot + * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API + * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API + * @returns {Array} Array of formatted observations suitable for use in a scatter plot + */ +const formatSensorThingsApiResponseForScatterPlot = function ( + obsArrayOne, + obsArrayTwo +) { + // When our observation arrays have DIFFERENT lengths + if (obsArrayOne.length !== obsArrayTwo.length) { + const [obsArrayOneFinal, obsArrayTwoFinal] = + checkForAndDeleteUniqueObservationsFromLargerArray( + obsArrayOne, + obsArrayTwo + ); + + return createCombinedObservationValues(obsArrayOneFinal, obsArrayTwoFinal); + } + + // When our observation arrays already have SAME lengths + return createCombinedObservationValues(obsArrayOne, obsArrayTwo); +}; + +/** + * Draw a scatter plot using Highcharts library + * @param {Array} formattedObsArrayForSeriesOnePlusSeriesTwo Response from SensorThings API formatted for use in a scatter plot + * @param {Object} formattedDatastreamMetadataSeriesOne Object containing Datastream metadata for the first chart series + * @param {Object} formattedDatastreamMetadataSeriesTwo Object containing Datastream metadata for the second chart series + * @returns {undefined} + */ +const drawScatterPlotHighcharts = function ( + formattedObsArrayForSeriesOnePlusSeriesTwo, + formattedDatastreamMetadataSeriesOne, + formattedDatastreamMetadataSeriesTwo +) { + const { + datastreamDescription: DATASTREAM_DESCRIPTION_SERIES_1, + datastreamName: DATASTREAM_NAME_SERIES_1, + phenomenonName: PHENOMENON_NAME_SERIES_1, + unitOfMeasurementSymbol: PHENOMENON_SYMBOL_SERIES_1, + } = formattedDatastreamMetadataSeriesOne; + + const { + datastreamDescription: DATASTREAM_DESCRIPTION_SERIES_2, + datastreamName: DATASTREAM_NAME_SERIES_2, + phenomenonName: PHENOMENON_NAME_SERIES_2, + unitOfMeasurementSymbol: PHENOMENON_SYMBOL_SERIES_2, + } = formattedDatastreamMetadataSeriesTwo; + + // Order of axes + // Y-Axis -- Series 2 + // X-Axis -- Series 1 + + const CHART_TITLE = `${PHENOMENON_NAME_SERIES_2} Versus ${PHENOMENON_NAME_SERIES_1}`; + const CHART_SUBTITLE = `Source: ${DATASTREAM_NAME_SERIES_2} & ${DATASTREAM_NAME_SERIES_1}`; + + const SERIES_1_NAME = `${PHENOMENON_NAME_SERIES_1}`; + const SERIES_1_SYMBOL = `${PHENOMENON_SYMBOL_SERIES_1}`; + + const SERIES_2_NAME = `${PHENOMENON_NAME_SERIES_2}`; + const SERIES_2_SYMBOL = `${PHENOMENON_SYMBOL_SERIES_2}`; + + const SERIES_COMBINED_NAME = "Y, X"; + const SERIES_COMBINED_SYMBOL_COLOR_RGB_ELEMENTS = "223, 83, 83"; + const SERIES_COMBINED_SYMBOL_COLOR_OPACITY = ".3"; + const SERIES_COMBINED_SYMBOL_COLOR = `rgba(${SERIES_COMBINED_SYMBOL_COLOR_RGB_ELEMENTS}, ${SERIES_COMBINED_SYMBOL_COLOR_OPACITY})`; + + const MARKER_RADIUS = 2; + + Highcharts.chart("chart-scatter-plot", { + chart: { + type: "scatter", + zoomType: "xy", + }, + + boost: { + useGPUTranslations: true, + usePreAllocated: true, + }, + + title: { + text: CHART_TITLE, + }, + + subtitle: { + text: CHART_SUBTITLE, + }, + + xAxis: { + labels: { + format: `{value}`, + }, + title: { + enabled: true, + text: `${SERIES_1_NAME} [${SERIES_1_SYMBOL}]`, + }, + startOnTick: true, + endOnTick: true, + showLastLabel: true, + }, + + yAxis: [ + { + labels: { + format: `{value}`, + }, + title: { + text: `${SERIES_2_NAME} [${SERIES_2_SYMBOL}]`, + }, + }, + ], + + legend: { + enabled: false, + }, + + plotOptions: { + scatter: { + marker: { + radius: MARKER_RADIUS, + states: { + hover: { + enabled: true, + lineColor: "rgb(100,100,100)", + }, + }, + }, + states: { + hover: { + marker: { + enabled: false, + }, + }, + }, + tooltip: { + headerFormat: "{series.name}<br>", + pointFormat: `<b>{point.y:.2f} ${SERIES_1_SYMBOL}, {point.x:.2f} ${SERIES_2_SYMBOL}</b>`, + }, + }, + }, + + series: [ + { + name: SERIES_COMBINED_NAME, + color: SERIES_COMBINED_SYMBOL_COLOR, + data: formattedObsArrayForSeriesOnePlusSeriesTwo, + }, + ], + }); +}; + +export { + formatSensorThingsApiResponseForScatterPlot, + drawScatterPlotHighcharts, +}; diff --git a/public/js/src_modules/createUrl.js b/public/js/src_modules/createUrl.js new file mode 100644 index 0000000000000000000000000000000000000000..4c2b8b6621965682ff98d18ef333a9249d401357 --- /dev/null +++ b/public/js/src_modules/createUrl.js @@ -0,0 +1,41 @@ +"use strict"; + +const BASE_URL = "http://193.196.39.91:8080/frost-icity-tp31/v1.1"; + +/** + * Create a temporal filter string for the fetched Observations + * @param {String} dateStart Start date in YYYY-MM-DD format + * @param {String} dateStop Stop date in YYYY-MM-DD format + * @returns {String} Temporal filter string + */ +const createTemporalFilterString = function (dateStart, dateStop) { + if (!dateStart || !dateStop) return; + return `resultTime ge ${dateStart}T00:00:00.000Z and resultTime le ${dateStop}T00:00:00.000Z`; +}; + +/** + * Create a query parameter object that should be sent together with a HTTP GET request using the Axios library + * @param {String} dateStart Start date (for temporal filter) in YYYY-MM-DD format + * @param {String} dateStop Stop date (for temporal filter) in YYYY-MM-DD format + * @returns {Object} A query parameter object + */ +const createUrlParametersForGetRequest = function (dateStart, dateStop) { + const QUERY_PARAM_RESULT_FORMAT = "dataArray"; + const QUERY_PARAM_ORDER_BY = "phenomenonTime asc"; + const QUERY_PARAM_FILTER = createTemporalFilterString(dateStart, dateStop); + const QUERY_PARAM_SELECT = "result,phenomenonTime"; + + return { + "$resultFormat": QUERY_PARAM_RESULT_FORMAT, + "$orderBy": QUERY_PARAM_ORDER_BY, + "$filter": QUERY_PARAM_FILTER, + "$select": QUERY_PARAM_SELECT, + }; +}; + +const QUERY_PARAMS_COMBINED = createUrlParametersForGetRequest( + "2020-01-01", + "2021-01-01" +); + +export { BASE_URL, QUERY_PARAMS_COMBINED }; diff --git a/public/js/src_modules/fetchData.js b/public/js/src_modules/fetchData.js new file mode 100644 index 0000000000000000000000000000000000000000..5fedf89d90ca5c4b5d026c7ed6e6dd181306f69d --- /dev/null +++ b/public/js/src_modules/fetchData.js @@ -0,0 +1,478 @@ +"use strict"; + +/** + * Retrieve the datastream ID that corresponds to a particular building + * @param {Number | String} buildingNumber Integer representing the building ID + * @param {String} phenomenon String representing the phenomenon of interest + * @param {String} samplingRate String representing the sampling rate of the observations + * @returns {Number} Datastream corresponding to the input building + */ +const getDatastreamIdFromBuildingNumber = function ( + buildingNumber, + phenomenon, + samplingRate +) { + const buildingToDatastreamMapping = { + 101: { + vl: { "15min": "69", "60min": "75" }, + rl: { "15min": "81", "60min": "87" }, + + // These Datastreams do not yet have Observations + // flow: { "15min": "93", "60min": "99" }, + // power: { "15min": "105", "60min": "111" }, + // energy: { "15min": "117", "60min": "123" }, + // energy_verb: { "15min": "129", "60min": "135" }, + }, + + 102: { + vl: { "15min": "70", "60min": "76" }, + rl: { "15min": "82", "60min": "88" }, + + // These Datastreams do not yet have Observations + // flow: { "15min": "94", "60min": "100" }, + // power: { "15min": "106", "60min": "112" }, + // energy: { "15min": "118", "60min": "124" }, + // energy_verb: { "15min": "130", "60min": "136" }, + }, + + 107: { + vl: { "15min": "71", "60min": "77" }, + rl: { "15min": "83", "60min": "89" }, + + // These Datastreams do not yet have Observations + // flow: { "15min": "95", "60min": "101" }, + // power: { "15min": "107", "60min": "113" }, + // energy: { "15min": "119", "60min": "125" }, + // energy_verb: { "15min": "131", "60min": "137" }, + }, + + "112, 118": { + vl: { "15min": "72", "60min": "78" }, + rl: { "15min": "84", "60min": "90" }, + + // These Datastreams do not yet have Observations + // flow: { "15min": "96", "60min": "102" }, + // power: { "15min": "108", "60min": "114" }, + // energy: { "15min": "120", "60min": "126" }, + // energy_verb: { "15min": "132", "60min": "138" }, + }, + + 125: { + vl: { "15min": "73", "60min": "79" }, + rl: { "15min": "85", "60min": "91" }, + + // These Datastreams do not yet have Observations + // flow: { "15min": "97", "60min": "103" }, + // power: { "15min": "109", "60min": "115" }, + // energy: { "15min": "121", "60min": "127" }, + // energy_verb: { "15min": "133", "60min": "139" }, + }, + + 225: { + vl: { "15min": "74", "60min": "80" }, + rl: { "15min": "86", "60min": "92" }, + flow: { "15min": "98", "60min": "104" }, + power: { "15min": "110", "60min": "116" }, + energy: { "15min": "122", "60min": "128" }, + energy_verb: { "15min": "134", "60min": "140" }, + }, + + weather_station_521: { + outside_temp: { "15min": "141", "60min": "142" }, + }, + }; + + if ( + buildingToDatastreamMapping?.[buildingNumber]?.[phenomenon]?.[ + samplingRate + ] === undefined + ) + return; + + return Number( + buildingToDatastreamMapping[buildingNumber][phenomenon][samplingRate] + ); +}; + +/** + * Create URL to fetch the details of single Datastream + * @param {String} baseUrl Base URL of the STA server + * @param {Number} datastreamID Integer representing the Datastream ID + * @returns {String} URL string for fetching a single Datastream + */ +const createDatastreamUrl = function (baseUrl, datastreamID) { + if (!datastreamID) return; + return `${baseUrl}/Datastreams(${datastreamID})`; +}; + +/** + * Create URL to fetch Observations + * @param {String} baseUrl Base URL of the STA server + * @param {Number} datastreamID Integer representing the Datastream ID + * @returns {String} URL string for fetching Observations + */ +const createObservationsUrl = function (baseUrl, datastreamID) { + if (!datastreamID) return; + return `${baseUrl}/Datastreams(${datastreamID})/Observations`; +}; + +/** + * Perform a GET request using the Axios library + * @param {String} urlObservations A URL that fetches Observations from an STA instance + * @param {Object} urlParamObj The URL parameters to be sent together with the GET request + * @returns {Promise} A promise that contains the first page of results when fulfilled + */ +const performGetRequestUsingAxios = function (urlObservations, urlParamObj) { + return axios.get(urlObservations, { + params: urlParamObj, + }); +}; + +/** + * Retrieve the metadata for a single datastream + * @async + * @param {String} urlDatastream A URL that fetches a Datastream from an STA instance + * @returns {Promise} A promise that contains a metadata object for a Datastream when fulfilled + */ +const getMetadataFromSingleDatastream = async function (urlDatastream) { + try { + // Extract properties of interest + const { + data: { description, name, unitOfMeasurement }, + } = await performGetRequestUsingAxios(urlDatastream); + + return { description, name, unitOfMeasurement }; + } catch (err) { + console.error(err); + } +}; + +/** + * Retrieve metadata from multiple datastreams + * @async + * @param {Array} datastreamsUrlArr An array that contains N Datastream URL strings + * @returns {Promise} A promise that contains an array of N Datastream metadata objects when fulfilled + */ +const getMetadataFromMultipleDatastreams = async function (datastreamsUrlArr) { + try { + // Array to store our final result + const datastreamMetadataArr = []; + + // Use for/of loop - we need to maintain the order of execution of the async operations + for (const datastreamUrl of datastreamsUrlArr) { + // Metadata from a single Datastream + const datastreamMetadata = await getMetadataFromSingleDatastream( + datastreamUrl + ); + datastreamMetadataArr.push(datastreamMetadata); + } + + return datastreamMetadataArr; + } catch (err) { + console.error(err); + } +}; + +/** + * Match the unitOfMeasurement's string representation of a symbol to an actual symbol, where necessary + * @param {String} unitOfMeasurementSymbolString String representation of the unitOfMeasurement's symbol + * @returns {String} The unitOfMeasurement's symbol + */ +const matchUnitOfMeasurementSymbolStringToSymbol = function ( + unitOfMeasurementSymbolString +) { + const unicodeCodePointDegreeSymbol = "\u00B0"; + const unicodeCodePointSuperscriptThree = "\u00B3"; + + if (unitOfMeasurementSymbolString === "degC") + return `${unicodeCodePointDegreeSymbol}C`; + + if (unitOfMeasurementSymbolString === "m3/h") + return `m${unicodeCodePointSuperscriptThree}/h`; + + // If no symbol exists + return unitOfMeasurementSymbolString; +}; + +/** + * Extract the phenomenon name from a Datastream's name + * @param {String} datastreamName A string representing the Datastream's name + * @returns {String} The extracted phenomenon name + */ +const extractPhenomenonNameFromDatastreamName = function (datastreamName) { + const regex = /\/ (.*) DS/; + return datastreamName.match(regex)[1]; // use second element in array +}; + +/** + * Format the response containing a Datastream's metadata from Sensorthings API + * @param {Object} datastreamMetadata An object containing a Datastream's metadata + * @returns {Object} An object containing the formatted metadata that is suitable for use in a line chart or heatmap + */ +const formatDatastreamMetadataForChart = function (datastreamMetadata) { + const { + description: datastreamDescription, + name: datastreamName, + unitOfMeasurement, + } = datastreamMetadata; + + // Extract phenomenon name from Datastream name + const phenomenonName = + extractPhenomenonNameFromDatastreamName(datastreamName); + + // Get the unitOfMeasurement's symbol + const unitOfMeasurementSymbol = matchUnitOfMeasurementSymbolStringToSymbol( + unitOfMeasurement.symbol + ); + + return { + datastreamDescription, + datastreamName, + phenomenonName, + unitOfMeasurementSymbol, + }; +}; + +/** + * Traverses all the pages that make up the response from a SensorThingsAPI instance. The link to the next page, if present, is denoted by the presence of a "@iot.nextLink" property in the response object. This function concatenates all the values so that the complete results are returned in one array. + * @async + * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request + * @returns {Promise} A promise that contains an object containing results from all the pages when fulfilled + */ +const combineResultsFromAllPages = async function (httpGetRequestPromise) { + try { + if (!httpGetRequestPromise) return; + + const lastSuccess = await httpGetRequestPromise; + + // The "success" objects contain a "data" object which in turn has a "value" property + // If the "data" object in turn has a "@iot.nextLink" property, then a next page exists + if (lastSuccess.data["@iot.nextLink"]) { + const nextLinkSuccess = await combineResultsFromAllPages( + axios.get(lastSuccess.data["@iot.nextLink"]) + ); + // The "data" object in turn has a "value" property + // The "value" property's value is an array + nextLinkSuccess.data.value = lastSuccess.data.value.concat( + nextLinkSuccess.data.value + ); + return nextLinkSuccess; + } else { + return lastSuccess; + } + } catch (err) { + console.error(err); + } +}; + +/** + * Traverses all the pages that make up the response from a SensorThingsAPI instance and extracts the combined Observations + * @async + * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request + * @returns {Promise} A promise that contains an array of Observations when fulfilled + */ +const extractCombinedObservationsFromAllPages = async function ( + httpGetRequestPromise +) { + try { + const successResponse = await combineResultsFromAllPages( + httpGetRequestPromise + ); + + // Extract value array from the success response object + const { + data, + data: { value: valueArr }, + } = successResponse; + + // Array that will hold the combined observations + const combinedObservations = []; + + valueArr.forEach((val) => { + // Each page of results will have a dataArray that holds the observations + const { dataArray } = val; + combinedObservations.push(...dataArray); + }); + + return new Promise((resolve, reject) => { + resolve(combinedObservations); + }); + } catch (err) { + console.error(err); + } +}; + +/** + * Retrieve all the Observations from an array of Observations promises + * @async + * @param {Promise} observationPromiseArray An array that contains N observation promises + * @returns {Promise} A promise that contains an array of Observations from multiple Datastreams when fulfilled + */ +const getObservationsFromMultipleDatastreams = async function ( + observationPromiseArray +) { + try { + // Array to store our final result + const observationsAllDatastreamsArr = []; + + // Use for/of loop - we need to maintain the order of execution of the async operations + for (const observationPromise of observationPromiseArray) { + // Observations from a single Datastream + const observations = await observationPromise; + observationsAllDatastreamsArr.push(observations); + } + + return observationsAllDatastreamsArr; + } catch (err) { + console.error(err); + } +}; + +/** + * Retrieve the metadata from a single Datastream or multiple Datastreams and the Observations corresponding to the Datastream(s) + * @param {String} baseUrl Base URL of the STA server + * @param {Object} urlParamObj The URL parameters to be sent together with the GET request + * @param {Array} bldgSensorSamplingRateArr A N*1 array (where N >= 1) containing a nested array of buildings, sensors & sampling rates as strings, i.e. [["101", "rl", "15min"]] or [["101", "rl", "15min"], ["102", "vl", "60min"]] or [["101", "rl", "15min"], ["102", "vl", "60min"], ["225", "vl", "60min"]], etc + * @returns {Promise} A promise that contains a 1*2 array (the first element is an array that contans N Observations arrays; and the second element is an array of N Datastream metadata objects) when fulfilled + */ +const getMetadataPlusObservationsFromSingleOrMultipleDatastreams = + async function (baseUrl, urlParamObj, bldgSensorSamplingRateArr) { + try { + if (!bldgSensorSamplingRateArr) return; + + // Datastreams IDs + const datastreamsIdsArr = bldgSensorSamplingRateArr.map( + (bldgSensorSamplingRate) => + getDatastreamIdFromBuildingNumber(...bldgSensorSamplingRate) + ); + + // Observations URLs + const observationsUrlArr = datastreamsIdsArr.map((datastreamId) => + createObservationsUrl(baseUrl, datastreamId) + ); + + // Datastreams URLs + const datastreamsUrlArr = datastreamsIdsArr.map((datastreamId) => + createDatastreamUrl(baseUrl, datastreamId) + ); + + // Promise objects - Observations + const observationsPromisesArr = observationsUrlArr.map((obsUrl) => + extractCombinedObservationsFromAllPages( + performGetRequestUsingAxios(obsUrl, urlParamObj) + ) + ); + + // Observations array + const observationsArr = await getObservationsFromMultipleDatastreams( + observationsPromisesArr + ); + + // Metadata array + const metadataArr = await getMetadataFromMultipleDatastreams( + datastreamsUrlArr + ); + + return [observationsArr, metadataArr]; + } catch (err) { + console.error(err); + } + }; + +/** + * Calculate the temperature difference, dT, between Vorlauf temperature [VL] and Rücklauf temperature [RL] (i.e., dT = VL - RL) + * @param {String} baseUrl Base URL of the STA server + * @param {Object} urlParams The URL parameters to be sent together with the GET request + * @param {String} buildingId The building ID as a string + * @param {String} samplingRate The sampling rate as a string + * @returns {Promise} A promise that contains an array (that is made up of a temperature difference array and a metadata object) when fulfilled + */ +const calculateVorlaufMinusRuecklaufTemperature = async function ( + baseUrl, + urlParams, + buildingId, + samplingRate +) { + try { + const bldgSensorSamplingRateArr = [ + [buildingId, "vl", samplingRate], + [buildingId, "rl", samplingRate], + ]; + + const BUILDING_ID = buildingId; + const SAMPLING_RATE = samplingRate; + + const observationsPlusMetadata = + await getMetadataPlusObservationsFromSingleOrMultipleDatastreams( + baseUrl, + urlParams, + bldgSensorSamplingRateArr + ); + + // Extract Vorlauf temperature, Ruecklauf temperature and metadata + const [[vorlaufTemp, ruecklaufTemp], [metadataVorlauf, metadataRuecklauf]] = + observationsPlusMetadata; + + // Extract the temperature values + const vorlaufTempValues = vorlaufTemp.map((obs) => obs[1]); + const ruecklaufTempValues = ruecklaufTemp.map((obs) => obs[1]); + + // The arrays have equal length, we need only use one of them for looping + // Resulting array contains the following pairs (timestamp + dT) + const vorlaufMinusRuecklaufTemp = vorlaufTemp.map((obs, i) => [ + obs[0], + vorlaufTempValues[i] - ruecklaufTempValues[i], + ]); + + // From Vorlauf metadata, extract `name` and `unitOfMeasurement` + const { + name: datastreamNameVorlauf, + unitOfMeasurement: unitOfMeasurementVorlauf, + } = metadataVorlauf; + + // From Ruecklauf metadata, extract `name` + const { name: datastreamNameRuecklauf } = metadataRuecklauf; + + // Extract the phenomenon names from the Datastream names + const phenomenonNameVorlauf = extractPhenomenonNameFromDatastreamName( + datastreamNameVorlauf + ); + const phenomenonNameRuecklauf = extractPhenomenonNameFromDatastreamName( + datastreamNameRuecklauf + ); + + // Create our custom datastream description text + // The resulting datastream description string has two `temperature` substrings; + // replace the first occurence with an empty string + const descriptionTempDifference = + `Computed dT: ${phenomenonNameVorlauf} minus ${phenomenonNameRuecklauf}`.replace( + "temperature", + "" + ); + + // Create our custom datastream name text + const nameTempDifference = `BOSCH_${BUILDING_ID} / dT Temperature difference (VL-RL) DS:${SAMPLING_RATE}`; + + // The datastream object that we return needs to have these property names + const description = descriptionTempDifference; + const name = nameTempDifference; + const unitOfMeasurement = unitOfMeasurementVorlauf; + + return [ + vorlaufMinusRuecklaufTemp, + { + description, + name, + unitOfMeasurement, + }, + ]; + } catch (err) { + console.error(err); + } +}; + +export { + formatDatastreamMetadataForChart, + getMetadataPlusObservationsFromSingleOrMultipleDatastreams, + calculateVorlaufMinusRuecklaufTemperature, +}; diff --git a/public/js/src_modules/loadingIndicator.js b/public/js/src_modules/loadingIndicator.js new file mode 100644 index 0000000000000000000000000000000000000000..c8465f1339fe71c88fc3c7936b3174ebfe1cee5f --- /dev/null +++ b/public/js/src_modules/loadingIndicator.js @@ -0,0 +1,27 @@ +"use strict"; + +/** + * Show a loading indicator at the start of an async task. The indicator consists of a spinner and a transluscent mask placed on top of page elements + * @returns {undefined} + */ +const showLoadingSpinner = function () { + const loadingIndicatorMask = document.querySelector("#loadingIndicator"); + const loadingIconSpinner = document.querySelector("#loadingIcon"); + + loadingIndicatorMask.style.display = "block"; + loadingIconSpinner.style.display = "block"; +}; + +/** + * Hide the loading indicator after completion of the async tasks + * @returns {undefined} + */ +const hideLoadingSpinner = function () { + const loadingIndicatorMask = document.querySelector("#loadingIndicator"); + const loadingIconSpinner = document.querySelector("#loadingIcon"); + + loadingIndicatorMask.style.display = "none"; + loadingIconSpinner.style.display = "none"; +}; + +export { showLoadingSpinner, hideLoadingSpinner };