From c3b7423c7f4e9035005a59859a0462758752213b Mon Sep 17 00:00:00 2001 From: Starbeamrainbowlabs Date: Tue, 11 Jun 2019 14:00:59 +0100 Subject: [PATCH] Rewire the UI, and style the voronoi diagram a bit chroma.js is great - thanks @gka :D --- client_src/js/LayerHeatmap.mjs | 293 ----------------------- client_src/js/LayerHeatmapGlue.mjs | 86 ------- client_src/js/MapManager.mjs | 16 +- client_src/js/Overlay/VoronoiManager.mjs | 27 ++- client_src/js/Overlay/VoronoiOverlay.mjs | 7 +- client_src/js/UI.mjs | 2 +- 6 files changed, 33 insertions(+), 398 deletions(-) delete mode 100644 client_src/js/LayerHeatmap.mjs delete mode 100644 client_src/js/LayerHeatmapGlue.mjs diff --git a/client_src/js/LayerHeatmap.mjs b/client_src/js/LayerHeatmap.mjs deleted file mode 100644 index e0db8b1..0000000 --- a/client_src/js/LayerHeatmap.mjs +++ /dev/null @@ -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; diff --git a/client_src/js/LayerHeatmapGlue.mjs b/client_src/js/LayerHeatmapGlue.mjs deleted file mode 100644 index 6171af7..0000000 --- a/client_src/js/LayerHeatmapGlue.mjs +++ /dev/null @@ -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; diff --git a/client_src/js/MapManager.mjs b/client_src/js/MapManager.mjs index 16b2702..7533591 100644 --- a/client_src/js/MapManager.mjs +++ b/client_src/js/MapManager.mjs @@ -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 diff --git a/client_src/js/Overlay/VoronoiManager.mjs b/client_src/js/Overlay/VoronoiManager.mjs index 26fd472..ded44ad 100644 --- a/client_src/js/Overlay/VoronoiManager.mjs +++ b/client_src/js/Overlay/VoronoiManager.mjs @@ -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); } } diff --git a/client_src/js/Overlay/VoronoiOverlay.mjs b/client_src/js/Overlay/VoronoiOverlay.mjs index 7356ab0..9cedfd6 100644 --- a/client_src/js/Overlay/VoronoiOverlay.mjs +++ b/client_src/js/Overlay/VoronoiOverlay.mjs @@ -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 } } }); diff --git a/client_src/js/UI.mjs b/client_src/js/UI.mjs index c9f8d38..75d92bb 100644 --- a/client_src/js/UI.mjs +++ b/client_src/js/UI.mjs @@ -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) }, {