*** Backup Mirror *** The web interface and JSON api for the ConnectedHumber Air Quality Monitoring Project.
https://github.com/ConnectedHumber/Air-Quality-Web
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
293 lines
8.2 KiB
293 lines
8.2 KiB
"use strict"; |
|
|
|
import HeatmapOverlay from 'leaflet-heatmap'; |
|
|
|
import Config from './Config.mjs'; |
|
|
|
import GetFromUrl from './Helpers/GetFromUrl.mjs'; |
|
import { RenderGradient } from './Helpers/GradientHelpers.mjs'; |
|
import Guage from './Guage.mjs'; |
|
|
|
|
|
class LayerHeatmap { |
|
/** |
|
* Creates a new heatmap manager wrapper class fort he given map. |
|
* @param {L.Map} in_map The leaflet map to attach to. |
|
*/ |
|
constructor(in_map, in_device_data) { |
|
this.map = in_map; |
|
this.device_data = in_device_data; |
|
|
|
this.overlay_config = { |
|
radius: Config.heatmap.blob_radius, |
|
minOpacity: 0.5, |
|
maxOpacity: 0.8, |
|
scaleRadius: true, |
|
useLocalExtrema: false, |
|
|
|
latField: "latitude", |
|
lngField: "longitude", |
|
valueField: "value", |
|
|
|
gradient: { } // Automatically filled in further down |
|
}; |
|
this.layer = new L.LayerGroup(); |
|
this.map.addLayer(this.layer); |
|
this.recreate_overlay(); |
|
|
|
// Custom configuration directives to apply based on the reading type displayed. |
|
this.reading_type_configs = { |
|
"PM25": { |
|
/* |
|
* Range Midpoint Name Colour |
|
* 0 - 11 5.5 Low 1 #9CFF9C |
|
* 12 - 23 17.5 Low 2 #31FF00 |
|
* 24 - 35 29.5 Low 3 #31CF00 |
|
* 36 - 41 38.5 Moderate 1 #FFFF00 |
|
* 42 - 47 44.5 Moderate 2 #FFCF00 |
|
* 48 - 53 50.5 Moderate 3 #FF9A00 |
|
* 54 - 58 56 High 1 #FF6464 |
|
* 59 - 64 61.5 High 2 #FF0000 |
|
* 65 - 70 67.5 High 3 #990000 |
|
* 71+ n/a Very high #CE30FF |
|
*/ |
|
max: 75, |
|
gradient: { |
|
"0": "#9CFF9C", "5.5": "#9CFF9C", // Low 1 |
|
"17.5": "#31FF00", // Low 2 |
|
"29.5": "#31CF00", // Low 3 |
|
"38.5": "#FFFF00", // Moderate 1 |
|
"44.5": "#FFCF00", // Moderate 2 |
|
"50.5": "#FF9A00", // Moderate 3 |
|
"56": "#FF6464", // High 1 |
|
"61.5": "#FF0000", // High 2 |
|
"67.5": "#990000", // High 3 |
|
"72.5": "#CE30FF", "75": "#CE30FF", // Very high |
|
} |
|
}, |
|
"PM10": { |
|
/* |
|
* Range Midpoint Name Colour |
|
* 0-16 8 Low 1 #9CFF9C |
|
* 17-33 25 Low 2 #31FF00 |
|
* 34-50 42 Low 3 #31CF00 |
|
* 51-58 54.5 Moderate 1 #FFFF00 |
|
* 59-66 62.5 Moderate 2 #FFCF00 |
|
* 67-75 71 Moderate 3 #FF9A00 |
|
* 76-83 79.5 High 1 #FF6464 |
|
* 84-91 87.5 High 2 #FF0000 |
|
* 92-100 96 High 3 #990000 |
|
* 101 105.5 Very High #CE30FF |
|
*/ |
|
max: 110, |
|
gradient: { |
|
"0": "#9CFF9C", "8": "#9CFF9C", // Low 1 |
|
"25": "#31FF00", // Low 2 |
|
"42": "#31CF00", // Low 3 |
|
"54.5": "#FFFF00", // Moderate 1 |
|
"62.5": "#FFCF00", // Moderate 2 |
|
"71": "#FF9A00", // Moderate 3 |
|
"79.5": "#FF6464", // High 1 |
|
"87.5": "#FF0000", // High 2 |
|
"96": "#990000", // High 3 |
|
"105.5": "#CE30FF", "110": "#CE30FF", // Very high |
|
} |
|
}, |
|
"humidity": { |
|
max: 100, |
|
gradient: { |
|
"0": "hsla(176, 77%, 40%, 0)", |
|
"50": "hsl(176, 77%, 40%)", |
|
"100": "blue" |
|
} |
|
}, |
|
"temperature": { |
|
max: 40, |
|
gradient: { |
|
"0": "blue", |
|
"5": "cyan", |
|
"15": "green", |
|
"20": "yellow", |
|
"30": "orange", |
|
"40": "red" |
|
} |
|
}, |
|
"pressure": { |
|
max: 1100, |
|
gradient: { |
|
"870": "purple", |
|
"916": "red", |
|
"962": "orange", |
|
"1008": "yellow", |
|
"1054": "green", |
|
"1100": "#BFED91" |
|
} |
|
} |
|
}; |
|
|
|
this.guage = new Guage(document.getElementById("canvas-guage")); |
|
|
|
this.reading_cache = new Map(); |
|
} |
|
|
|
/** |
|
* Re-creates the heatmap overlay layer. |
|
* Needed sometimes internally to work around an annoying bug. |
|
*/ |
|
recreate_overlay() { |
|
if(typeof this.heatmap != "undefined") |
|
this.layer.removeLayer(this.heatmap); |
|
this.heatmap = new HeatmapOverlay(this.overlay_config); |
|
this.layer.addLayer(this.heatmap); |
|
} |
|
|
|
/** |
|
* Sets the display data to the given array of data points. |
|
* @param {object[]} readings_list The array of data points to display. |
|
*/ |
|
set_data(readings_list) { |
|
// Substitute in the device locations |
|
for(let reading of readings_list) { |
|
let device_info = this.device_data.get_by_id(reading.device_id); |
|
reading.latitude = device_info.latitude; |
|
reading.longitude = device_info.longitude; |
|
} |
|
|
|
let data_object = { |
|
max: 0, |
|
data: readings_list |
|
}; |
|
|
|
if(typeof this.reading_type_configs[this.reading_type] != "undefined") |
|
data_object.max = this.reading_type_configs[this.reading_type].max; |
|
else |
|
data_object.max = readings_list.reduce((prev, next) => next.value > prev ? next.value : prev, 0); |
|
|
|
console.log("[map/heatmap] Displaying", this.reading_type, data_object); |
|
|
|
this.heatmap.setData(data_object); |
|
} |
|
|
|
/** |
|
* Updates the heatmap with data for the specified datetime & reading type, |
|
* fetching new data if necessary. |
|
* @param {Date} datetime The datetime to display. |
|
* @param {string} reading_type The reading type to display data for. |
|
* @return {Promise} A promise that resolves when the operation is completed. |
|
*/ |
|
async update_data(datetime, reading_type) { |
|
if(!(datetime instanceof Date)) |
|
throw new Error("Error: 'datetime' must be an instance of Date."); |
|
if(typeof reading_type != "string") |
|
throw new Error("Error: 'reading_type' must be a string."); |
|
|
|
this.datetime = datetime; |
|
this.reading_type = reading_type; |
|
|
|
console.log("[map/heatmap] Updating values to", this.reading_type, "@", this.datetime); |
|
|
|
if(typeof this.reading_type_configs[this.reading_type] != "undefined") { |
|
this.overlay_config.gradient = RenderGradient( |
|
this.reading_type_configs[this.reading_type].gradient, |
|
this.reading_type_configs[this.reading_type].max |
|
); |
|
console.log("[map/heatmap] Gradient is now", this.overlay_config.gradient); |
|
this.recreate_overlay(); |
|
} |
|
else { |
|
delete this.overlay_config.gradient; |
|
} |
|
|
|
this.guage.set_spec( |
|
this.reading_type_configs[this.reading_type].gradient, |
|
this.reading_type_configs[this.reading_type].max |
|
); |
|
this.guage.render(); |
|
|
|
try { |
|
this.set_data(await this.fetch_data(this.datetime, this.reading_type)); |
|
} catch(error) { |
|
console.log(error); |
|
alert(error); |
|
} |
|
} |
|
|
|
/** |
|
* Fetches & decodes data for the given datetime and the current reading type. |
|
* @param {Date} datetime The Date to fetch data for. |
|
* @param {string} reading_type The reading type code to fetch data for. |
|
* @return {Promise} The requested data array, as the return value of a promise |
|
*/ |
|
async fetch_data(datetime, reading_type) { |
|
let cache_key = `${reading_type}|${datetime.toISOString()}`; |
|
let result = this.reading_cache.get(cache_key); |
|
|
|
if(typeof result == "undefined") { |
|
result = JSON.parse(await GetFromUrl( |
|
`${Config.api_root}?action=fetch-data&datetime=${encodeURIComponent(datetime.toISOString())}&reading_type=${encodeURIComponent(reading_type)}` |
|
)); |
|
this.prune_cache(100); |
|
this.reading_cache.set(cache_key, { data: result, inserted: new Date() }); |
|
} |
|
else |
|
result = result.data; |
|
|
|
return result; |
|
} |
|
|
|
/** |
|
* Prunes the reading cache, leaving at most newest_count items behind. |
|
* The items inserted first are deleted first. |
|
* @param {Number} newest_count The numebr of items to leave behind in the cache. |
|
* @returns {Number} The number of items deleted from the cache. |
|
*/ |
|
prune_cache(newest_count) { |
|
let items = []; |
|
for(let next_key of this.reading_cache) { |
|
let cache_item = this.reading_cache.get(next_key); |
|
if(typeof cache_item == "undefined") { |
|
this.reading_cache.delete(cache_item); |
|
continue; |
|
} |
|
items.push({ |
|
key: next_key, |
|
date: cache_item.inserted |
|
}); |
|
} |
|
items.sort((a, b) => a.date - b.date); |
|
let deleted = 0; |
|
for(let i = 0; i < items.length - newest_count; i++) { |
|
this.reading_cache.delete(this.items[i].key); |
|
deleted++; |
|
} |
|
return deleted; |
|
} |
|
|
|
/** |
|
* Whether the reading cache contains data for the given datetime & reading type. |
|
* @param {Date} datetime The datetime to check. |
|
* @param {string} reading_type The reading type code to check. |
|
* @return {Boolean} Whether the reading cache contains data for the requested datetime & reading type. |
|
*/ |
|
is_data_cached(datetime, reading_type) { |
|
let cache_key = Symbol.for(`${reading_type}|${datetime.toISOString()}`); |
|
return this.reading_cache.has(cache_key); |
|
} |
|
|
|
|
|
async update_reading_type(reading_type) { |
|
await this.update_data( |
|
this.datetime, |
|
reading_type |
|
); |
|
} |
|
|
|
async refresh_display() { |
|
await this.update_data( |
|
this.datetime, |
|
this.reading_type |
|
); |
|
} |
|
} |
|
|
|
export default LayerHeatmap;
|
|
|