appChart.js 29.2 KB
Newer Older
Pithon Kabiro's avatar
Pithon Kabiro committed
1
"use strict";
Pithon Kabiro's avatar
Pithon Kabiro committed
2

3
const BASE_URL = "http://193.196.39.91:8080/frost-icity-tp31/v1.1";
4

5
6
/**
 * Retrieve the datastream ID that corresponds to a particular building
7
8
9
 * @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
10
11
 * @returns {Number} Datastream corresponding to the input building
 */
12
const getDatastreamIdFromBuildingNumber = function (
13
14
15
16
17
18
19
20
  buildingNumber,
  phenomenon,
  samplingRate
) {
  const buildingToDatastreamMapping = {
    101: {
      vl: { "15min": "69", "60min": "75" },
      rl: { "15min": "81", "60min": "87" },
21
22
23
24
25
26

      // 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" },
27
    },
28

29
30
31
    102: {
      vl: { "15min": "70", "60min": "76" },
      rl: { "15min": "82", "60min": "88" },
32
33
34
35
36
37

      // 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" },
38
    },
39

40
41
42
    107: {
      vl: { "15min": "71", "60min": "77" },
      rl: { "15min": "83", "60min": "89" },
43
44
45
46
47
48

      // 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" },
49
    },
50

51
    "112, 118": {
52
53
      vl: { "15min": "72", "60min": "78" },
      rl: { "15min": "84", "60min": "90" },
54
55
56
57
58
59

      // 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" },
60
    },
61

62
63
64
    125: {
      vl: { "15min": "73", "60min": "79" },
      rl: { "15min": "85", "60min": "91" },
65
66
67
68
69
70

      // 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" },
71
    },
72

73
74
75
76
77
78
79
80
    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" },
    },
81
82
83
84

    weather_station_521: {
      outside_temp: { "15min": "141", "60min": "142" },
    },
85
86
  };

87
88
89
90
91
92
  if (
    buildingToDatastreamMapping?.[buildingNumber]?.[phenomenon]?.[
      samplingRate
    ] === undefined
  )
    return;
93

94
95
96
97
98
99
100
  const datastreamIdMatched = Number(
    buildingToDatastreamMapping[buildingNumber][phenomenon][samplingRate]
  );

  return datastreamIdMatched;
};

101
102
103
104
105
106
/**
 * 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
 */
107
const getDatastreamUrl = function (baseUrl, datastreamID) {
108
109
110
111
112
  if (!datastreamID) return;
  const fullDatastreamURL = `${baseUrl}/Datastreams(${datastreamID})`;
  return fullDatastreamURL;
};

113
/**
114
 * Create URL to fetch Observations
115
 * @param {String} baseUrl Base URL of the STA server
116
117
 * @param {Number} datastreamID Integer representing the Datastream ID
 * @returns {String} URL string for fetching Observations
118
 */
119
const getObservationsUrl = function (baseUrl, datastreamID) {
120
  if (!datastreamID) return;
121
122
  const fullObservationsURL = `${baseUrl}/Datastreams(${datastreamID})/Observations`;
  return fullObservationsURL;
123
124
125
126
};

/**
 * Create a temporal filter string for the fetched Observations
127
128
129
 * @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
130
 */
131
const createTemporalFilterString = function (dateStart, dateStop) {
132
133
134
135
136
  if (!dateStart || !dateStop) return;
  const filterString = `resultTime ge ${dateStart}T00:00:00.000Z and resultTime le ${dateStop}T00:00:00.000Z`;
  return filterString;
};

137
// const BASE_URL_OBSERVATIONS = getObservationsUrl(80);
138
139
140
141
142
143
144
const QUERY_PARAM_RESULT_FORMAT = "dataArray";
const QUERY_PARAM_ORDER_BY = "phenomenonTime asc";
const QUERY_PARAM_FILTER = createTemporalFilterString(
  "2020-01-01",
  "2021-01-01"
);
const QUERY_PARAM_SELECT = "result,phenomenonTime";
145
const QUERY_PARAMS_COMBINED = {
146
147
148
149
  "$resultFormat": QUERY_PARAM_RESULT_FORMAT,
  "$orderBy": QUERY_PARAM_ORDER_BY,
  "$filter": QUERY_PARAM_FILTER,
  "$select": QUERY_PARAM_SELECT,
150
};
Pithon Kabiro's avatar
Pithon Kabiro committed
151

152
153
/**
 * Perform a GET request using the Axios library
154
 * @param {String} urlObservations A URL that fetches Observations from an STA instance
155
 * @param {Object} urlParamObj The URL parameters to be sent together with the GET request
156
 * @returns {Promise} A promise that contains the first page of results when fulfilled
157
 */
158
const axiosGetRequest = function (urlObservations, urlParamObj) {
159
160
161
162
  return axios.get(urlObservations, {
    params: urlParamObj,
  });
};
163

164
165
166
167
168
169
/**
 * 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
 */
170
const getMetadataFromSingleDatastream = async function (urlDatastream) {
171
172
173
174
175
176
177
178
  try {
    // Extract properties of interest
    const {
      data: { description, name, unitOfMeasurement },
    } = await axiosGetRequest(urlDatastream);

    return { description, name, unitOfMeasurement };
  } catch (err) {
179
    console.error(err);
180
181
182
  }
};

183
184
/**
 * Retrieve metadata from multiple datastreams
185
 * @async
186
187
188
189
190
191
192
193
194
195
196
 * @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
197
198
199
      const datastreamMetadata = await getMetadataFromSingleDatastream(
        datastreamUrl
      );
200
201
202
203
204
205
206
207
208
      datastreamMetadataArr.push(datastreamMetadata);
    }

    return datastreamMetadataArr;
  } catch (err) {
    console.error(err);
  }
};

209
210
211
212
213
/**
 * 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
 */
214
const formatDatastreamMetadataForChart = function (datastreamMetadata) {
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
  const {
    description: datastreamDescription,
    name: datastreamName,
    unitOfMeasurement,
  } = datastreamMetadata;

  // Extract phenomenon name from Datastream name
  const regex = /\/ (.*) DS/;
  const phenomenonName = datastreamName.match(regex)[1]; // use second element in array

  // Match the unitOfMeasurement's string representation of a symbol
  // to an actual symbol, where necessary
  const unitOfMeasurementSymbol = (() => {
    if (unitOfMeasurement.symbol === "degC") {
      return "";
    } else if (unitOfMeasurement.symbol === "m3/h") {
      return "m<sup>3</sup>/h";
    } else {
      return unitOfMeasurement.symbol;
    }
  })();

  return {
    datastreamDescription,
    datastreamName,
    phenomenonName,
    unitOfMeasurementSymbol,
  };
};

245
/**
246
 * Format the response from SensorThings API to make it suitable for heatmap
247
248
 * @param {Array} obsArray Response from SensorThings API as array
 * @returns {Array} Array of formatted observations suitable for use in a heatmap
249
 */
250
const formatSTAResponseForHeatMap = function (obsArray) {
Pithon Kabiro's avatar
Pithon Kabiro committed
251
  if (!obsArray) return;
252
253

  const dataSTAFormatted = obsArray.map((obs) => {
254
255
256
257
258
259
260
261
262
263
264
265
266
    // 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];
267
    return [timestamp, hourOfDay, value];
268
  });
269

270
271
272
273
274
  return dataSTAFormatted;
};

/**
 * Draw a heatmap using Highcharts library
275
 * @param {Array} formattedObsArrayForHeatmap Response from SensorThings API formatted for use in a heatmap
276
 * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata
277
 * @returns {undefined} undefined
278
 */
279
const drawHeatMapHC = function (
280
  formattedObsArrayForHeatmap,
281
  formattedDatastreamMetadata
282
) {
283
284
285
286
287
288
289
  const {
    datastreamDescription: DATASTREAM_DESCRIPTION,
    datastreamName: DATASTREAM_NAME,
    phenomenonName: PHENOMENON_NAME,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL,
  } = formattedDatastreamMetadata;

Pithon Kabiro's avatar
Pithon Kabiro committed
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
  // Function returns the min and max observation values
  const {
    minObsValue: MINIMUM_VALUE_COLOR_AXIS,
    maxObsValue: MAXIMUM_VALUE_COLOR_AXIS,
  } = (() => {
    // The observation value is the third element in array
    const obsValueArr = formattedObsArrayForHeatmap.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 };
  })();

309
310
311
312
313
314
315
316
317
318
319
  Highcharts.chart("chart-heatmap", {
    chart: {
      type: "heatmap",
      zoomType: "x",
    },

    boost: {
      useGPUTranslations: true,
    },

    title: {
320
      text: DATASTREAM_DESCRIPTION,
321
322
323
324
325
      align: "left",
      x: 40,
    },

    subtitle: {
326
      text: DATASTREAM_NAME,
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
      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"],
      ],
Pithon Kabiro's avatar
Pithon Kabiro committed
370
371
      min: MINIMUM_VALUE_COLOR_AXIS,
      max: MAXIMUM_VALUE_COLOR_AXIS,
372
373
374
      startOnTick: false,
      endOnTick: false,
      labels: {
375
376
        // format: "{value}℃",
        format: `{value}${PHENOMENON_SYMBOL}`,
377
378
379
380
381
      },
    },

    series: [
      {
382
        data: formattedObsArrayForHeatmap,
383
384
385
386
387
        boostThreshold: 100,
        borderWidth: 0,
        nullColor: "#525252",
        colsize: 24 * 36e5, // one day
        tooltip: {
388
389
          headerFormat: `${PHENOMENON_NAME}<br/>`,
          valueDecimals: 2,
390
          pointFormat:
391
392
            // "{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>`,
Pithon Kabiro's avatar
Pithon Kabiro committed
393
          nullFormat: `{point.x:%e %b, %Y} {point.y}:00: <b>null</b>`,
394
395
396
397
398
399
400
        },
        turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release
      },
    ],
  });
};

Pithon Kabiro's avatar
Pithon Kabiro committed
401
402
/**
 * Convert the observations' phenomenonTime from an ISO 8601 string to Unix epoch
403
 * @param {Array} obsArray Response from SensorThings API as array
404
 * @returns {Array} Array of formatted observations suitable for use in a line chart or scatter plot
Pithon Kabiro's avatar
Pithon Kabiro committed
405
 */
406
const formatSTAResponseForLineChartOrScatterPlot = function (obsArray) {
Pithon Kabiro's avatar
Pithon Kabiro committed
407
  if (!obsArray) return;
408
409

  const dataSTAFormatted = obsArray.map((result) => {
Pithon Kabiro's avatar
Pithon Kabiro committed
410
411
    const timestampObs = new Date(result[0].slice(0, -1)).getTime(); // slice() removes trailing "Z" character in timestamp
    const valueObs = result[1];
412
    return [timestampObs, valueObs];
Pithon Kabiro's avatar
Pithon Kabiro committed
413
  });
414

Pithon Kabiro's avatar
Pithon Kabiro committed
415
416
417
418
419
  return dataSTAFormatted;
};

/**
 * Draw a line chart using Highcharts library
420
 * @param {Array} formattedObsArrayForLineChart Response from SensorThings API formatted for use in a line chart
421
 * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata
422
 * @returns {undefined} undefined
Pithon Kabiro's avatar
Pithon Kabiro committed
423
 */
424
const drawLineChartHC = function (
425
  formattedObsArrayForLineChart,
426
  formattedDatastreamMetadata
427
) {
428
429
430
431
432
433
434
  const {
    datastreamDescription: DATASTREAM_DESCRIPTION,
    datastreamName: DATASTREAM_NAME,
    phenomenonName: PHENOMENON_NAME,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL,
  } = formattedDatastreamMetadata;

Pithon Kabiro's avatar
Pithon Kabiro committed
435
436
437
438
439
440
  Highcharts.stockChart("chart-line", {
    chart: {
      zoomType: "x",
    },

    rangeSelector: {
441
      selected: 5,
Pithon Kabiro's avatar
Pithon Kabiro committed
442
443
444
    },

    title: {
445
      text: DATASTREAM_DESCRIPTION,
446
447
448
449
      "align": "left",
    },

    subtitle: {
450
      text: DATASTREAM_NAME,
451
      align: "left",
Pithon Kabiro's avatar
Pithon Kabiro committed
452
453
454
455
    },

    series: [
      {
456
        name: `${PHENOMENON_NAME} (${PHENOMENON_SYMBOL})`,
457
        data: formattedObsArrayForLineChart,
Pithon Kabiro's avatar
Pithon Kabiro committed
458
459
460
461
462
463
464
465
466
        tooltip: {
          valueDecimals: 2,
        },
        turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release
      },
    ],
  });
};

467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
/**
 * 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) => !obsTimestampArrayTwo.includes(timestampTwo)
      )
    );

  return differenceBetweenArrays;
};

/**
 * Determines the indexes of timestamps that are missing from a smaller set of observatiuons. Based on the comparison of two observation arrays, where one array is larger than the other
 * @param {Array} missingTimestampsArr An array of strings representing the missing timestamps
 * @param {Array} largerObsTimestampArr An array of timestamps for the larger array of observations
 * @returns {Array} An array of the indexes of the missing observations
 */
const getIndexOfMissingObservation = function (
  missingTimestampsArr,
  largerObsTimestampArr
) {
  const indexesMissingObs = missingTimestampsArr.map((index) =>
    largerObsTimestampArr.indexOf(index)
  );

  return indexesMissingObs;
};

/**
 * Removes observations (by modifying array in place) from a larger set of observations that are missing from a smaller set of observatiuons. Based on the comparison of two observation arrays, where one array is larger than the other
 * @param {Array} missingIndexesArr An array of the indexes of the observations missing from the smaller set of observations
 * @param {Array} largerObsArr The larger array of observations (timestamp + value) which is modified in place
 * @returns {undefined}
 */
const removeMissingObservationFromLargerArray = function (
  missingIndexesArr,
  largerObsArr
) {
  missingIndexesArr.forEach((index) => {
    if (index > -1) {
      largerObsArr.splice(index, 1);
    }
  });
};

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

/**
 * Extracts and combines observation values from two imput 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 2*N 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 scatter plot
 * @param {Array} obsArrayOne Response from SensorThings API as array
 * @param {Array} obsArrayTwo Response from SensorThings API as array
 * @returns {Array} Array of formatted observations suitable for use in a scatter plot
 */
const formatSTAResponseForScatterPlot = function (obsArrayOne, obsArrayTwo) {
  // When our observation arrays have DIFFERENT lengths
  if (obsArrayOne.length !== obsArrayTwo.length) {
    // 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 = getIndexOfMissingObservation(
      missingTimestamp,
      biggerObsTimestampArr
    );

    // Determine the larger observation array
    const biggerObsArr = getLargerArrayBetweenTwoInputArrays(
      obsArrayOne,
      obsArrayTwo
    );

    // Remove the missing observation from the larger array of observations
    // Modifies the array in place
    removeMissingObservationFromLargerArray(indexesMissingObsArr, biggerObsArr);

    return createCombinedObservationValues(obsArrayOne, obsArrayTwo);
  }

  // When our observation arrays already have SAME lengths
  return createCombinedObservationValues(obsArrayOne, obsArrayTwo);
};

/**
 * Draw a scatter plot using Highcharts library
 * @param {*} formattedObsArrayForSeriesOnePlusSeriesTwo Response from SensorThings API formatted for use in a scatter plot
 * @param {*} formattedDatastreamMetadataSeriesOne Object containing Datastream metadata for the first chart series
 * @param {*} formattedDatastreamMetadataSeriesTwo Object containing Datastream metadata for the second chart series
 * @returns {undefined}
 */
const drawScatterPlotHC = 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,
      },
    ],
  });
};

Pithon Kabiro's avatar
Pithon Kabiro committed
734
/**
Pithon Kabiro's avatar
Pithon Kabiro committed
735
 * Follows "@iot.nextLink" links in SensorThingsAPI's response
Pithon Kabiro's avatar
Pithon Kabiro committed
736
 * Appends new results to existing results
Pithon Kabiro's avatar
Pithon Kabiro committed
737
 * @async
738
739
 * @param {Promise} responsePromise Promise object resulting from an Axios GET request
 * @returns {Object} Object containing results from all the "@iot.nextLink" links
Pithon Kabiro's avatar
Pithon Kabiro committed
740
 */
741
const followNextLink = function (responsePromise) {
Pithon Kabiro's avatar
Pithon Kabiro committed
742
  if (!responsePromise) return;
Pithon Kabiro's avatar
Pithon Kabiro committed
743
  return responsePromise
744
    .then((lastSuccess) => {
Pithon Kabiro's avatar
Pithon Kabiro committed
745
746
747
      if (lastSuccess.data["@iot.nextLink"]) {
        return followNextLink(
          axios.get(lastSuccess.data["@iot.nextLink"])
748
        ).then((nextLinkSuccess) => {
Pithon Kabiro's avatar
Pithon Kabiro committed
749
750
751
752
753
754
755
756
757
          nextLinkSuccess.data.value = lastSuccess.data.value.concat(
            nextLinkSuccess.data.value
          );
          return nextLinkSuccess;
        });
      } else {
        return lastSuccess;
      }
    })
758
    .catch((err) => {
759
      console.error(err);
Pithon Kabiro's avatar
Pithon Kabiro committed
760
761
762
    });
};

763
764
765
/**
 * Retrieve all the Observations from a Datastream after traversing all the "@iot.nextLink" links
 * @async
766
767
 * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request
 * @returns {Promise} A promise that contains an array of Observations from a single Datastream when fulfilled
768
 */
769
const getCombinedObservationsFromAllNextLinks = function (
770
  httpGetRequestPromise
771
) {
772
  return followNextLink(httpGetRequestPromise)
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
    .then((success) => {
      const successValue = success.data.value;
      // Array that will hold the combined observations
      const combinedObservations = [];
      successValue.forEach((dataObj) => {
        // Each page of results will have a dataArray that holds the observations
        const dataArrays = dataObj.dataArray;
        combinedObservations.push(...dataArrays);
      });

      return new Promise((resolve, reject) => {
        resolve(combinedObservations);
      });
    })
    .catch((err) => {
788
      console.error(err);
789
790
    });
};
791

792
793
794
795
796
797
/**
 * Retrieve the metadata for a Datastream as well as the Observations corresponding to it
 * @async
 * @param {Promise} metadataPlusObsPromiseArray An array that contains two promises, one for datastream metadata, the other for observations
 * @returns {Promise} A promise that contains two arrays when fulfilled, one for datastream metadata and the other for observations
 */
798
const getMetadataPlusObservationsFromSingleDatastream = async function (
799
  metadataPlusObsPromiseArray
800
) {
801
802
803
  try {
    // Array to store our final result
    const combinedResolvedPromises = [];
804

805
806
    // Use for/of loop - we need to maintain the order of execution of the async operations
    for (const promise of metadataPlusObsPromiseArray) {
807
      // Resolved value of a single promise
808
809
810
      const resolvedPromise = await promise;
      combinedResolvedPromises.push(resolvedPromise);
    }
811
812
813
814

    return combinedResolvedPromises;
  } catch (err) {
    console.error(err);
815
816
817
  }
};

818
819
820
/**
 * Retrieve all the Observations from an array of Observations promises
 * @async
821
 * @param {Promise} observationPromiseArray An array that contains N observation promises
822
823
824
825
826
 * @returns {Promise} A promise that contains an array of Observations from multiple Datastreams when fulfilled
 */
const getObservationsFromMultipleDatastreams = async function (
  observationPromiseArray
) {
827
828
829
  try {
    // Array to store our final result
    const observationsAllDatastreamsArr = [];
830

831
832
    // Use for/of loop - we need to maintain the order of execution of the async operations
    for (const observationPromise of observationPromiseArray) {
833
834
835
836
      // Observations from a single Datastream
      const observations = await observationPromise;
      observationsAllDatastreamsArr.push(observations);
    }
837
838
839
840

    return observationsAllDatastreamsArr;
  } catch (err) {
    console.error(err);
841
842
  }
};
843

844
845
846
847
848
849
850
851
852
853
854
/**
 * Retrieve the metadata from multiple Datastreams and the Observations corresponding to these Datastreams
 * @async
 * @param {Array} bldgSensorSamplingRateArr A 3*N array containing buildings, sensors & sampling rates as strings, e.g. ["101", "rl", "60min"]
 * @returns {Promise} A promise that contains a N*2 array (the first element is an array of Observations and the second element is an array of Datastream metadata objects) when fulfilled
 */
const getMetadataPlusObservationsFromMultipleDatastreams = async function (
  bldgSensorSamplingRateArr
) {
  try {
    if (!bldgSensorSamplingRateArr) return;
855

856
857
858
859
860
    // Datastreams IDs
    const datastreamsIdsArr = bldgSensorSamplingRateArr.map(
      (bldgSensorSamplingRate) =>
        getDatastreamIdFromBuildingNumber(...bldgSensorSamplingRate)
    );
861

862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
    // Observations URLs
    const observationsUrlArr = datastreamsIdsArr.map((datastreamId) =>
      getObservationsUrl(BASE_URL, datastreamId)
    );

    // Datastreams URLs
    const datastreamsUrlArr = datastreamsIdsArr.map((datastreamId) =>
      getDatastreamUrl(BASE_URL, datastreamId)
    );

    // Promise objects - Observations
    const observationsPromisesArr = observationsUrlArr.map((obsUrl) =>
      getCombinedObservationsFromAllNextLinks(
        axiosGetRequest(obsUrl, QUERY_PARAMS_COMBINED)
      )
    );

    // Observations array
    const observationsArr = await getObservationsFromMultipleDatastreams(
      observationsPromisesArr
    );

    // Metadata array
    const metadataArr = await getMetadataFromMultipleDatastreams(
      datastreamsUrlArr
    );
888

889
890
891
892
893
    return [observationsArr, metadataArr];
  } catch (err) {
    console.error(err);
  }
};
894

895
896
897
/**
 * Test drawing of scatter plot chart
 */
898
const drawScatterPlotHCTest2 = async function () {
899
900
  const sensorsOfInterestArr = [
    ["225", "vl", "60min"],
901
902
    // ["125", "rl", "60min"],
    ["weather_station_521", "outside_temp", "60min"],
903
904
905
906
907
  ];

  const observationsPlusMetadata =
    await getMetadataPlusObservationsFromMultipleDatastreams(
      sensorsOfInterestArr
908
909
    );

910
911
912
913
914
915
  // Extract the observations and metadata for each sensor
  // Array elements in same order as input array
  const [
    [obsSensorOneArr, obsSensorTwoArr],
    [metadataSensorOne, metadataSensorTwo],
  ] = observationsPlusMetadata;
916
917

  drawScatterPlotHC(
918
    formatSTAResponseForScatterPlot(obsSensorOneArr, obsSensorTwoArr),
919
920
    formatDatastreamMetadataForChart(metadataSensorOne),
    formatDatastreamMetadataForChart(metadataSensorTwo)
921
  );
922
923
924
};

(async () => {
925
  await drawScatterPlotHCTest2();
926
})();
927

928
929
930
931
932
933
934
935
export {
  BASE_URL,
  QUERY_PARAMS_COMBINED,
  getDatastreamIdFromBuildingNumber,
  getDatastreamUrl,
  getObservationsUrl,
  createTemporalFilterString,
  axiosGetRequest,
936
  getMetadataFromSingleDatastream,
937
938
939
  formatDatastreamMetadataForChart,
  formatSTAResponseForHeatMap,
  drawHeatMapHC,
940
  formatSTAResponseForLineChartOrScatterPlot,
941
942
  drawLineChartHC,
  getCombinedObservationsFromAllNextLinks,
943
  getMetadataPlusObservationsFromSingleDatastream,
944
};