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 };