mirror of
https://github.com/ConnectedHumber/Air-Quality-Web
synced 2024-12-22 10:25:01 +00:00
Rewire the UI, and style the voronoi diagram a bit
chroma.js is great - thanks @gka :D
This commit is contained in:
parent
38345222e7
commit
c3b7423c7f
6 changed files with 33 additions and 398 deletions
|
@ -1,293 +0,0 @@
|
|||
"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;
|
|
@ -1,86 +0,0 @@
|
|||
"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;
|
|
@ -68,10 +68,13 @@ class MapManager {
|
|||
|
||||
async setup_overlay() {
|
||||
this.overlay = new VoronoiManager(this.device_data, this.map);
|
||||
await this.overlay.set_data(new Date(), "PM25");
|
||||
await this.overlay.setup();
|
||||
// No need to do this here, as it does it automatically
|
||||
// await this.overlay.set_data(new Date(), "PM25");
|
||||
}
|
||||
|
||||
setup_time_dimension() {
|
||||
// FUTURE: Replace leaflet-time-dimension with our own solution that's got a better ui & saner API?
|
||||
this.layer_time = new L.TimeDimension({
|
||||
period: "PT1H", // 1 hour
|
||||
timeInterval: `2019-01-01T12:00:00Z/${new Date().toISOString()}`
|
||||
|
@ -111,22 +114,13 @@ class MapManager {
|
|||
await this.device_markers.setup();
|
||||
}
|
||||
|
||||
async setup_heatmap() {
|
||||
this.heatmap = new LayerHeatmap(this.map, this.device_data);
|
||||
|
||||
// TODO: Use leaflet-timedimension here
|
||||
// TODO: Allow configuration of the different reading types here
|
||||
|
||||
this.heatmap.update_data(new Date(new Date-10*60), "PM25");
|
||||
}
|
||||
|
||||
setup_layer_control() {
|
||||
this.layer_control = L.control.layers({
|
||||
// Base layer(s)
|
||||
"OpenStreetMap": this.layer_openstreet
|
||||
}, { // Overlay(s)
|
||||
"Devices": this.device_markers.layer,
|
||||
// TODO: Have 1 heatmap layer per reading type?
|
||||
// FUTURE: Have 1 heatmap layer per reading type?
|
||||
"Heatmap": this.overlay.layer
|
||||
}, { // Options
|
||||
|
||||
|
|
|
@ -21,20 +21,37 @@ class VoronoiManager {
|
|||
|
||||
this.layer = null;
|
||||
|
||||
this.setup_guage();
|
||||
this.setup_overlay();
|
||||
this.last_datetime = new Date();
|
||||
this.last_reading_type = "PM25";
|
||||
}
|
||||
|
||||
setup_overlay() {
|
||||
async setup() {
|
||||
this.setup_guage();
|
||||
await this.setup_overlay();
|
||||
}
|
||||
|
||||
async setup_overlay() {
|
||||
this.overlay = new VoronoiOverlay();
|
||||
this.set_data(new Date(), "PM25"); // TODO: Make this customisable
|
||||
await this.set_data(new Date(), "PM25"); // TODO: Make this customisable? Probably elsewhere though, as this is a reasonable default
|
||||
}
|
||||
|
||||
setup_guage() {
|
||||
this.guage = new Guage(document.getElementById("canvas-guage"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
async update_reading_type(new_reading_type) {
|
||||
await this.set_data(this.last_datetime, new_reading_type);
|
||||
}
|
||||
async update_datetime(new_datetime) {
|
||||
await this.set_data(new_datetime, this.last_reading_type);
|
||||
}
|
||||
|
||||
async set_data(datetime, reading_type) {
|
||||
this.last_datetime = datetime;
|
||||
this.last_reading_type = reading_type;
|
||||
|
||||
this.spec = Specs[reading_type];
|
||||
if(typeof this.spec.chroma == "undefined")
|
||||
this.spec.chroma = chroma.scale(Object.values(this.spec.gradient))
|
||||
|
@ -68,8 +85,6 @@ class VoronoiManager {
|
|||
// Generate & add the new layer
|
||||
this.layer = this.overlay.generate_layer();
|
||||
this.layer.addTo(this.map);
|
||||
|
||||
console.log(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import L from 'leaflet';
|
||||
import { Delaunay } from 'd3-delaunay';
|
||||
import chroma from 'chroma-js';
|
||||
|
||||
import Vector2 from '../Helpers/Vector2.mjs';
|
||||
import Rectangle from '../Helpers/Rectangle.mjs';
|
||||
|
@ -110,7 +111,11 @@ class VoronoiOverlay {
|
|||
return L.geoJSON(this.render(), {
|
||||
// FUTURE: If we want to be even moar fanceh, we can check out https://leafletjs.com/reference-1.5.0.html#path
|
||||
style: (feature) => { return {
|
||||
color: feature.properties.colour,
|
||||
// Stroke
|
||||
color: chroma(feature.properties.colour).darken(0.75).toString(),
|
||||
|
||||
// Fill
|
||||
fillColor: feature.properties.colour,
|
||||
fillOpacity: 0.4
|
||||
} }
|
||||
});
|
||||
|
|
|
@ -58,7 +58,7 @@ class UI {
|
|||
callback: ((event) => {
|
||||
let new_type = this.reading_types.find((type) => type.friendly_text == event.target.value).short_descr;
|
||||
|
||||
this.map_manager.heatmap.update_reading_type(new_type);
|
||||
this.map_manager.overlay.update_reading_type(new_type);
|
||||
}).bind(this)
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue