"use strict"; // import CreateElement from 'dom-create-element-query-selector'; // We're using the git repo for now until an update is released, and rollup doesn't like that apparently import CreateElement from '../../node_modules/dom-create-element-query-selector/src/index.js'; import moment from 'moment'; // import Chart from 'chart.js'; // Chart.js pollutes the global scope, but the main entry point is going to change soon in v2.8 - which should fix our issue here import * as Chart from '../../node_modules/chart.js/dist/chart.esm.js'; import Config from './Config.mjs'; import GetFromUrl from './Helpers/GetFromUrl.mjs'; import GetContainingElement from './Helpers/GetContainingElement.mjs'; import Postify from './Helpers/Postify.mjs'; import { human_duration_unit, round_date_interval } from './Helpers/DateHelper.mjs'; class DeviceReadingDisplay { constructor(in_config, in_device_id) { this.config = in_config; /** The ID of the device to display a graph for. @type {int} */ this.device_id = in_device_id; /** The current reading type to display a graph for. @type {Object} */ this.reading_type = null; // The number of points to display at a time. this.points_resolution = 50; let date = new Date(); round_date_interval(date, Config.date_rounding_interval); this.start_time = moment(date).subtract(1, "days"); this.end_time = moment(date); this.default_colours = { borderColor: "hsla(0, 0%, 50%, 1)", backgroundColor: "hsla(0, 0%, 65%, 0.5)" }; this.reading_type_defs = { "PM10": { suggestedMin: 0, suggestedMax: 110, borderColor: "hsla(0, 82%, 56%, 1)", backgroundColor: "hsla(14, 94%, 71%, 0.57)" }, "PM25": { suggestedMin: 0, suggestedMax: 75, borderColor: "hsla(33, 70%, 51%, 1)", backgroundColor: "hsla(28, 77%, 58%, 0.63)" }, "temperature": { suggestedMin: 0, suggestedMax: 20, borderColor: "hsla(0, 77%, 45%, 1)", backgroundColor: "hsla(0, 61%, 58%, 0.59)" }, "humidity": { suggestedMin: 0, suggestedMax: 100, borderColor: "hsla(184, 69%, 40%, 1)", backgroundColor: "hsla(188, 53%, 46%, 0.58)" }, "pressure": { borderColor: "hsla(258, 67%, 40%, 1)", backgroundColor: "hsla(249, 56%, 40%, 0.66)" } }; } async setup(default_reading_type) { // 1: Display window HTML // ---------------------------------------------------- // Create the display element first, as we need it to be immediately available for inclusion in the popup window /** @type {HTMLElement} */ this.display = CreateElement("div.chart-device-data.working", CreateElement("canvas.canvas-chart"), CreateElement("ul.reading-types.button-array"), CreateElement("ul.quick-time-selector.button-array", CreateElement("li", CreateElement("button[data-timelength=1h]", "1 hour")), CreateElement("li", CreateElement("button[data-timelength=6h]", "6 hours")), CreateElement("li", CreateElement("button[data-timelength=1d].selected", "1 day")), CreateElement("li", CreateElement("button[data-timelength=1w]", "1 week")), CreateElement("li", CreateElement("button[data-timelength=1M]", "1 month")), CreateElement("li", CreateElement("button[data-timelength=3M]", "3 months")), CreateElement("li", CreateElement("button[data-timelength=1y]", "1 year")) ), CreateElement("ul.adv-time-selector.button-array", CreateElement("li", CreateElement("button.timeinterval.prev[title=Previous Time Interval]", "«")), CreateElement("li", CreateElement("input.time-start-date[type=date]")), CreateElement("li", CreateElement("input.time-start-time[type=time]")), CreateElement("li", "—"), CreateElement("li", CreateElement("input.time-end-date[type=date]")), CreateElement("li", CreateElement("input.time-end-time[type=time]")), CreateElement("li", CreateElement("button.timeinterval.next[title=Next Time Interval]", "»")) ) ); // 2: Measurement type buttons // ---------------------------------------------------- this.reading_types = await this.fetch_reading_types(); this.reading_type = this.reading_types.find((type) => type.short_descr == default_reading_type) || this.reading_types[0] // Default to the 1st reading type if we can't find the default || { short_descr: "NONE_PRESENT", friendly_text: "(none yet!)", id: -1, count: -1 }; // ...or a default object if there aren't any readings yet // Create the reading type buttons let reading_type_list = this.display.querySelector(".reading-types"); for(let reading_type of this.reading_types) { let new_element = CreateElement("li", CreateElement("button", reading_type.friendly_text) ); let button = new_element.querySelector("button"); if(reading_type.id == this.reading_type.id) button.classList.add("selected"); button.addEventListener("click", this.switch_graph_type_handler.bind(this)); new_element.dataset.id = reading_type.id; reading_type_list.appendChild(new_element); } // 2.5: Advanced time picker // ---------------------------------------------------- this.set_adv_timepicker_ui(this.start_time, this.end_time); // 3: Event listeners // ---------------------------------------------------- this.display.querySelector(".quick-time-selector") .addEventListener("click", (this.timelength_button_click_handler).bind(this)); this.display.querySelector(".timeinterval.prev") .addEventListener("click", this.timeinterval_button_click_handler.bind(this)); this.display.querySelector(".timeinterval.next") .addEventListener("click", this.timeinterval_button_click_handler.bind(this)); this.display.querySelectorAll(".adv-time-selector input").forEach(((el) => { el.addEventListener("input", this.adv_timepicker_ui_click_handler.bind(this)); }).bind(this)); // 4: Chart and ending tasks // ---------------------------------------------------- // Setup the chart itself await this.setup_chart(); this.display.classList.remove("working"); } /** * Updates the advanced time picker UI with the specified start and end dates. * The supplied start & end datetimes should Moment.js instances. * @param {moment} start_time The start datetime to display. * @param {moment} end_time The end datetime to display. */ set_adv_timepicker_ui(start_time, end_time) { this.display.querySelector(".time-start-date").value = start_time.format("YYYY-MM-DD"); this.display.querySelector(".time-start-time").value = start_time.format("HH:mm"); this.display.querySelector(".time-end-date").value = end_time.format("YYYY-MM-DD"); this.display.querySelector(".time-end-time").value = end_time.format("HH:mm"); } /** * Handles events from advanced time picker UI elements. */ async adv_timepicker_ui_click_handler() { let el_start_date = this.display.querySelector("input.time-start-date"), el_start_time = this.display.querySelector("input.time-start-time"), el_end_date = this.display.querySelector("input.time-end-date"), el_end_time = this.display.querySelector("input.time-end-time"); this.start_time = moment(`${el_start_date.value} ${el_start_time.value}`); this.end_time = moment(`${el_end_date.value} ${el_end_time.value}`); await this.update_chart(); } async timeinterval_button_click_handler(event) { let diff_seconds = this.end_time.diff(this.start_time) / 1000; if(event.target.classList.contains("prev")) { this.start_time.subtract(diff_seconds, "seconds"); this.end_time.subtract(diff_seconds, "seconds"); } else if(event.target.classList.contains("next")) { this.start_time.add(diff_seconds, "seconds"); this.end_time.add(diff_seconds, "seconds"); } else { throw new Error("Error: Failed to adjust time interval, as we couldn't tell which direction to go in."); } this.set_adv_timepicker_ui(this.start_time, this.end_time); await this.update_chart(); } async timelength_button_click_handler(event) { let timelength = event.target.dataset.timelength; if(typeof timelength == "undefined") return; let time_unit = timelength.replace(/[0-9]+/g, ""); let time_length = timelength.replace(/[^0-9]+/g, ""); this.start_time = moment().subtract(time_length, time_unit); this.end_time = moment(); this.set_adv_timepicker_ui(this.start_time, this.end_time); let popup_container = GetContainingElement(event.target, "div"); popup_container.classList.add("working"); await this.update_chart(); // Show the new button to be selected this.display.querySelectorAll(".quick-time-selector button").forEach((button) => button.classList.remove("selected")); event.target.classList.add("selected"); popup_container.classList.remove("working"); } async setup_chart() { this.chart = new Chart(this.display.querySelector("canvas").getContext("2d"), { type: "line", data: { // We need to define an initial dataset here because otherwise // Chart.js gets confused 'cause it has nothing to animate from // labels: [], datasets: [] }, options: { scales: { xAxes: [{ type: "time", time: { format: "YYYY-MM-DD HH:mm", tooltipFormat: 'll HH:mm', }, scaleLabel: { display: true, labelString: "Time" } }], yAxes: [{ ticks: { }, scaleLabel: { display: true, labelString: "Value" } }] } } }); await this.update_chart(); } async get_data() { // If there aren't any readings yet, then there's no point in requesting them if(this.reading_type.id == -1) return false; let new_data = null; // Dividing by 1000: ms -> s let average_seconds = (this.end_time.diff(this.start_time) / 1000) / this.points_resolution; console.info("Requesting data with start", this.start_time.toString(), ", end", this.end_time.toString(), ", average-seconds", average_seconds); try { new_data = JSON.parse(await GetFromUrl(`${this.config.api_root}?` + Postify({ action: "device-data", "device-id": this.device_id, "reading-type": this.reading_type.short_descr, start: this.start_time.toISOString(), end: this.end_time.toISOString(), "average-seconds": average_seconds }))); } catch(error) { // TODO: Display a nice error message here instead of an alert() // Wrap it in an Error instance if needed if(typeof error == "string") error = new Error(error.length == 0 ? "An unknown error has ocurred. It's probably on the server." : error); alert(error.message); console.error(error); return null; } console.log("[marker/popup/device-graph] Fetched data:", new_data); console.log("[marker/popup/device-graph] Point count:", new_data.length); return new_data.map((data_point) => { return { t: moment(data_point.datetime), y: Math.round(data_point.value*10000)/10000 }}); } async fetch_reading_types() { return JSON.parse(await GetFromUrl(`${this.config.api_root}?action=list-reading-types&device-id=${this.device_id}`)); } async switch_graph_type_handler(event) { // Figure out what the new reading type is this.reading_type = this.reading_types.find((type) => type.id == event.target.parentNode.dataset.id); console.log("[marker/device-graph] Reading type is now", this.reading_type); let popup_container = GetContainingElement(event.target, "div"); popup_container.classList.add("working"); // Update the button list to highlight the newly-selected reading type, but only if we managed to update the chart successfully if(await this.update_chart()) { // Remove the selected class from the old one(s?) event.target.parentNode.parentNode.querySelectorAll(`button`) .forEach((next_button) => next_button.classList.remove("selected")); // Add the selected class to the new one event.target.classList.add("selected"); } popup_container.classList.remove("working"); } async update_chart() { this.chart.data.datasets.length = 0; // Get the Chart.js data object let new_data_obj = { label: this.reading_type.friendly_text, data: await this.get_data() }; if(new_data_obj.data == null) return false; // Update the colour & suggested min/max values let def = this.reading_type_defs[this.reading_type.short_descr], y_axis = this.chart.options.scales.yAxes[0]; if(typeof def !== "undefined") { new_data_obj.borderColor = def.borderColor; new_data_obj.backgroundColor = def.backgroundColor; } else { new_data_obj.borderColor = this.default_colours.borderColor; new_data_obj.backgroundColor = this.default_colours.backgroundColor; } if(typeof def !== "undefined" && typeof def.suggestedMin !== "undefined") { y_axis.ticks.suggestedMin = def.suggestedMin; y_axis.ticks.suggestedMax = def.suggestedMax; } else { delete y_axis.ticks.suggestedMin; delete y_axis.ticks.suggestedMax; } this.chart.data.datasets.push(new_data_obj); // Update the x axis labels // this.chart.data.labels = new_data_obj.data.map((point) => point.t); this.chart.options.scales.xAxes[0].time.unit = human_duration_unit(this.end_time.diff(this.start_time)); console.log(`New unit: ${this.chart.options.scales.xAxes[0].time.unit}`); // Update the chart this.chart.update(); return true; } } export default DeviceReadingDisplay;