diff --git a/client_src/js/Config.mjs b/client_src/js/Config.mjs index b132d03..70ebda1 100644 --- a/client_src/js/Config.mjs +++ b/client_src/js/Config.mjs @@ -10,6 +10,8 @@ export default { // The default zoom level to use when loading the page. default_zoom: 12, + default_reading_type: "PM25", + // The number of minutes to round dates to when making time-based HTTP API requests. // Very useful for improving cache hit rates. date_rounding_interval: 6, diff --git a/client_src/js/Overlay/OverlaySpecs.mjs b/client_src/js/Gradients.mjs similarity index 92% rename from client_src/js/Overlay/OverlaySpecs.mjs rename to client_src/js/Gradients.mjs index 1c4af49..fd8cc08 100644 --- a/client_src/js/Overlay/OverlaySpecs.mjs +++ b/client_src/js/Gradients.mjs @@ -1,5 +1,6 @@ "use strict"; +import chroma from 'chroma-js'; var PM25 = { /* @@ -103,6 +104,11 @@ var specs = { unknown }; +for(let spec of Object.values(specs)) { + spec.chroma = chroma.scale(Object.values(spec.gradient)) + .domain(Object.keys(spec.gradient)); +} + export default specs; export { PM10, PM25, diff --git a/client_src/js/LayerDeviceMarkers.mjs b/client_src/js/LayerDeviceMarkers.mjs index ef04eaf..edc7560 100644 --- a/client_src/js/LayerDeviceMarkers.mjs +++ b/client_src/js/LayerDeviceMarkers.mjs @@ -10,45 +10,121 @@ import Emitter from 'event-emitter-es6'; import Config from './Config.mjs'; import DeviceReadingDisplay from './DeviceReadingDisplay.mjs'; +import MarkerGenerator from './MarkerGenerator.mjs'; import GetFromUrl from './Helpers/GetFromUrl.mjs'; import { human_time_since } from './Helpers/DateHelper.mjs'; class LayerDeviceMarkers extends Emitter { - constructor(in_map, in_device_data) { + constructor(in_map_manager, in_device_data) { super(); - this.map = in_map; + this.map_manager = in_map_manager; this.device_data = in_device_data; + this.marker_generator = new MarkerGenerator(); + // Create a new clustering layer + this.layer = null; + } + + /** + * Performs initial setup of the device markers layer. + * @return {Promise} A Promise that resolves when the initial setup is complete + */ + async setup() { + await this.update_markers(Config.default_reading_type, "now"); + } + + /** + * Replaces all existing device markers (if any) with those for a given + * reading type and datetime. + * @param {string} reading_type The reading type to use to colour the markers. + * @param {String|Date} [datetime="now"] The datetime of the data to use to colour the markers (default: the special keyword "now", which indicates to fetch data for the current time) + * @return {Promise} A Promise that resolves when the markers have been updated. + */ + async update_markers(reading_type, datetime = "now") { + // 1: Remove the old layer, if present + // -------------------------------------------------------------------- + if(this.layer !== null) + this.map_manager.map.removeLayer(this.layer); + + // 2: Create a new layer + // -------------------------------------------------------------------- this.layer = L.markerClusterGroup({ // this.layer = L.layerGroup({ zoomToBoundsOnClick: false }); - } - - async setup() { - // Add a marker for each device + + // 3: Fetch the latest readings data + // -------------------------------------------------------------------- + let device_values = await this.map_manager.readings_data.fetch(reading_type, datetime); + + // 4: Add a marker for each device + // -------------------------------------------------------------------- + let has_data = 0, has_no_data = 0, total = 0; for (let device of this.device_data.devices) { // If the device doesn't have a location, we're not interested - // FUTURE: We might be able to displaymobile devices by adding additional logic here + // FUTURE: We might be able to display mobile devices by adding additional logic here if(typeof device.latitude != "number" || typeof device.longitude != "number") continue; - this.add_device_marker(device); + + console.log(`[LayerDeviceMarkers] id =`, device.id, `name =`, device.name, `location: (`, device.latitude, `,`, device.longitude, `)`); + + if(device_values.has(device.id)) { + this.add_device_marker(device, reading_type, device_values.get(device.id).value); + console.log(`has value`); + has_data++; + } + else { + this.add_device_marker(device, "unknown"); + console.log(`doesn't have value`); + has_no_data++; + } + + total++; } + console.log(`[LayerDeviceMarkers] has_data`, has_data, `has_no_data`, has_no_data, `total`, total); - // Display this layer - this.map.addLayer(this.layer); + // 5: Display the new layer + // -------------------------------------------------------------------- + this.map_manager.map.addLayer(this.layer); } - add_device_marker(device) { + /** + * Adds a single device marker with a given reading type and value. + * @param {Object} device The object representing the device to add. + * @param {string} reading_type The reading type to use when colouring the marker. The special "unknown" reading type causes a default blue marker to be shown (regardless of the value passed). + * @param {number} value The reading value to use when colouring the marker. + */ + add_device_marker(device, reading_type, value) { + let icon; + if(reading_type !== "unknown") { + icon = L.divIcon({ + className: "device-marker-icon", + html: this.marker_generator.marker(value, reading_type), + iconSize: L.point(17.418, 27.508), + iconAnchor: L.point(8.71, 27.16) + }); + console.log(`[LayerDeviceMarkers/add_device_marker] got value`); + } + else { + icon = L.divIcon({ + className: "device-marker-icon icon-unknown", + html: this.marker_generator.marker_default(), + iconSize: L.point(17.418, 27.508), + iconAnchor: L.point(8.71, 27.16) + }); + console.log(`[LayerDeviceMarkers/add_device_marker] unknown value`); + } + // Create the marker let marker = L.marker( L.latLng(device.latitude, device.longitude), { // See https://leafletjs.com/reference-1.4.0.html#marker title: `Device: ${device.name}`, autoPan: true, - autoPanPadding: L.point(100, 100) + autoPanPadding: L.point(100, 100), + icon } ); // Create the popup diff --git a/client_src/js/MapManager.mjs b/client_src/js/MapManager.mjs index b6c31e2..0ee8cd6 100644 --- a/client_src/js/MapManager.mjs +++ b/client_src/js/MapManager.mjs @@ -8,15 +8,16 @@ import 'leaflet-easyprint'; import Config from './Config.mjs'; import LayerDeviceMarkers from './LayerDeviceMarkers.mjs'; -import VoronoiManager from './Overlay/VoronoiManager.mjs'; // import LayerHeatmap from './LayerHeatmap.mjs'; // import LayerHeatmapGlue from './LayerHeatmapGlue.mjs'; import DeviceData from './DeviceData.mjs'; +import ReadingsData from './ReadingsData.mjs'; import UI from './UI.mjs'; class MapManager { constructor() { console.log(Config); + this.readings_data = new ReadingsData(); } async setup() { @@ -55,9 +56,6 @@ class MapManager { Promise.all([ this.setup_device_markers.bind(this)() .then(() => console.info("[map] Device markers loaded successfully.")), - - this.setup_overlay.bind(this)() - .then(this.setup_layer_control.bind(this)) ]).then(() => document.querySelector("main").classList.remove("working-visual")); // Add the heatmap @@ -69,13 +67,6 @@ class MapManager { // .then(() => console.info("[map] Time dimension initialised.")); } - async setup_overlay() { - this.overlay = new VoronoiManager(this.device_data, this.map); - 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({ @@ -113,7 +104,7 @@ class MapManager { } async setup_device_markers() { - this.device_markers = new LayerDeviceMarkers(this.map, this.device_data); + this.device_markers = new LayerDeviceMarkers(this, this.device_data); await this.device_markers.setup(); } @@ -149,8 +140,7 @@ class MapManager { "OpenStreetMap": this.layer_openstreet }, { // Overlay(s) "Devices": this.device_markers.layer, - // FUTURE: Have 1 heatmap layer per reading type? - "Heatmap": this.overlay.layer + // "Heatmap": this.overlay.layer }, { // Options }); diff --git a/client_src/js/MarkerGenerator.mjs b/client_src/js/MarkerGenerator.mjs new file mode 100644 index 0000000..e0f024a --- /dev/null +++ b/client_src/js/MarkerGenerator.mjs @@ -0,0 +1,46 @@ +"use strict"; + +import chroma from 'chroma-js'; + +import marker_svg from '../marker-embed.svg'; +import gradients from './Gradients.mjs'; + +class MarkerGenerator { + constructor() { + + } + + marker_default() { + return marker_svg.replace("{{colour_a}}", "#1975c8") + .replace("{{colour_b}}", "#5ea6d5") + .replace("{{colour_dark}}", "#2e6d99"); + } + + marker(value, type) { + let col = this.get_colour(value, type); + let result = marker_svg.replace(/\{\{colour_a\}\}/g, col.darken(0.25).hex()) + .replace(/\{\{colour_b\}\}/g, col.brighten(0.25).hex()) + .replace(/\{\{colour_dark\}\}/g, col.darken(1)) + .replace(/\{\{id_grad\}\}/g, `marker-grad-${btoa(`${type}-${value}`).replace(/\+/g, "-").replace(/\//g, "_")}`); + return result; + } + + /** + * Fetches the colour for a given value of a given type. + * Currently a gradient is not used - i.e. a value is coloured according to + * the last threshold it crossed in the associated gradient definition. + * TODO: Calculate the gradient instead. + * @param {number} value The value to calculate the colour for. + * @param {string} type The type of measurement value we're working with. Determines the gradient to use. + * @return {chroma} The calculated colour. + */ + get_colour(value, type) { + if(typeof gradients[type] == "undefined") { + console.warn(`[MarkerGenerator] Warning: Unknown gradient type '${type}', using unknown instead.`); + type = "unknown"; + } + return gradients[type].chroma(value); + } +} + +export default MarkerGenerator; diff --git a/client_src/js/ReadingsData.mjs b/client_src/js/ReadingsData.mjs new file mode 100644 index 0000000..28e3849 --- /dev/null +++ b/client_src/js/ReadingsData.mjs @@ -0,0 +1,38 @@ +"use strict"; + +import Config from './Config.mjs'; + +import GetFromUrl from './Helpers/GetFromUrl.mjs'; + +class ReadingsData { + constructor() { + + } + + /** + * Fetches the data from all currently active devices at a given datetime + * and for a given reading type. + * @param {string} reading_type The reading type to fetch data for. + * @param {String|Date} [datetime="now"] The datetime to fetch the data for. + * @return {Promise} A promise that resolves to a Map keyed by device IDs that contains the data objects returned by the fetch-data API action. + */ + async fetch(reading_type, datetime = "now") { + if(datetime instanceof Date) + datetime = datetime.toISOString(); + // TODO: memoize this + let data = await this.__make_request(reading_type, datetime); + let result = new Map(); + for(let item of data) + result.set(item.device_id, item); + + return result; + } + + async __make_request(reading_type, datetime) { + return JSON.parse(await GetFromUrl( + `${Config.api_root}?action=fetch-data&datetime=${encodeURIComponent(datetime)}&reading_type=${encodeURIComponent(reading_type)}` + )); + } +} + +export default ReadingsData; diff --git a/client_src/marker-embed.svg b/client_src/marker-embed.svg new file mode 100644 index 0000000..85180b1 --- /dev/null +++ b/client_src/marker-embed.svg @@ -0,0 +1 @@ + diff --git a/client_src/marker.svg b/client_src/marker.svg index 4cf630a..53f396f 100644 --- a/client_src/marker.svg +++ b/client_src/marker.svg @@ -1,6 +1,4 @@ - - @@ -68,11 +66,12 @@ fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" - inkscape:window-width="1825" + inkscape:window-width="1831" inkscape:window-height="1047" - inkscape:window-x="95" + inkscape:window-x="89" inkscape:window-y="33" - inkscape:window-maximized="1" /> + inkscape:window-maximized="1" + inkscape:document-rotation="0" /> @@ -91,10 +90,11 @@ id="layer1" transform="translate(-27.03016,-48.232836)"> + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccccc" />