mirror of
https://github.com/ConnectedHumber/Air-Quality-Web
synced 2024-11-22 06:23:01 +00:00
Merge branch 'heatmap-time' into dev
This commit is contained in:
commit
7486dcddde
6 changed files with 231 additions and 16 deletions
|
@ -7,6 +7,10 @@ import GetFromUrl from './Helpers/GetFromUrl.mjs';
|
||||||
|
|
||||||
|
|
||||||
class LayerHeatmap {
|
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) {
|
constructor(in_map) {
|
||||||
this.map = in_map;
|
this.map = in_map;
|
||||||
|
|
||||||
|
@ -77,8 +81,14 @@ class LayerHeatmap {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.reading_cache = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-creates the heatmap overlay layer.
|
||||||
|
* Needed sometimes internally to work around an annoying bug.
|
||||||
|
*/
|
||||||
recreate_overlay() {
|
recreate_overlay() {
|
||||||
if(typeof this.heatmap != "undefined")
|
if(typeof this.heatmap != "undefined")
|
||||||
this.layer.removeLayer(this.heatmap);
|
this.layer.removeLayer(this.heatmap);
|
||||||
|
@ -86,6 +96,10 @@ class LayerHeatmap {
|
||||||
this.layer.addLayer(this.heatmap);
|
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) {
|
set_data(readings_list) {
|
||||||
let data_object = {
|
let data_object = {
|
||||||
max: 0,
|
max: 0,
|
||||||
|
@ -102,6 +116,13 @@ class LayerHeatmap {
|
||||||
this.heatmap.setData(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) {
|
async update_data(datetime, reading_type) {
|
||||||
if(!(datetime instanceof Date))
|
if(!(datetime instanceof Date))
|
||||||
throw new Error("Error: 'datetime' must be an instance of Date.");
|
throw new Error("Error: 'datetime' must be an instance of Date.");
|
||||||
|
@ -123,15 +144,75 @@ class LayerHeatmap {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.set_data(JSON.parse(await GetFromUrl(
|
this.set_data(await this.fetch_data(this.datetime, this.reading_type));
|
||||||
`${Config.api_root}?action=fetch-data&datetime=${encodeURIComponent(this.datetime.toISOString())}&reading_type=${encodeURIComponent(this.reading_type)}`
|
|
||||||
)));
|
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
alert(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) {
|
async update_reading_type(reading_type) {
|
||||||
await this.update_data(
|
await this.update_data(
|
||||||
|
@ -139,12 +220,6 @@ class LayerHeatmap {
|
||||||
reading_type
|
reading_type
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
async update_datetime(datetime) {
|
|
||||||
await this.update_data(
|
|
||||||
datetime,
|
|
||||||
this.reading_type
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LayerHeatmap;
|
export default LayerHeatmap;
|
||||||
|
|
86
client_src/js/LayerHeatmapGlue.mjs
Normal file
86
client_src/js/LayerHeatmapGlue.mjs
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
class LayerHeatmapGlue {
|
||||||
|
constructor(time_dimension, heatmap) {
|
||||||
|
this.time_dimension = time_dimension;
|
||||||
|
|
||||||
|
this.heatmap = heatmap;
|
||||||
|
|
||||||
|
this.glue_layer_const = L.TimeDimension.Layer.extend({
|
||||||
|
isReady: this.isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
attachTo(map) {
|
||||||
|
this.layer_glue = new this.glue_layer_const({
|
||||||
|
timeDimension: this.time_dimension
|
||||||
|
}).addTo(map);
|
||||||
|
this.time_dimension.on("timeload", this.update.bind(this));
|
||||||
|
this.time_dimension.on("timeloading", this.onNewTime.bind(this));
|
||||||
|
// this.time_dimension.registerSyncedLayer(this.layer_glue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onNewTime(event) {
|
||||||
|
console.log("on-new-time", arguments);
|
||||||
|
// event.time here is a number - TODO: Figure out how to keep it as a date
|
||||||
|
|
||||||
|
if(typeof this.cache[event.time] == "undefined") {
|
||||||
|
await this.loadTime(new Date(event.time));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fireLoadComplete(event.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads data into the cache for the specified datetime.
|
||||||
|
* @param {Date} time The datetime to load data in for.
|
||||||
|
* @return {[type]} [description]
|
||||||
|
*/
|
||||||
|
async loadTime(time) {
|
||||||
|
await this.heatmap.fetch_data(
|
||||||
|
time,
|
||||||
|
this.heatmap.reading_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires the loadComplete event to leaflet-timedimension know we're done loading.
|
||||||
|
* @param {Number} time The timestamp to fire the timeload event for.
|
||||||
|
*/
|
||||||
|
fireLoadComplete(time) {
|
||||||
|
this.layer_glue.fire("timeload", {
|
||||||
|
time: time
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells leaflet-timedimension if we're ready for a given time.
|
||||||
|
* @param {object} event The event object
|
||||||
|
* @return {Boolean} Whether we're ready or not
|
||||||
|
*/
|
||||||
|
isReady(event) {
|
||||||
|
// BUG: This will go awry if the user changes the reading type on the fly
|
||||||
|
// console.log("is-ready", arguments);
|
||||||
|
return this.heatmap.is_data_cached(
|
||||||
|
new Date(event.time),
|
||||||
|
this.heatmap.reading_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passes an update to the heatmap manager
|
||||||
|
* @param {[type]} event [description]
|
||||||
|
* @return {[type]} [description]
|
||||||
|
*/
|
||||||
|
async update(event) {
|
||||||
|
//console.log("update", arguments);
|
||||||
|
await this.heatmap.update_data(
|
||||||
|
new Date(event.time),
|
||||||
|
this.heatmap.reading_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LayerHeatmapGlue;
|
|
@ -3,10 +3,13 @@
|
||||||
// Import leaflet, but some plugins require it to have the variable name 'L' :-/
|
// Import leaflet, but some plugins require it to have the variable name 'L' :-/
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet-fullscreen';
|
import 'leaflet-fullscreen';
|
||||||
|
import 'iso8601-js-period';
|
||||||
|
import '../../node_modules/leaflet-timedimension/dist/leaflet.timedimension.src.withlog.js';
|
||||||
|
|
||||||
import Config from './Config.mjs';
|
import Config from './Config.mjs';
|
||||||
import LayerDeviceMarkers from './LayerDeviceMarkers.mjs';
|
import LayerDeviceMarkers from './LayerDeviceMarkers.mjs';
|
||||||
import LayerHeatmap from './LayerHeatmap.mjs';
|
import LayerHeatmap from './LayerHeatmap.mjs';
|
||||||
|
import LayerHeatmapGlue from './LayerHeatmapGlue.mjs';
|
||||||
import UI from './UI.mjs';
|
import UI from './UI.mjs';
|
||||||
|
|
||||||
class MapManager {
|
class MapManager {
|
||||||
|
@ -17,7 +20,12 @@ class MapManager {
|
||||||
setup() {
|
setup() {
|
||||||
// Create the map
|
// Create the map
|
||||||
this.map = L.map("map", {
|
this.map = L.map("map", {
|
||||||
fullscreenControl: true
|
fullscreenControl: true,
|
||||||
|
timeDimension: true,
|
||||||
|
timeDimensionOptions: {
|
||||||
|
timeInterval: `2019-01-01/${(new Date()).toISOString().split("T")[0]}`,
|
||||||
|
period: "PT6M" // 6 minutes, in ISO 8601 Durations format: https://en.wikipedia.org/wiki/ISO_8601#Durations
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.map.setView(Config.default_location, Config.default_zoom);
|
this.map.setView(Config.default_location, Config.default_zoom);
|
||||||
|
|
||||||
|
@ -28,9 +36,11 @@ class MapManager {
|
||||||
attribution: "© <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap contributors</a>"
|
attribution: "© <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap contributors</a>"
|
||||||
}).addTo(this.map);
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
// Add the attribution
|
||||||
this.map.attributionControl.addAttribution("Data: <a href='https://connectedhumber.org/'>Connected Humber</a>");
|
this.map.attributionControl.addAttribution("Data: <a href='https://connectedhumber.org/'>Connected Humber</a>");
|
||||||
this.map.attributionControl.addAttribution("<a href='https://github.com/ConnectedHumber/Air-Quality-Web/'>Air Quality Web</a> by <a href='https://starbeamrainbowlabs.com/'>Starbeamrainbowlabs</a>");
|
this.map.attributionControl.addAttribution("<a href='https://github.com/ConnectedHumber/Air-Quality-Web/'>Air Quality Web</a> by <a href='https://starbeamrainbowlabs.com/'>Starbeamrainbowlabs</a>");
|
||||||
|
|
||||||
|
|
||||||
// Add the device markers
|
// Add the device markers
|
||||||
console.info("[map] Loading device markers....");
|
console.info("[map] Loading device markers....");
|
||||||
this.setup_device_markers().then(() => {
|
this.setup_device_markers().then(() => {
|
||||||
|
@ -42,14 +52,51 @@ class MapManager {
|
||||||
|
|
||||||
// Add the heatmap
|
// Add the heatmap
|
||||||
console.info("[map] Loading heatmap....");
|
console.info("[map] Loading heatmap....");
|
||||||
this.setup_heatmap().then(() => {
|
this.setup_heatmap()
|
||||||
console.info("[map] Heatmap loaded successfully.");
|
.then(() => console.info("[map] Heatmap loaded successfully."))
|
||||||
});
|
// ...and the time dimension
|
||||||
|
.then(this.setup_time_dimension.bind(this))
|
||||||
|
.then(() => console.info("[map] Time dimension initialised."));
|
||||||
|
|
||||||
this.ui = new UI(Config, this);
|
this.ui = new UI(Config, this);
|
||||||
this.ui.setup().then(() => console.log("[map] Settings initialised."));
|
this.ui.setup().then(() => console.log("[map] Settings initialised."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup_time_dimension() {
|
||||||
|
this.layer_time = new L.TimeDimension({
|
||||||
|
period: "PT1H", // 1 hour
|
||||||
|
timeInterval: `2019-01-01T12:00:00Z/${new Date().toISOString()}`
|
||||||
|
});
|
||||||
|
//this.layer_time.on("timeloading", console.log.bind(null, "timeloading"));
|
||||||
|
|
||||||
|
this.layer_time_player = new L.TimeDimension.Player({
|
||||||
|
transitionTime: 500,
|
||||||
|
loop: false,
|
||||||
|
startOver: true,
|
||||||
|
buffer: 10 // Default: 5
|
||||||
|
}, this.layer_time);
|
||||||
|
|
||||||
|
this.layer_time_control = new L.Control.TimeDimension({
|
||||||
|
player: this.layer_time_player,
|
||||||
|
timeDimension: this.layer_time,
|
||||||
|
position: "bottomright",
|
||||||
|
autoplay: false,
|
||||||
|
minSpeed: 1,
|
||||||
|
speedStep: 0.25,
|
||||||
|
maxSpeed: 15,
|
||||||
|
timeSliderDragUpdate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.addControl(this.layer_time_control);
|
||||||
|
|
||||||
|
// Create the time dimension <---> heatmap glue object
|
||||||
|
this.layer_heatmap_glue = new LayerHeatmapGlue(
|
||||||
|
this.layer_time,
|
||||||
|
this.heatmap
|
||||||
|
);
|
||||||
|
this.layer_heatmap_glue.attachTo(this.map);
|
||||||
|
}
|
||||||
|
|
||||||
async setup_device_markers() {
|
async setup_device_markers() {
|
||||||
this.device_markers = new LayerDeviceMarkers(this.map);
|
this.device_markers = new LayerDeviceMarkers(this.map);
|
||||||
await this.device_markers.setup();
|
await this.device_markers.setup();
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import '../css/main.css';
|
import '../css/main.css';
|
||||||
|
import '../../node_modules/leaflet-timedimension/dist/leaflet.timedimension.control.css';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -1626,9 +1626,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"iso8601-js-period": {
|
"iso8601-js-period": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/iso8601-js-period/-/iso8601-js-period-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/iso8601-js-period/-/iso8601-js-period-0.2.1.tgz",
|
||||||
"integrity": "sha1-5XQP1OcguWCoXraF/CBSAWIcAYw="
|
"integrity": "sha512-iDyz2TQFBd5WhCZjruOwHj01JkQGu7YbVLCVdpA7lCGEcBzE3ffCPAhLh/M8TAp//kCixPpYN4XU54WHCxvD2Q=="
|
||||||
},
|
},
|
||||||
"isobject": {
|
"isobject": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
|
@ -1730,6 +1730,11 @@
|
||||||
"leaflet": "~0.7.4 || ~1.0.0"
|
"leaflet": "~0.7.4 || ~1.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"iso8601-js-period": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iso8601-js-period/-/iso8601-js-period-0.2.0.tgz",
|
||||||
|
"integrity": "sha1-5XQP1OcguWCoXraF/CBSAWIcAYw="
|
||||||
|
},
|
||||||
"leaflet": {
|
"leaflet": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.0.3.tgz",
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^2.8.0",
|
"chart.js": "^2.8.0",
|
||||||
"dom-create-element-query-selector": "github:hekigan/dom-create-element-query-selector",
|
"dom-create-element-query-selector": "github:hekigan/dom-create-element-query-selector",
|
||||||
|
"iso8601-js-period": "^0.2.1",
|
||||||
"leaflet": "^1.4.0",
|
"leaflet": "^1.4.0",
|
||||||
"leaflet-fullscreen": "^1.0.2",
|
"leaflet-fullscreen": "^1.0.2",
|
||||||
"leaflet-timedimension": "^1.1.0",
|
"leaflet-timedimension": "^1.1.0",
|
||||||
|
|
Loading…
Reference in a new issue