Browse Source

Colour the markers instead of having a heatmap

Goodbye, old friend :-(

That heatmap thingy took several revisions and lots fo hard work, but it 
just didn't work out :-( :-( :'(
pull/65/head
Starbeamrainbowlabs 10 months ago
parent
commit
3fe326b02d
Signed by: sbrl GPG Key ID: 1BE5172E637709C2
  1. 2
      client_src/js/Config.mjs
  2. 6
      client_src/js/Gradients.mjs
  3. 100
      client_src/js/LayerDeviceMarkers.mjs
  4. 18
      client_src/js/MapManager.mjs
  5. 46
      client_src/js/MarkerGenerator.mjs
  6. 38
      client_src/js/ReadingsData.mjs
  7. 1
      client_src/marker-embed.svg
  8. 18
      client_src/marker.svg
  9. 9299
      package-lock.json
  10. 24
      package.json
  11. 5
      rollup.config.js

2
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,

6
client_src/js/Overlay/OverlaySpecs.mjs → 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,

100
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

18
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
});

46
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;

38
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<Map>} 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;

1
client_src/marker-embed.svg

@ -0,0 +1 @@
<svg width="21.7725" height="34.385" version="1.1" viewBox="0 0 4.6085 7.2783" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="{{id_grad}}" x1="25.492" x2="25.557" y1="65.023" y2="44.643" gradientTransform="matrix(.33982 0 0 .33982 -6.3929 -14.983)" gradientUnits="userSpaceOnUse"><stop stop-color="{{colour_a}}" offset="0"/><stop stop-color="{{colour_b}}" offset="1"/></linearGradient><clipPath id="c"><path d="m40.048 68.631c-3.078-6.038-3.783-7.984-3.538-9.768 0.439-3.208 3.281-5.696 6.505-5.696 2.457 0 4.418 1.149 5.704 3.343 0.913 1.559 1.082 3.161 0.533 5.069-0.455 1.582-5.895 12.541-6.226 12.541-0.098 0-1.439-2.47-2.978-5.49zm4.445-7.594c1.168-1.168 0.58-3.06-1.079-3.477-1.301-0.326-2.675 1.155-2.368 2.554 0.345 1.572 2.28 2.09 3.448 0.922z" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-opacity=".321" stroke-width="1.39"/></clipPath></defs><path d="m0.086822 2.4041c-0.039079-1.2465 1.1491-2.3381 2.217-2.3254 1.6322 0.19404 2.5079 1.4466 2.1195 2.8589-0.15462 0.5376-2.0032 4.2617-2.1157 4.2617-0.66809-1.378-2.0406-3.2681-2.2211-4.7952zm2.7196 0.34866c0.79076-1.4035-1.3749-1.4731-1.1717-0.31331 0.11724 0.5342 0.77479 0.71022 1.1717 0.31331z" fill="url(#{{id_grad}})" stroke-width=".33982"/><path transform="matrix(.33982 0 0 .33982 -12.313 -17.988)" d="m40.048 68.631c-3.078-6.038-3.783-7.984-3.538-9.768 0.439-3.208 3.281-5.696 6.505-5.696 2.457 0 4.418 1.149 5.704 3.343 0.913 1.559 1.082 3.161 0.533 5.069-0.455 1.582-5.895 12.541-6.226 12.541-0.098 0-1.439-2.47-2.978-5.49zm4.445-7.594c1.168-1.168 0.58-3.06-1.079-3.477-1.301-0.326-2.675 1.155-2.368 2.554 0.345 1.572 2.28 2.09 3.448 0.922z" clip-path="url(#c)" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-opacity=".321" stroke-width="1.39"/><path d="m1.2962 5.334c-1.046-2.0518-1.2855-2.7131-1.2023-3.3194 0.14918-1.0901 1.115-1.9356 2.2105-1.9356 0.83494 0 1.5013 0.39045 1.9383 1.136 0.31026 0.52978 0.36769 1.0742 0.18112 1.7225-0.15462 0.5376-2.0032 4.2617-2.1157 4.2617-0.033302 0-0.489-0.83936-1.012-1.8656zm1.5105-2.5806c0.39691-0.39691 0.1971-1.0398-0.36667-1.1816-0.44211-0.11078-0.90902 0.39249-0.80469 0.8679 0.11724 0.5342 0.77479 0.71022 1.1717 0.31331z" fill="none" stroke="{{colour_dark}}" stroke-opacity=".956" stroke-width=".15802"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

18
client_src/marker.svg

@ -1,6 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
@ -15,7 +13,7 @@
viewBox="0 0 13.562857 21.42"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="marker.svg">
<defs
id="defs2">
@ -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" />
<metadata
id="metadata5">
<rdf:RDF>
@ -91,10 +90,11 @@
id="layer1"
transform="translate(-27.03016,-48.232836)">
<path
style="fill:url(#linearGradient844);fill-opacity:1;stroke:none;stroke-width:0.46500003;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.95686275"
d="m 30.844224,63.930232 c -3.078474,-6.038878 -3.783282,-7.984375 -3.538785,-9.76818 0.439793,-3.20864 3.281231,-5.696716 6.505776,-5.696716 2.457647,0 4.418161,1.149131 5.704071,3.343371 0.913856,1.559378 1.082523,3.161787 0.533646,5.069884 -0.455307,1.582814 -5.895833,12.541745 -6.226308,12.541745 -0.09882,0 -1.439103,-2.470547 -2.9784,-5.490104 z m 4.445635,-7.594744 c 1.16819,-1.168191 0.580604,-3.060887 -1.079611,-3.477574 -1.30134,-0.326616 -2.675892,1.155891 -2.368663,2.554695 0.345401,1.572603 2.280617,2.090534 3.448274,0.922879 z"
style="fill:url(#linearGradient844);fill-opacity:1;stroke:none;stroke-width:0.465;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.956863"
d="m 27.286537,55.308491 c -0.115852,-3.668092 3.911381,-6.767612 6.524678,-6.843155 4.803399,0.571307 7.380181,4.257602 6.237717,8.413255 -0.455307,1.582814 -5.895833,12.541745 -6.226308,12.541745 -1.966961,-4.055469 -6.005853,-9.61727 -6.536087,-14.111845 z m 8.003322,1.026997 c 2.327368,-4.130254 -4.046386,-4.335875 -3.448274,-0.922879 0.345401,1.572603 2.280617,2.090534 3.448274,0.922879 z"
id="path842"
inkscape:connector-curvature="0" />
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccc" />
<path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:1.39091456;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.32129965"
d="m 40.048125,68.631745 c -3.078476,-6.03888 -3.783284,-7.98438 -3.538787,-9.76818 0.439793,-3.20864 3.281227,-5.69672 6.505777,-5.69672 2.45765,0 4.41816,1.14913 5.70407,3.34337 0.91386,1.55938 1.08252,3.16179 0.53365,5.06989 -0.45531,1.58281 -5.89584,12.54174 -6.22631,12.54174 -0.0988,0 -1.43911,-2.47055 -2.9784,-5.4901 z m 4.44563,-7.59475 c 1.16819,-1.16819 0.58061,-3.06088 -1.07961,-3.47757 -1.30134,-0.32662 -2.67589,1.15589 -2.36866,2.55469 0.3454,1.57261 2.28062,2.09054 3.44827,0.92288 z"

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

9299
package-lock.json

File diff suppressed because it is too large

24
package.json

@ -18,17 +18,16 @@
},
"homepage": "https://github.com/sbrl/ConnectedHumber-Air-Quality-Interface#readme",
"dependencies": {
"chart.js": "^2.9.3",
"chart.js": "^2.9.4",
"chroma-js": "^2.1.0",
"d3-delaunay": "^5.2.1",
"dom-create-element-query-selector": "github:hekigan/dom-create-element-query-selector",
"event-emitter-es6": "^1.1.5",
"iso8601-js-period": "^0.2.1",
"leaflet": "^1.6.0",
"leaflet": "^1.7.1",
"leaflet-easyprint": "^2.1.9",
"leaflet-fullscreen": "^1.0.2",
"leaflet.markercluster": "^1.4.1",
"moment": "^2.24.0",
"moment": "^2.29.1",
"nanomodal": "^5.1.1",
"shepherd.js": "^7.1.4",
"smartsettings": "^1.2.3",
@ -36,22 +35,23 @@
"xml-writer": "^1.7.0"
},
"devDependencies": {
"@types/chart.js": "^2.9.21",
"@types/chroma-js": "^2.0.0",
"@types/d3-delaunay": "^4.1.0",
"@types/chart.js": "^2.9.30",
"@types/chroma-js": "^2.1.3",
"@types/event-emitter-es6": "^1.1.0",
"@types/leaflet": "^1.5.12",
"@types/leaflet": "^1.5.21",
"@types/leaflet-fullscreen": "^1.0.4",
"nightdocs": "^1.0.9",
"postcss": "^8.2.4",
"postcss-copy": "^7.1.0",
"postcss-import": "^12.0.1",
"rollup": "^2.7.2",
"postcss-import": "^14.0.0",
"rollup": "^2.38.1",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-json": "^4.0.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-replace": "^2.2.0",
"rollup-plugin-terser": "^5.3.0"
"rollup-plugin-string": "^3.0.0",
"rollup-plugin-terser": "^7.0.2"
},
"docpress": {
"github": "ConnectedHumber/Air-Quality-Web",

5
rollup.config.js

@ -8,6 +8,7 @@ import postcss from 'rollup-plugin-postcss';
import { terser } from "rollup-plugin-terser";
import replace from 'rollup-plugin-replace';
import json from 'rollup-plugin-json';
import { string } from 'rollup-plugin-string'
import postcss_import from 'postcss-import';
import postcss_copy from 'postcss-copy';
@ -40,6 +41,10 @@ let plugins = [
}),
string({
include: '**/*.svg'
}),
replace({
exclude: 'node_modules/**',
values: {

Loading…
Cancel
Save