diff --git a/index.html b/index.html
index caf70127f79b058c7bb59f149ba8dddf3b297b5d..efcc355baa5f069a0b62b9d85a3962c98da8ba0e 100644
--- a/index.html
+++ b/index.html
@@ -42,6 +42,7 @@
     <script src="https://code.highcharts.com/modules/boost-canvas.js"></script>
     <script src="https://code.highcharts.com/modules/boost.js"></script>
     <script src="https://code.highcharts.com/modules/accessibility.js"></script>
+    <script src="https://code.highcharts.com/modules/offline-exporting.js"></script>
 
     <!-- Cesium -->
     <script src="https://cesium.com/downloads/cesiumjs/releases/1.48/Build/Cesium/Cesium.js"></script>
diff --git a/public/js/appChart.js b/public/js/appChart.js
index 85c3007991fa5037d51ea0471bf02804e49d3f77..a73d8a73d008f6ea7d70b6737ced452265acf7ec 100644
--- a/public/js/appChart.js
+++ b/public/js/appChart.js
@@ -1,40 +1,44 @@
 "use strict";
 
-import { BASE_URL, QUERY_PARAMS_COMBINED } from "./src_modules/createUrl.js";
+import {
+  BASE_URL,
+  QUERY_PARAMS_COMBINED,
+} from "./src_modules/baseUrlPlusQueryParams.mjs";
 
 import {
   formatSensorThingsApiResponseForLineChart,
   drawLineChartHighcharts,
-} from "./src_modules/chartLine.js";
+} from "./src_modules/chartLine.mjs";
 
 import {
   formatSensorThingsApiResponseForHeatMap,
   drawHeatMapHighcharts,
-} from "./src_modules/chartHeatmap.js";
+} from "./src_modules/chartHeatmap.mjs";
 
 import {
   formatSensorThingsApiResponseForScatterPlot,
   drawScatterPlotHighcharts,
-} from "./src_modules/chartScatterPlot.js";
+} from "./src_modules/chartScatterPlot.mjs";
 
 import {
   formatAggregationResultForColumnChart,
   drawColumnChartHighcharts,
-} from "./src_modules/chartColumn.js";
+} from "./src_modules/chartColumn.mjs";
 
 import {
   formatDatastreamMetadataForChart,
   extractPropertiesFromFormattedDatastreamMetadata,
   getMetadataPlusObservationsFromSingleOrMultipleDatastreams,
-  calculateVorlaufMinusRuecklaufTemperature,
-} from "./src_modules/fetchData.js";
+} from "./src_modules/fetchData.mjs";
+
+import { calculateVorlaufMinusRuecklaufTemperature } from "./src_modules/calculateTemperatureDiff.mjs";
 
 import {
-  calculateSumOfObservationValuesWithinDatesInterval,
-  calculateSumOfObservationValuesWithinMonthInterval,
+  calculateSumOfObservationValuesWithinInterval,
   extractUniqueCalendarDatesFromTimestamp,
   extractUniqueCalendarMonthsFromCalendarDates,
-} from "./src_modules/aggregate.js";
+  calculateAverageOfObservationValuesWithinInterval,
+} from "./src_modules/aggregate.mjs";
 
 /**
  * Test plotting of temp difference (dT) using heatmap
@@ -169,7 +173,7 @@ const testLineChartMultipleSeries = async function () {
 };
 
 /**
- * Test drawing of column chart using aggregation result
+ * Test drawing of column chart using aggregation / sum result - monthly
  */
 const drawColumnChartMonthlySumTest = async function () {
   const sensorsOfInterestNestedArr = [
@@ -200,42 +204,213 @@ const drawColumnChartMonthlySumTest = async function () {
       extractUniqueCalendarMonthsFromCalendarDates(uniqueCalendarDatesArr)
   );
 
-  // Calculate sum of values of observations - daily
-  // Note the two nested `map` methods
-  const observationsSumDailyNestedArr = uniqueCalendarDatesNestedArr.map(
-    (uniqueCalendarDatesArr, i) =>
-      uniqueCalendarDatesArr.map((uniqueCalendarDate) =>
-        calculateSumOfObservationValuesWithinDatesInterval(
-          observationsNestedArr[i],
-          "60 min",
-          uniqueCalendarDate,
-          uniqueCalendarDate
-        )
+  // Calculate sum of values of observations - monthly
+  const observationsSumMonthlyNestedArr =
+    calculateSumOfObservationValuesWithinInterval(
+      observationsNestedArr,
+      "60min",
+      uniqueCalendarMonthsNestedArr,
+      "monthly"
+    );
+
+  // Format the observations
+  const formattedObservationsSumMonthlyNestedArr =
+    observationsSumMonthlyNestedArr.map((obsSumMonthlyArr, i) =>
+      formatAggregationResultForColumnChart(
+        uniqueCalendarMonthsNestedArr[i],
+        obsSumMonthlyArr
       )
+    );
+
+  // Format the metadata
+  const formattedMetadataNestedArr = metadataNestedArr.map((metadataObj) =>
+    formatDatastreamMetadataForChart(metadataObj)
   );
 
-  // Calculate sum of values of observations - monthly
-  // Note the two nested `map` methods
-  const observationsSumMonthlyNestedArr = uniqueCalendarMonthsNestedArr.map(
-    (uniqueCalendarMonthsArr, i) =>
-      uniqueCalendarMonthsArr.map((uniqueCalendarMonth) =>
-        calculateSumOfObservationValuesWithinMonthInterval(
-          observationsNestedArr[i],
-          "60 min",
-          uniqueCalendarMonth
-        )
+  // Extract the formatted metadata properties
+  const extractedFormattedDatastreamProperties =
+    extractPropertiesFromFormattedDatastreamMetadata(
+      formattedMetadataNestedArr
+    );
+
+  drawColumnChartHighcharts(
+    formattedObservationsSumMonthlyNestedArr,
+    extractedFormattedDatastreamProperties
+  );
+};
+
+/**
+ * Test drawing of column chart using aggregation / sum result - daily
+ */
+const drawColumnChartDailySumTest = async function () {
+  const sensorsOfInterestNestedArr = [
+    ["125", "vl", "60min"],
+    ["225", "vl", "60min"],
+  ];
+
+  const observationsPlusMetadata =
+    await getMetadataPlusObservationsFromSingleOrMultipleDatastreams(
+      BASE_URL,
+      QUERY_PARAMS_COMBINED,
+      sensorsOfInterestNestedArr
+    );
+
+  // Extract the observations and metadata for each sensor
+  // Array elements in same order as input array
+  const [observationsNestedArr, metadataNestedArr] = observationsPlusMetadata;
+
+  // Unique calendar dates
+  const uniqueCalendarDatesNestedArr = observationsNestedArr.map(
+    (observationsArr) =>
+      extractUniqueCalendarDatesFromTimestamp(observationsArr)
+  );
+
+  // Calculate sum of values of observations - daily
+  const observationsSumDailyNestedArr =
+    calculateSumOfObservationValuesWithinInterval(
+      observationsNestedArr,
+      "60min",
+      uniqueCalendarDatesNestedArr,
+      "daily"
+    );
+
+  // Format the observations - daily
+  const formattedObservationsSumDailyNestedArr =
+    observationsSumDailyNestedArr.map((obsSumDailyArr, i) =>
+      formatAggregationResultForColumnChart(
+        uniqueCalendarDatesNestedArr[i],
+        obsSumDailyArr
       )
+    );
+
+  // Format the metadata
+  const formattedMetadataNestedArr = metadataNestedArr.map((metadataObj) =>
+    formatDatastreamMetadataForChart(metadataObj)
+  );
+
+  // Extract the formatted metadata properties
+  const extractedFormattedDatastreamProperties =
+    extractPropertiesFromFormattedDatastreamMetadata(
+      formattedMetadataNestedArr
+    );
+
+  drawColumnChartHighcharts(
+    formattedObservationsSumDailyNestedArr,
+    extractedFormattedDatastreamProperties
+  );
+};
+
+/**
+ * Test drawing of line chart using aggregation / average result - monthly
+ */
+const drawLineChartMonthlyAverageTest = async function () {
+  const sensorsOfInterestNestedArr = [
+    ["125", "vl", "60min"],
+    ["225", "vl", "60min"],
+  ];
+
+  const observationsPlusMetadata =
+    await getMetadataPlusObservationsFromSingleOrMultipleDatastreams(
+      BASE_URL,
+      QUERY_PARAMS_COMBINED,
+      sensorsOfInterestNestedArr
+    );
+
+  // Extract the observations and metadata for each sensor
+  // Array elements in same order as input array
+  const [observationsNestedArr, metadataNestedArr] = observationsPlusMetadata;
+
+  // Unique calendar dates
+  const uniqueCalendarDatesNestedArr = observationsNestedArr.map(
+    (observationsArr) =>
+      extractUniqueCalendarDatesFromTimestamp(observationsArr)
   );
 
+  // Unique calendar months
+  const uniqueCalendarMonthsNestedArr = uniqueCalendarDatesNestedArr.map(
+    (uniqueCalendarDatesArr) =>
+      extractUniqueCalendarMonthsFromCalendarDates(uniqueCalendarDatesArr)
+  );
+
+  // Calculate average of values of observations - monthly
+  const observationsAverageMonthlyNestedArr =
+    calculateAverageOfObservationValuesWithinInterval(
+      observationsNestedArr,
+      "60min",
+      uniqueCalendarMonthsNestedArr,
+      "monthly"
+    );
+
   // Format the observations
-  const formattedObservationsNestedArr = observationsSumMonthlyNestedArr.map(
-    (obsSumMonthlyArr, i) =>
+  const formattedObservationsAverageMonthlyNestedArr =
+    observationsAverageMonthlyNestedArr.map((obsAverageMonthlyArr, i) =>
       formatAggregationResultForColumnChart(
         uniqueCalendarMonthsNestedArr[i],
-        obsSumMonthlyArr
+        obsAverageMonthlyArr
       )
+    );
+
+  //  Format the metadata
+  const formattedMetadataNestedArr = metadataNestedArr.map((metadataObj) =>
+    formatDatastreamMetadataForChart(metadataObj)
+  );
+
+  // Extract the formatted metadata properties
+  const extractedFormattedDatastreamProperties =
+    extractPropertiesFromFormattedDatastreamMetadata(
+      formattedMetadataNestedArr
+    );
+
+  drawLineChartHighcharts(
+    formattedObservationsAverageMonthlyNestedArr,
+    extractedFormattedDatastreamProperties
+  );
+};
+
+/**
+ * Test drawing of line chart using aggregation / average result - daily
+ */
+const drawLineChartDailyAverageTest = async function () {
+  const sensorsOfInterestNestedArr = [
+    ["125", "vl", "60min"],
+    ["225", "vl", "60min"],
+  ];
+
+  const observationsPlusMetadata =
+    await getMetadataPlusObservationsFromSingleOrMultipleDatastreams(
+      BASE_URL,
+      QUERY_PARAMS_COMBINED,
+      sensorsOfInterestNestedArr
+    );
+
+  // Extract the observations and metadata for each sensor
+  // Array elements in same order as input array
+  const [observationsNestedArr, metadataNestedArr] = observationsPlusMetadata;
+
+  // Unique calendar dates
+  const uniqueCalendarDatesNestedArr = observationsNestedArr.map(
+    (observationsArr) =>
+      extractUniqueCalendarDatesFromTimestamp(observationsArr)
   );
 
+  // Calculate average of values of observations - daily
+  const observationsAverageDailyNestedArr =
+    calculateAverageOfObservationValuesWithinInterval(
+      observationsNestedArr,
+      "60min",
+      uniqueCalendarDatesNestedArr,
+      "daily"
+    );
+
+  // Format the observations - daily
+  const formattedObservationsAverageDailyNestedArr =
+    observationsAverageDailyNestedArr.map((obsAverageDailyArr, i) =>
+      formatAggregationResultForColumnChart(
+        uniqueCalendarDatesNestedArr[i],
+        obsAverageDailyArr
+      )
+    );
+
   // Format the metadata
   const formattedMetadataNestedArr = metadataNestedArr.map((metadataObj) =>
     formatDatastreamMetadataForChart(metadataObj)
@@ -247,8 +422,8 @@ const drawColumnChartMonthlySumTest = async function () {
       formattedMetadataNestedArr
     );
 
-  drawColumnChartHighcharts(
-    formattedObservationsNestedArr,
+  drawLineChartHighcharts(
+    formattedObservationsAverageDailyNestedArr,
     extractedFormattedDatastreamProperties
   );
 };
@@ -257,3 +432,6 @@ const drawColumnChartMonthlySumTest = async function () {
 // drawHeatmapHCUsingTempDifference();
 // testLineChartMultipleSeries();
 // drawColumnChartMonthlySumTest();
+// drawColumnChartDailySumTest();
+// drawLineChartMonthlyAverageTest();
+// drawLineChartDailyAverageTest();
diff --git a/public/js/dropDownList.js b/public/js/dropDownList.js
index 9bda35e9b3684d4faf9fe30b57a50a2d459636f3..e498776f2dede2f9f30e4fda1b3ad0bdf2b1cd93 100644
--- a/public/js/dropDownList.js
+++ b/public/js/dropDownList.js
@@ -1,27 +1,30 @@
 "use strict";
 
-import { BASE_URL, QUERY_PARAMS_COMBINED } from "./src_modules/createUrl.js";
+import {
+  BASE_URL,
+  QUERY_PARAMS_COMBINED,
+} from "./src_modules/baseUrlPlusQueryParams.mjs";
 
 import {
   formatDatastreamMetadataForChart,
   extractPropertiesFromFormattedDatastreamMetadata,
   getMetadataPlusObservationsFromSingleOrMultipleDatastreams,
-} from "./src_modules/fetchData.js";
+} from "./src_modules/fetchData.mjs";
 
 import {
   formatSensorThingsApiResponseForLineChart,
   drawLineChartHighcharts,
-} from "./src_modules/chartLine.js";
+} from "./src_modules/chartLine.mjs";
 
 import {
   formatSensorThingsApiResponseForHeatMap,
   drawHeatMapHighcharts,
-} from "./src_modules/chartHeatmap.js";
+} from "./src_modules/chartHeatmap.mjs";
 
 import {
   showLoadingSpinner,
   hideLoadingSpinner,
-} from "./src_modules/loadingIndicator.js";
+} from "./src_modules/loadingIndicator.mjs";
 
 const buildingsAvailableSensorsArr = [
   ["--Select--", "", ""],
@@ -276,13 +279,13 @@ const selectChartTypeFromDropDown = async function () {
     const [observationsNestedArr, metadataNestedArr] = observationsPlusMetadata;
 
     // Create formatted array(s) for observations - line chart
-    const formattedObsLineChartArr = observationsNestedArr.map(
+    const formattedObsLineChartNestedArr = observationsNestedArr.map(
       (observationsArr) =>
         formatSensorThingsApiResponseForLineChart(observationsArr)
     );
 
     // Create formatted array(s) for observations - heatmap
-    const formattedObsHeatMapArr = observationsNestedArr.map(
+    const formattedObsHeatMapNestedArr = observationsNestedArr.map(
       (observationsArr) =>
         formatSensorThingsApiResponseForHeatMap(observationsArr)
     );
@@ -298,14 +301,14 @@ const selectChartTypeFromDropDown = async function () {
 
     if (selectedChartType === "Line") {
       drawLineChartHighcharts(
-        formattedObsLineChartArr,
+        formattedObsLineChartNestedArr,
         extractedFormattedDatastreamProperties
       );
     } else if (selectedChartType === "Heatmap") {
       // First need to extract the formatted observations from the nested array
       // Heatmap only needs one set of formatted observation values
       drawHeatMapHighcharts(
-        ...formattedObsHeatMapArr,
+        ...formattedObsHeatMapNestedArr,
         extractedFormattedDatastreamProperties
       );
     }
diff --git a/public/js/src_modules/aggregate.js b/public/js/src_modules/aggregate.js
deleted file mode 100644
index 5c4bafbb096f706f745be793b6885bbd713a5009..0000000000000000000000000000000000000000
--- a/public/js/src_modules/aggregate.js
+++ /dev/null
@@ -1,234 +0,0 @@
-"use strict";
-
-/**
- * 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"
- * @returns {Array} An array of two 24-hour strings representing the start time and end time
- */
-const createTimeStringsForInterval = function (phenomenonSamplingRate) {
-  const fifteenMinutes = "15 min";
-  const sixtyMinutes = "60 min";
-
-  const startTime = "00:00:00";
-  const endTimeFifteenMinutes = "23:45:00";
-  const endTimeSixtyMinutes = "23:00:00";
-
-  if (
-    phenomenonSamplingRate !== fifteenMinutes &&
-    phenomenonSamplingRate !== sixtyMinutes
-  )
-    return;
-
-  // 15 min sampling rate
-  if (phenomenonSamplingRate === fifteenMinutes) {
-    return [startTime, endTimeFifteenMinutes];
-  }
-
-  // 60 min sampling rate
-  if (phenomenonSamplingRate === sixtyMinutes) {
-    return [startTime, endTimeSixtyMinutes];
-  }
-};
-
-/**
- * Create an ISO 8601 date and time string
- * @param {String} inputCalendarDate Calendar date string in "YYYY-MM-DD" format
- * @param {String} inputTwentyFourHourTime 24-hour time string in "hh:mm:ss" format
- * @returns {String} An ISO 8601 date and time string
- */
-const createIso8601DateTimeString = function (
-  inputCalendarDate,
-  inputTwentyFourHourTime
-) {
-  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
- * @param {String} timestampOfInterest A string representing the timestamp of interest in ISO 8601 format
- * @returns {Number} An integer representing the index of the timestamp of interest in the array of timestamps
- */
-const getIndexOfTimestamp = function (inputTimestampArr, timestampOfInterest) {
-  const timestampIndex = inputTimestampArr.findIndex(
-    (timestamp) => timestamp === timestampOfInterest
-  );
-
-  // If the timestamp does not exist in the timestamp array
-  if (timestampIndex === -1)
-    throw new Error(
-      "A start or end timestamp could not be found in the timestamp array"
-    );
-
-  // If the timestamp exists in the timestamp array
-  return timestampIndex;
-};
-
-/**
- * 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 sum of observation values
- */
-const calculateSumOfObservationValuesWithinDatesInterval = function (
-  obsArray,
-  samplingRate,
-  startDate,
-  endDate
-) {
-  // Extract the timestamps and values from the observations
-  const obsTimestampArr = obsArray.map((obs) => obs[0]);
-  const obsValuesArr = obsArray.map((obs) => obs[1]);
-
-  // Create array of 24-hour strings for the start and end of interval
-  const startPlusEndTimeStrings = createTimeStringsForInterval(samplingRate);
-
-  // Extract 24-hour strings for the start and end of interval
-  const [startTimeString, endTimeString] = startPlusEndTimeStrings;
-
-  // Create ISO 8601 strings for the start and end of interval
-  const startIso8601DateTimeString = createIso8601DateTimeString(
-    startDate,
-    startTimeString
-  );
-  const endIso8601DateTimeString = createIso8601DateTimeString(
-    endDate,
-    endTimeString
-  );
-
-  // Calculate the indexes of the timestamps for the start and end of interval
-  const indexStartTimestamp = getIndexOfTimestamp(
-    obsTimestampArr,
-    startIso8601DateTimeString
-  );
-  const indexEndTimestamp = getIndexOfTimestamp(
-    obsTimestampArr,
-    endIso8601DateTimeString
-  );
-
-  // Extract the observations that fall within our time interval
-  const obsValuesForTimeIntervalArr = obsValuesArr.slice(
-    indexStartTimestamp,
-    indexEndTimestamp + 1
-  );
-
-  return obsValuesForTimeIntervalArr.reduce(
-    (accumulator, currentValue) => accumulator + currentValue
-  );
-};
-
-/**
- * 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 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`
-    );
-  }
-
-  // Months with 30 days
-  if (monthNum === 4 || monthNum === 6 || monthNum === 9 || monthNum === 11)
-    return calculateSumOfObservationValuesWithinDatesInterval(
-      obsArray,
-      samplingRate,
-      startDateStr,
-      `${calendarMonthStr}-30`
-    );
-
-  // 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];
-};
-
-/**
- * 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/aggregate.mjs b/public/js/src_modules/aggregate.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..6c6a30c7b0f8f48642015bad8085823746873cf6
--- /dev/null
+++ b/public/js/src_modules/aggregate.mjs
@@ -0,0 +1,420 @@
+"use strict";
+
+/**
+ * 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. "15min", "60min"
+ * @returns {Array} An array of two 24-hour strings representing the start time and end time
+ */
+const createTimeStringsForInterval = function (phenomenonSamplingRate) {
+  const fifteenMinutes = "15min";
+  const sixtyMinutes = "60min";
+
+  const startTime = "00:00:00";
+  const endTimeFifteenMinutes = "23:45:00";
+  const endTimeSixtyMinutes = "23:00:00";
+
+  if (
+    phenomenonSamplingRate !== fifteenMinutes &&
+    phenomenonSamplingRate !== sixtyMinutes
+  )
+    throw new Error(
+      `Check that the provided phenomenon sampling rate string is in this format: "15min" or "60min"`
+    );
+
+  // 15 min sampling rate
+  if (phenomenonSamplingRate === fifteenMinutes) {
+    return [startTime, endTimeFifteenMinutes];
+  }
+
+  // 60 min sampling rate
+  if (phenomenonSamplingRate === sixtyMinutes) {
+    return [startTime, endTimeSixtyMinutes];
+  }
+};
+
+/**
+ * Create an ISO 8601 date and time string
+ * @param {String} inputCalendarDate Calendar date string in "YYYY-MM-DD" format
+ * @param {String} inputTwentyFourHourTime 24-hour time string in "hh:mm:ss" format
+ * @returns {String} An ISO 8601 date and time string
+ */
+const createIso8601DateTimeString = function (
+  inputCalendarDate,
+  inputTwentyFourHourTime
+) {
+  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
+ * @param {String} timestampOfInterest A string representing the timestamp of interest in ISO 8601 format
+ * @returns {Number} An integer representing the index of the timestamp of interest in the array of timestamps
+ */
+const getIndexOfTimestamp = function (inputTimestampArr, timestampOfInterest) {
+  const timestampIndex = inputTimestampArr.findIndex(
+    (timestamp) => timestamp === timestampOfInterest
+  );
+
+  // If the timestamp does not exist in the timestamp array
+  if (timestampIndex === -1)
+    throw new Error(
+      "A start or end timestamp could not be found in the timestamp array"
+    );
+
+  // If the timestamp exists in the timestamp array
+  return timestampIndex;
+};
+
+/**
+ * Calculate the indexes of the start and end timestamps
+ * @param {Array} obsTimestampArr An array of observations timestamps
+ * @param {String} samplingRate The sampling rate of observations as a string, e.g. "15min", "60min"
+ * @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 {Array} A 1*2 array tht contains integers representing the start index and end index respectively
+ */
+const calculateIndexStartEndTimestamp = function (
+  obsTimestampArr,
+  samplingRate,
+  startDate,
+  endDate
+) {
+  // Create and extract 24-hour strings for the start and end of interval
+  const [startTimeString, endTimeString] =
+    createTimeStringsForInterval(samplingRate);
+
+  // Create ISO 8601 strings for the start and end of interval
+  const startIso8601DateTimeString = createIso8601DateTimeString(
+    startDate,
+    startTimeString
+  );
+  const endIso8601DateTimeString = createIso8601DateTimeString(
+    endDate,
+    endTimeString
+  );
+
+  // Calculate the indexes of the timestamps for the start and end of interval
+  const indexStartTimestamp = getIndexOfTimestamp(
+    obsTimestampArr,
+    startIso8601DateTimeString
+  );
+  const indexEndTimestamp = getIndexOfTimestamp(
+    obsTimestampArr,
+    endIso8601DateTimeString
+  );
+
+  return [indexStartTimestamp, indexEndTimestamp];
+};
+
+/**
+ * Extract the set of observation values that fall 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. "15min", "60min"
+ * @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 {Array} An array of observation values that fall within our time interval
+ */
+const extractObservationValuesWithinDatesInterval = function (
+  obsArray,
+  samplingRate,
+  startDate,
+  endDate
+) {
+  // Extract the timestamps and values from the observations
+  const obsTimestampArr = obsArray.map((obs) => obs[0]);
+  const obsValuesArr = obsArray.map((obs) => obs[1]);
+
+  // Calculate the indexes of the timestamps for the start and end of interval
+  const [indexStartTimestamp, indexEndTimestamp] =
+    calculateIndexStartEndTimestamp(
+      obsTimestampArr,
+      samplingRate,
+      startDate,
+      endDate
+    );
+
+  // Extract the observations that fall within our time interval
+  return obsValuesArr.slice(indexStartTimestamp, indexEndTimestamp + 1);
+};
+
+/**
+ * Calculate the sum of observation values that fall within a time interval delimited by a start date and end date
+ * @param {Array} obsValuesForDaysIntervalArr An array of observation values that fall within our time interval
+ * @returns {Number} A floating-point number representing the sum of observation values
+ */
+const calculateSumOfObservationValuesWithinDatesInterval = function (
+  obsValuesForDaysIntervalArr
+) {
+  return obsValuesForDaysIntervalArr.reduce(
+    (accumulator, currentValue) => accumulator + currentValue
+  );
+};
+
+/**
+ * Calculate the average (arithmetic mean) of observation values that fall within a time interval delimited by a start date and end date
+ * @param {Array} obsValuesForDaysIntervalArr An array of observation values that fall within our time interval
+ * @returns {Number} A floating-point number representing the average (arithmetic mean) of observation values
+ */
+const calculateAverageOfObservationValuesWithinDatesInterval = function (
+  obsValuesForDaysIntervalArr
+) {
+  return (
+    calculateSumOfObservationValuesWithinDatesInterval(
+      obsValuesForDaysIntervalArr
+    ) / obsValuesForDaysIntervalArr.length
+  );
+};
+
+/**
+ * Extract the year and month digits from a calendar month string
+ * @param {String} calendarMonthStr Calendar month string in "YYYY-MM" format
+ * @returns {Array} A 1*2 array tht contains integers representing the year and month respectively
+ */
+const extractMonthYearDigitsFromCalendarMonthString = function (
+  calendarMonthStr
+) {
+  // Extract year as integer
+  const yearNum = parseInt(calendarMonthStr.slice(0, 4), 10);
+
+  // Extract month as integer
+  const monthNum = parseInt(calendarMonthStr.slice(-2), 10);
+
+  return [yearNum, monthNum];
+};
+
+/**
+ * Extract the set of observation values that fall within a time interval delimited by the first day and last day 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. "15min", "60min"
+ * @param {String} calendarMonthStr Calendar month string in "YYYY-MM" format
+ * @returns {Array} An array of observation values that fall within one calendar month
+ */
+const extractObservationValuesWithinMonthInterval = function (
+  obsArray,
+  samplingRate,
+  calendarMonthStr
+) {
+  // Extract the year and month digits from the calendar month string
+  const [yearNum, monthNum] =
+    extractMonthYearDigitsFromCalendarMonthString(calendarMonthStr);
+
+  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 extractObservationValuesWithinDatesInterval(
+        obsArray,
+        samplingRate,
+        startDateStr,
+        `${calendarMonthStr}-29`
+      );
+
+    // Non-leap year
+    return extractObservationValuesWithinDatesInterval(
+      obsArray,
+      samplingRate,
+      startDateStr,
+      `${calendarMonthStr}-28`
+    );
+  }
+
+  // Months with 30 days
+  if (monthNum === 4 || monthNum === 6 || monthNum === 9 || monthNum === 11)
+    return extractObservationValuesWithinDatesInterval(
+      obsArray,
+      samplingRate,
+      startDateStr,
+      `${calendarMonthStr}-30`
+    );
+
+  // Months with 31 days
+  return extractObservationValuesWithinDatesInterval(
+    obsArray,
+    samplingRate,
+    startDateStr,
+    `${calendarMonthStr}-31`
+  );
+};
+
+/**
+ * Calculate the sum of observation values that fall within a time interval delimited by the first day and last day of a calendar month
+ * @param {Array} obsValuesForMonthIntervalArr An array of observation values that fall within one calendar month
+ * @returns {Number} A floating-point number representing the sum of observation values within one calendar month
+ */
+const calculateSumOfObservationValuesWithinMonthInterval = function (
+  obsValuesForMonthIntervalArr
+) {
+  return obsValuesForMonthIntervalArr.reduce(
+    (accumulator, currentValue) => accumulator + currentValue
+  );
+};
+
+/**
+ * Calculate the average (arithmetic mean) of observation values that fall within a time interval delimited by the first day and last day of a calendar month
+ * @param {Array} obsValuesForMonthIntervalArr An array of observation values that fall within one calendar month
+ * @returns {Number} A floating-point number representing the average (arithmetic mean) of observation values within one calendar month
+ */
+const calculateAverageOfObservationValuesWithinMonthInterval = function (
+  obsValuesForMonthIntervalArr
+) {
+  return (
+    calculateSumOfObservationValuesWithinMonthInterval(
+      obsValuesForMonthIntervalArr
+    ) / obsValuesForMonthIntervalArr.length
+  );
+};
+
+/**
+ * Calculate the sum of observation values within a time interval delimited by a start date and end date. The time interval may be daily or monthly
+ * @param {Array} obsNestedArr A 1*N array that contains N nested arrays of observations (timestamp + value)
+ * @param {String} samplingRate The sampling rate of observations as a string, e.g. "15min", "60min"
+ * @param {Array} uniqueCalendarDatesOrMonthsArr A 1*N array of unique calendar dates or calendar months strings
+ * @param {String} aggregationInterval The aggregation interval as a string e.g. "daily", "monthly"
+ * @returns {Array} A 1*N array that contains N nested arrays of sum values
+ */
+const calculateSumOfObservationValuesWithinInterval = function (
+  obsNestedArr,
+  samplingRate,
+  uniqueCalendarDatesOrMonthsArr,
+  aggregationInterval
+) {
+  // Calculate sum of values of observations - daily
+  // Note the use of the two nested `map` methods
+  if (aggregationInterval === "daily") {
+    return uniqueCalendarDatesOrMonthsArr.map((uniqueCalendarDatesArr, i) =>
+      uniqueCalendarDatesArr.map((uniqueCalendarDate) =>
+        calculateSumOfObservationValuesWithinDatesInterval(
+          extractObservationValuesWithinDatesInterval(
+            obsNestedArr[i],
+            samplingRate,
+            uniqueCalendarDate,
+            uniqueCalendarDate
+          )
+        )
+      )
+    );
+  }
+
+  // Calculate sum of values of observations - monthly
+  // Note the use of the two nested `map` methods
+  if (aggregationInterval === "monthly") {
+    return uniqueCalendarDatesOrMonthsArr.map((uniqueCalendarMonthsArr, i) =>
+      uniqueCalendarMonthsArr.map((uniqueCalendarMonth) =>
+        calculateSumOfObservationValuesWithinMonthInterval(
+          extractObservationValuesWithinMonthInterval(
+            obsNestedArr[i],
+            samplingRate,
+            uniqueCalendarMonth
+          )
+        )
+      )
+    );
+  }
+};
+
+/**
+ * Calculate the average (arithmetic mean) of observation values within a time interval delimited by a start date and end date. The time interval may be daily or monthly
+ * @param {Array} obsNestedArr A 1*N array that contains N nested arrays of observations (timestamp + value)
+ * @param {String} samplingRate The sampling rate of observations as a string, e.g. "15min", "60min"
+ * @param {Array} uniqueCalendarDatesOrMonthsArr A 1*N array of unique calendar dates or calendar months strings
+ * @param {String} aggregationInterval The aggregation interval as a string e.g. "daily", "monthly"
+ * @returns {Array} A 1*N array that contains N nested arrays of average (arithmetic mean) values
+ */
+const calculateAverageOfObservationValuesWithinInterval = function (
+  obsNestedArr,
+  samplingRate,
+  uniqueCalendarDatesOrMonthsArr,
+  aggregationInterval
+) {
+  // Calculate average of values of observations - daily
+  // Note the use of the two nested `map` methods
+  if (aggregationInterval === "daily") {
+    return uniqueCalendarDatesOrMonthsArr.map((uniqueCalendarDatesArr, i) =>
+      uniqueCalendarDatesArr.map((uniqueCalendarDate) =>
+        calculateAverageOfObservationValuesWithinDatesInterval(
+          extractObservationValuesWithinDatesInterval(
+            obsNestedArr[i],
+            samplingRate,
+            uniqueCalendarDate,
+            uniqueCalendarDate
+          )
+        )
+      )
+    );
+  }
+
+  // Calculate average of values of observations - monthly
+  // Note the use of the two nested `map` methods
+  if (aggregationInterval === "monthly") {
+    return uniqueCalendarDatesOrMonthsArr.map((uniqueCalendarMonthsArr, i) =>
+      uniqueCalendarMonthsArr.map((uniqueCalendarMonth) =>
+        calculateAverageOfObservationValuesWithinMonthInterval(
+          extractObservationValuesWithinMonthInterval(
+            obsNestedArr[i],
+            samplingRate,
+            uniqueCalendarMonth
+          )
+        )
+      )
+    );
+  }
+};
+
+/**
+ * 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];
+};
+
+/**
+ * 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 {
+  calculateSumOfObservationValuesWithinInterval,
+  extractUniqueCalendarDatesFromTimestamp,
+  extractUniqueCalendarMonthsFromCalendarDates,
+  calculateAverageOfObservationValuesWithinInterval,
+};
diff --git a/public/js/src_modules/createUrl.js b/public/js/src_modules/baseUrlPlusQueryParams.mjs
similarity index 100%
rename from public/js/src_modules/createUrl.js
rename to public/js/src_modules/baseUrlPlusQueryParams.mjs
diff --git a/public/js/src_modules/calculateTemperatureDiff.mjs b/public/js/src_modules/calculateTemperatureDiff.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..afc2aac60547935f5e5471384c0077578a5579fa
--- /dev/null
+++ b/public/js/src_modules/calculateTemperatureDiff.mjs
@@ -0,0 +1,106 @@
+"use strict";
+
+import {
+  extractPhenomenonNameFromDatastreamName,
+  getMetadataPlusObservationsFromSingleOrMultipleDatastreams,
+} from "./fetchData.mjs";
+
+/**
+ * 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
+ */
+export const calculateVorlaufMinusRuecklaufTemperature = async function (
+  baseUrl,
+  urlParams,
+  buildingId,
+  samplingRate
+) {
+  try {
+    const bldgSensorSamplingRateNestedArr = [
+      [buildingId, "vl", samplingRate],
+      [buildingId, "rl", samplingRate],
+    ];
+
+    const BUILDING_ID = buildingId;
+    const SAMPLING_RATE = samplingRate;
+
+    const observationsPlusMetadata =
+      await getMetadataPlusObservationsFromSingleOrMultipleDatastreams(
+        baseUrl,
+        urlParams,
+        bldgSensorSamplingRateNestedArr
+      );
+
+    // Extract Vorlauf temperature, Ruecklauf temperature and metadata
+    const [
+      [vorlaufTemperatureObsArr, ruecklaufTemperatureObsArr],
+      [metadataVorlauf, metadataRuecklauf],
+    ] = observationsPlusMetadata;
+
+    // Extract the temperature values
+    const vorlaufTemperatureValues = vorlaufTemperatureObsArr.map(
+      (vlTempObs) => vlTempObs[1]
+    );
+    const ruecklaufTemperatureValues = ruecklaufTemperatureObsArr.map(
+      (rlTempObs) => rlTempObs[1]
+    );
+
+    // The arrays have equal length, we need only use one of them for looping
+    // Resulting array contains the following pairs (timestamp + dT)
+    const vorlaufMinusRuecklaufTemperatureObs = vorlaufTemperatureObsArr.map(
+      (vlTempObs, i) => [
+        vlTempObs[0], // timestamp
+        vorlaufTemperatureValues[i] - ruecklaufTemperatureValues[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 [
+      vorlaufMinusRuecklaufTemperatureObs,
+      {
+        description,
+        name,
+        unitOfMeasurement,
+      },
+    ];
+  } catch (err) {
+    console.error(err);
+  }
+};
diff --git a/public/js/src_modules/chartColumn.js b/public/js/src_modules/chartColumn.mjs
similarity index 97%
rename from public/js/src_modules/chartColumn.js
rename to public/js/src_modules/chartColumn.mjs
index 51f322424902803cbfbf8ad2bf3dd66d427e3bb2..9f259f307b0de2944da33f7b596973bdbea814e5 100644
--- a/public/js/src_modules/chartColumn.js
+++ b/public/js/src_modules/chartColumn.mjs
@@ -1,3 +1,7 @@
+"use strict";
+
+import { chartExportOptions } from "./chartExport.mjs";
+
 /**
  * Format a computed aggregation result to make it suitable for a column chart
  * @param {Array} calendarDatesMonthsStrArr An array of unique calendar dates strings (in "YYYY-MM-DD" fromat) or unique calendar months strings (in "YYYY-MM" format)
@@ -123,6 +127,8 @@ const drawColumnChartHighcharts = function (
       },
     },
 
+    exporting: chartExportOptions,
+
     series: seriesOptionsArr,
   });
 };
diff --git a/public/js/src_modules/chartExport.mjs b/public/js/src_modules/chartExport.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..0e26d0edbf8637eb7813d7d3483bfeeab100c1af
--- /dev/null
+++ b/public/js/src_modules/chartExport.mjs
@@ -0,0 +1,9 @@
+"use strict";
+
+export const chartExportOptions = {
+  buttons: {
+    contextButton: {
+      menuItems: ["downloadPNG", "downloadJPEG", "downloadPDF", "downloadSVG"],
+    },
+  },
+};
diff --git a/public/js/src_modules/chartHeatmap.js b/public/js/src_modules/chartHeatmap.mjs
similarity index 85%
rename from public/js/src_modules/chartHeatmap.js
rename to public/js/src_modules/chartHeatmap.mjs
index 5d15d4f6a7e7a0c85ca9d32dd109cd1b8b7b8b04..50534b41b2b560439dde1b8e22a61fa20a60baf4 100644
--- a/public/js/src_modules/chartHeatmap.js
+++ b/public/js/src_modules/chartHeatmap.mjs
@@ -1,5 +1,7 @@
 "use strict";
 
+import { chartExportOptions } from "./chartExport.mjs";
+
 /**
  * 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
@@ -142,11 +144,32 @@ const drawHeatMapHighcharts = function (
       startOnTick: false,
       endOnTick: false,
       labels: {
-        // format: "{value}℃",
         format: `{value}${PHENOMENON_SYMBOL}`,
       },
     },
 
+    exporting: chartExportOptions,
+
+    tooltip: {
+      formatter() {
+        const headerString = `${PHENOMENON_NAME}<br/>`;
+
+        // Check whether the point value is null or not; this will determine the string that we'll render
+        const pointString =
+          this.point.value === null
+            ? `${Highcharts.dateFormat("%e %b, %Y", this.point.x)} ${
+                this.point.y
+              }:00:00 <b>null</b>`
+            : `${Highcharts.dateFormat("%e %b, %Y", this.point.x)} ${
+                this.point.y
+              }:00:00 <b>${this.point.value.toFixed(
+                2
+              )} ${PHENOMENON_SYMBOL}</b>`;
+
+        return headerString + pointString;
+      },
+    },
+
     series: [
       {
         data: formattedObsArrayForHeatmap,
@@ -154,14 +177,6 @@ const drawHeatMapHighcharts = function (
         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
       },
     ],
diff --git a/public/js/src_modules/chartLine.js b/public/js/src_modules/chartLine.mjs
similarity index 81%
rename from public/js/src_modules/chartLine.js
rename to public/js/src_modules/chartLine.mjs
index 8c8e5bfc0e80d5217ffc56870fc1b4e71517316e..aaf3f4fd5774709d29cf353b07eece9505afd2a3 100644
--- a/public/js/src_modules/chartLine.js
+++ b/public/js/src_modules/chartLine.mjs
@@ -1,5 +1,7 @@
 "use strict";
 
+import { chartExportOptions } from "./chartExport.mjs";
+
 /**
  * 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
@@ -42,23 +44,21 @@ const createCombinedTextForLineChartTitles = function (
  * 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
+  phenomenonNamesArr
 ) {
   // 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
+  // Assumes that the observation array of arrays and phenomenon names array are of equal length
   // Use one of the arrays for looping
   return formattedObsArraysForLineChart.map((formattedObsArray, i) => {
     return {
-      name: `${phenomenonNamesArr[i]} (${phenomenonSymbolsArr[i]})`,
+      name: `${phenomenonNamesArr[i]}`,
       data: formattedObsArray,
       color: seriesColors[i],
       turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release
@@ -112,11 +112,27 @@ const drawLineChartHighcharts = function (
     },
 
     tooltip: {
-      pointFormat:
-        '<span style="color:{series.color}">{series.name}</span>: <b>{point.y}</b> <br/>',
-      valueDecimals: 2,
+      formatter() {
+        // Our tooltip is split
+        // this.x -- common for all points
+        // this.points -- an array containing properties for each series
+        return [`${Highcharts.dateFormat("%A, %b %e, %Y", this.x)}`].concat(
+          this.points
+            ? this.points.map(
+                (point, i) =>
+                  `<span style="color:${point.color}">${
+                    point.series.name
+                  }</span>: <b>${point.y.toFixed(2)} ${
+                    unitOfMeasurementSymbolsArr[i]
+                  }</b>`
+              )
+            : []
+        );
+      },
     },
 
+    exporting: chartExportOptions,
+
     series: seriesOptionsArr,
   });
 };
diff --git a/public/js/src_modules/chartScatterPlot.js b/public/js/src_modules/chartScatterPlot.mjs
similarity index 96%
rename from public/js/src_modules/chartScatterPlot.js
rename to public/js/src_modules/chartScatterPlot.mjs
index eb88b9488b31c6d81924cb5e2eb08e734a3cb59a..09b8592d2bf9bf4cdb89f77d009b62c12ad3880c 100644
--- a/public/js/src_modules/chartScatterPlot.js
+++ b/public/js/src_modules/chartScatterPlot.mjs
@@ -1,5 +1,7 @@
 "use strict";
 
+import { chartExportOptions } from "./chartExport.mjs";
+
 /**
  * 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
@@ -323,13 +325,23 @@ const drawScatterPlotHighcharts = function (
             },
           },
         },
-        tooltip: {
-          headerFormat: "{series.name}<br>",
-          pointFormat: `<b>{point.y:.2f} ${SERIES_1_SYMBOL}, {point.x:.2f} ${SERIES_2_SYMBOL}</b>`,
-        },
       },
     },
 
+    tooltip: {
+      formatter() {
+        const headerString = `${this.series.name}<br>`;
+        const pointString = `<b>${this.point.y.toFixed(
+          2
+        )} ${SERIES_1_SYMBOL}, ${this.point.x.toFixed(
+          2
+        )} ${SERIES_2_SYMBOL}</b>`;
+        return headerString + pointString;
+      },
+    },
+
+    exporting: chartExportOptions,
+
     series: [
       {
         name: SERIES_COMBINED_NAME,
diff --git a/public/js/src_modules/fetchData.js b/public/js/src_modules/fetchData.mjs
similarity index 63%
rename from public/js/src_modules/fetchData.js
rename to public/js/src_modules/fetchData.mjs
index 64ba329a1e43a7fb60b4099eeb315705f13287ca..718620d2ae827553422df275126cf5931f0c9c5d 100644
--- a/public/js/src_modules/fetchData.js
+++ b/public/js/src_modules/fetchData.mjs
@@ -1,98 +1,6 @@
 "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]
-  );
-};
+import { getDatastreamIdFromBuildingNumber } from "./getDatastreamId.mjs";
 
 /**
  * Create URL to fetch the details of single Datastream
@@ -412,109 +320,9 @@ const getMetadataPlusObservationsFromSingleOrMultipleDatastreams =
     }
   };
 
-/**
- * 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 bldgSensorSamplingRateNestedArr = [
-      [buildingId, "vl", samplingRate],
-      [buildingId, "rl", samplingRate],
-    ];
-
-    const BUILDING_ID = buildingId;
-    const SAMPLING_RATE = samplingRate;
-
-    const observationsPlusMetadata =
-      await getMetadataPlusObservationsFromSingleOrMultipleDatastreams(
-        baseUrl,
-        urlParams,
-        bldgSensorSamplingRateNestedArr
-      );
-
-    // Extract Vorlauf temperature, Ruecklauf temperature and metadata
-    const [
-      [vorlaufTemperatureObsArr, ruecklaufTemperatureObsArr],
-      [metadataVorlauf, metadataRuecklauf],
-    ] = observationsPlusMetadata;
-
-    // Extract the temperature values
-    const vorlaufTemperatureValues = vorlaufTemperatureObsArr.map(
-      (vlTempObs) => vlTempObs[1]
-    );
-    const ruecklaufTemperatureValues = ruecklaufTemperatureObsArr.map(
-      (rlTempObs) => rlTempObs[1]
-    );
-
-    // The arrays have equal length, we need only use one of them for looping
-    // Resulting array contains the following pairs (timestamp + dT)
-    const vorlaufMinusRuecklaufTemperatureObs = vorlaufTemperatureObsArr.map(
-      (vlTempObs, i) => [
-        vlTempObs[0], // timestamp
-        vorlaufTemperatureValues[i] - ruecklaufTemperatureValues[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 [
-      vorlaufMinusRuecklaufTemperatureObs,
-      {
-        description,
-        name,
-        unitOfMeasurement,
-      },
-    ];
-  } catch (err) {
-    console.error(err);
-  }
-};
-
 export {
+  extractPhenomenonNameFromDatastreamName,
   formatDatastreamMetadataForChart,
   extractPropertiesFromFormattedDatastreamMetadata,
   getMetadataPlusObservationsFromSingleOrMultipleDatastreams,
-  calculateVorlaufMinusRuecklaufTemperature,
 };
diff --git a/public/js/src_modules/getDatastreamId.mjs b/public/js/src_modules/getDatastreamId.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..ff5303a8afb4f9a2e4f0691ee8ea5c22ef779c63
--- /dev/null
+++ b/public/js/src_modules/getDatastreamId.mjs
@@ -0,0 +1,95 @@
+"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
+ */
+export 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]
+  );
+};
diff --git a/public/js/src_modules/loadingIndicator.js b/public/js/src_modules/loadingIndicator.mjs
similarity index 100%
rename from public/js/src_modules/loadingIndicator.js
rename to public/js/src_modules/loadingIndicator.mjs