From 2da6822ce28520e669c71c0b211cbd7755f3bda5 Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Sat, 18 Jun 2022 23:41:32 -0400 Subject: [PATCH] Added air quality for Patrons --- api/runtime.php | 25 +- css/dashboard.css | 1 + js/beestat.js | 1 + js/beestat/runtime_sensor.js | 88 +++- js/beestat/setting.js | 14 + js/beestat/style.js | 16 + js/component/card/air_quality_detail.js | 411 ++++++++++++++++++ js/component/card/early_access.js | 2 +- js/component/card/runtime_sensor_detail.js | 30 +- .../card/runtime_thermostat_detail.js | 14 +- js/component/card/voc_summary.js | 393 +++++++++++++++++ js/component/chart.js | 30 +- js/component/chart/air_quality.js | 168 +++++++ js/component/chart/co2_concentration.js | 174 ++++++++ .../chart/runtime_sensor_detail_occupancy.js | 29 +- .../runtime_sensor_detail_temperature.js | 31 +- js/component/chart/voc_concentration.js | 168 +++++++ js/component/header.js | 18 +- js/component/input/checkbox.js | 2 - .../modal/air_quality_detail_custom.js | 374 ++++++++++++++++ js/js.php | 7 + js/layer/air_quality.js | 64 +++ js/lib/highcharts/highcharts.js | 2 + 23 files changed, 1964 insertions(+), 98 deletions(-) create mode 100644 js/component/card/air_quality_detail.js create mode 100644 js/component/card/voc_summary.js create mode 100644 js/component/chart/air_quality.js create mode 100644 js/component/chart/co2_concentration.js create mode 100644 js/component/chart/voc_concentration.js create mode 100644 js/component/modal/air_quality_detail_custom.js create mode 100644 js/layer/air_quality.js diff --git a/api/runtime.php b/api/runtime.php index 75cf46d..11a9d39 100644 --- a/api/runtime.php +++ b/api/runtime.php @@ -776,10 +776,31 @@ class runtime extends cora\api { foreach($sensor['capability'] as $capability) { if( $capability['id'] == $capability_identifier && - in_array($capability['type'], ['temperature', 'occupancy']) === true && $value !== null ) { - $datas[$sensor['sensor_id']][$capability['type']] = ($capability['type'] === 'temperature') ? ($value * 10) : $value; + switch($capability['type']) { + case 'temperature': + $datas[$sensor['sensor_id']]['temperature'] = ($value * 10); + break; + case 'occupancy': + $datas[$sensor['sensor_id']]['occupancy'] = $value; + break; + case 'airPressure': + $datas[$sensor['sensor_id']]['air_pressure'] = $value; + break; + case 'airQuality': + $datas[$sensor['sensor_id']]['air_quality'] = $value; + break; + case 'airQualityAccuracy': + $datas[$sensor['sensor_id']]['air_quality_accuracy'] = $value; + break; + case 'vocPPM': + $datas[$sensor['sensor_id']]['voc_concentration'] = $value; + break; + case 'co2PPM': + $datas[$sensor['sensor_id']]['co2_concentration'] = $value; + break; + } } } } else { diff --git a/css/dashboard.css b/css/dashboard.css index ff60622..da3027c 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -422,6 +422,7 @@ input[type=checkbox] { .icon.network_strength_4:before { content: "\F08FA"; } .icon.network_strength_off:before { content: "\F08FC"; } .icon.numeric_1_box:before { content: "\F03A4"; } +.icon.numeric_4_box:before { content: "\F03AD"; } .icon.numeric_3_box:before { content: "\F03AA"; } .icon.numeric_7_box:before { content: "\F03B6"; } .icon.patreon:before { content: "\F0882"; } diff --git a/js/beestat.js b/js/beestat.js index 35a29e4..478b082 100644 --- a/js/beestat.js +++ b/js/beestat.js @@ -61,6 +61,7 @@ window.addEventListener('resize', rocket.throttle(100, function() { var breakpoints = [ 600, 650, + 800, 1000 ]; diff --git a/js/beestat/runtime_sensor.js b/js/beestat/runtime_sensor.js index a9c7f1f..e2cc9f6 100644 --- a/js/beestat/runtime_sensor.js +++ b/js/beestat/runtime_sensor.js @@ -6,10 +6,13 @@ beestat.runtime_sensor = {}; * * @param {number} thermostat_id The thermostat_id to get data for. * @param {object} range Range settings. + * @param {string} key The key to pull the data from inside + * beestat.cache.data. This exists because runtime_sensor data exists in + * multiple spots. * * @return {object} The data. */ -beestat.runtime_sensor.get_data = function(thermostat_id, range) { +beestat.runtime_sensor.get_data = function(thermostat_id, range, key) { var data = { 'x': [], 'series': {}, @@ -21,6 +24,33 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { } }; + var colors = [ + beestat.style.color.blue.base, + beestat.style.color.red.base, + beestat.style.color.yellow.base, + beestat.style.color.green.base, + beestat.style.color.orange.base, + beestat.style.color.bluegreen.base, + beestat.style.color.purple.base, + beestat.style.color.lightblue.base, + beestat.style.color.blue.light, + beestat.style.color.red.light, + beestat.style.color.yellow.light, + beestat.style.color.green.light, + beestat.style.color.orange.light, + beestat.style.color.bluegreen.light, + beestat.style.color.purple.light, + beestat.style.color.lightblue.light, + beestat.style.color.blue.dark, + beestat.style.color.red.dark, + beestat.style.color.yellow.dark, + beestat.style.color.green.dark, + beestat.style.color.orange.dark, + beestat.style.color.bluegreen.dark, + beestat.style.color.purple.dark, + beestat.style.color.lightblue.dark + ]; + // Duration objects. These are passed by reference into the metadata. var durations = {}; @@ -31,10 +61,19 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { data.metadata.sensors = sensors; // Set up the series_codes. - sensors.forEach(function(sensor) { + const sensor_series_colors = {}; + sensors.forEach(function(sensor, i) { if (sensor.thermostat_id === thermostat_id) { series_codes.push('temperature_' + sensor.sensor_id); series_codes.push('occupancy_' + sensor.sensor_id); + + sensor_series_colors[sensor.sensor_id] = colors[i]; + + if (sensor.type === 'thermostat') { + series_codes.push('air_quality_' + sensor.sensor_id); + series_codes.push('voc_concentration_' + sensor.sensor_id); + series_codes.push('co2_concentration_' + sensor.sensor_id); + } } }); @@ -53,9 +92,16 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { }; if (series_code === 'dummy') { data.metadata.series[series_code].name = null; + } else if (series_code.includes('air_quality_') === true) { + data.metadata.series[series_code].name = 'AQ'; + } else if (series_code.includes('voc_concentration_') === true) { + data.metadata.series[series_code].name = 'TVOC'; + } else if (series_code.includes('co2_concentration_') === true) { + data.metadata.series[series_code].name = 'CO2'; } else { var sensor_id = series_code.replace(/[^0-9]/g, ''); data.metadata.series[series_code].name = beestat.cache.sensor[sensor_id].name; + data.metadata.series[series_code].color = sensor_series_colors[sensor_id]; } durations[series_code] = {'seconds': 0}; @@ -83,7 +129,7 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { .second(0) .millisecond(0); - var runtime_sensors = beestat.runtime_sensor.get_runtime_sensors_by_date_(); + var runtime_sensors = beestat.runtime_sensor.get_runtime_sensors_by_date_(key); // Loop. var current_m = begin_m; @@ -100,6 +146,12 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { if (runtime_sensor === undefined) { data.series['temperature_' + sensor.sensor_id].push(null); data.series['occupancy_' + sensor.sensor_id].push(null); + + if (sensor.type === 'thermostat') { + data.series['air_quality_' + sensor.sensor_id].push(null); + data.series['voc_concentration_' + sensor.sensor_id].push(null); + data.series['co2_concentration_' + sensor.sensor_id].push(null); + } return; } @@ -108,6 +160,20 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { data.metadata.series['temperature_' + runtime_sensor.sensor_id].active = true; data.metadata.series['temperature_' + runtime_sensor.sensor_id].data[current_m.valueOf()] = temperature; + if (sensor.type === 'thermostat') { + data.series['air_quality_' + runtime_sensor.sensor_id].push(runtime_sensor.air_quality); + data.metadata.series['air_quality_' + runtime_sensor.sensor_id].active = true; + data.metadata.series['air_quality_' + runtime_sensor.sensor_id].data[current_m.valueOf()] = runtime_sensor.air_quality; + + data.series['voc_concentration_' + runtime_sensor.sensor_id].push(runtime_sensor.voc_concentration); + data.metadata.series['voc_concentration_' + runtime_sensor.sensor_id].active = true; + data.metadata.series['voc_concentration_' + runtime_sensor.sensor_id].data[current_m.valueOf()] = runtime_sensor.voc_concentration; + + data.series['co2_concentration_' + runtime_sensor.sensor_id].push(runtime_sensor.co2_concentration); + data.metadata.series['co2_concentration_' + runtime_sensor.sensor_id].active = true; + data.metadata.series['co2_concentration_' + runtime_sensor.sensor_id].data[current_m.valueOf()] = runtime_sensor.co2_concentration; + } + if (runtime_sensor.occupancy === true) { let swimlane_properties = beestat.component.chart.runtime_sensor_detail_occupancy.get_swimlane_properties( @@ -139,6 +205,12 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { if (sensor.thermostat_id === thermostat_id) { data.series['temperature_' + sensor.sensor_id].push(null); data.series['occupancy_' + sensor.sensor_id].push(null); + + if (sensor.type === 'thermostat') { + data.series['air_quality_' + sensor.sensor_id].push(null); + data.series['voc_concentration_' + sensor.sensor_id].push(null); + data.series['co2_concentration_' + sensor.sensor_id].push(null); + } } }); } @@ -152,12 +224,16 @@ beestat.runtime_sensor.get_data = function(thermostat_id, range) { /** * Get all the runtime_sensor rows indexed by date. * + * @param {string} key The key to pull the data from inside + * beestat.cache.data. This exists because runtime_sensor data exists in + * multiple spots. + * * @return {array} The runtime_sensor rows. */ -beestat.runtime_sensor.get_runtime_sensors_by_date_ = function() { +beestat.runtime_sensor.get_runtime_sensors_by_date_ = function(key) { var runtime_sensors = {}; - if (beestat.cache.runtime_sensor !== undefined) { - beestat.cache.runtime_sensor.forEach(function(runtime_sensor) { + if (beestat.cache.data[key] !== undefined) { + beestat.cache.data[key].forEach(function(runtime_sensor) { var timestamp = [moment(runtime_sensor.timestamp).valueOf()]; if (runtime_sensors[timestamp] === undefined) { runtime_sensors[timestamp] = {}; diff --git a/js/beestat/setting.js b/js/beestat/setting.js index 6fa8235..128dd61 100644 --- a/js/beestat/setting.js +++ b/js/beestat/setting.js @@ -39,6 +39,20 @@ beestat.setting = function(argument_1, opt_value, opt_callback) { 'runtime_sensor_detail_range_static_end': moment().format('MM/DD/YYYY'), 'runtime_sensor_detail_range_dynamic': 3, + 'air_quality_detail_range_type': 'dynamic', + 'air_quality_detail_range_static_begin': moment() + .subtract(3, 'day') + .format('MM/DD/YYYY'), + 'air_quality_detail_range_static_end': moment().format('MM/DD/YYYY'), + 'air_quality_detail_range_dynamic': 3, + + 'voc_summary_range_type': 'dynamic', + 'voc_summary_range_static_begin': moment() + .subtract(28, 'day') + .format('MM/DD/YYYY'), + 'voc_summary_range_static_end': moment().format('MM/DD/YYYY'), + 'voc_summary_range_dynamic': 30, + 'runtime_thermostat_summary_time_count': 0, 'runtime_thermostat_summary_time_period': 'all', 'runtime_thermostat_summary_group_by': 'month', diff --git a/js/beestat/style.js b/js/beestat/style.js index 5575204..7d9165b 100644 --- a/js/beestat/style.js +++ b/js/beestat/style.js @@ -323,3 +323,19 @@ beestat.series.indoor_resist_delta = { 'color': beestat.style.color.gray.dark }; beestat.series.indoor_resist_delta_raw = beestat.series.indoor_resist_delta; + +// Air Quality +beestat.series.air_quality = { + 'name': 'Air Quality', + 'color': beestat.style.color.gray.base +}; + +beestat.series.voc_concentration = { + 'name': 'TVOC', + 'color': beestat.style.color.yellow.dark +}; + +beestat.series.co2_concentration = { + 'name': 'CO₂', + 'color': beestat.style.color.blue.base +}; diff --git a/js/component/card/air_quality_detail.js b/js/component/card/air_quality_detail.js new file mode 100644 index 0000000..5898ee7 --- /dev/null +++ b/js/component/card/air_quality_detail.js @@ -0,0 +1,411 @@ +/** + * Air Quality card. Shows a chart with comfort profiles, occupancy, and air + * quality data. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for + */ +beestat.component.card.air_quality_detail = function(thermostat_id) { + var self = this; + + this.thermostat_id_ = thermostat_id; + + /* + * When a setting is changed clear all of the data. Then rerender which will + * trigger the loading state. Also do this when the cache changes. + * + * Debounce so that multiple setting changes don't re-trigger the same + * event. This fires on the trailing edge so that all changes are accounted + * for when rerendering. + */ + var change_function = beestat.debounce(function() { + self.get_data_(true); + self.rerender(); + }, 10); + + beestat.dispatcher.addEventListener( + [ + 'setting.air_quality_detail_range_type', + 'setting.air_quality_detail_range_dynamic', + 'cache.data.air_quality_detail__runtime_thermostat', + 'cache.data.air_quality_detail__runtime_sensor' + ], + change_function + ); + + beestat.component.card.apply(this, arguments); +}; +beestat.extend(beestat.component.card.air_quality_detail, beestat.component.card); + +/** + * Decorate + * + * @param {rocket.ELements} parent + */ +beestat.component.card.air_quality_detail.prototype.decorate_contents_ = function(parent) { + var self = this; + + this.charts_ = { + 'occupancy': new beestat.component.chart.runtime_sensor_detail_occupancy( + this.get_data_() + ), + 'air_quality': new beestat.component.chart.air_quality( + this.get_data_() + ), + 'voc_concentration': new beestat.component.chart.voc_concentration( + this.get_data_() + ), + 'co2_concentration': new beestat.component.chart.co2_concentration( + this.get_data_() + ) + }; + + var container = $.createElement('div').style({ + 'position': 'relative' + }); + parent.appendChild(container); + + var chart_container = $.createElement('div'); + container.appendChild(chart_container); + + this.charts_.occupancy.render(chart_container); + + chart_container.appendChild($.createElement('p').innerText('Air Quality')); + this.charts_.air_quality.render(chart_container); + + chart_container.appendChild($.createElement('p').innerText('TVOC Concentration')); + this.charts_.voc_concentration.render(chart_container); + + chart_container.appendChild($.createElement('p').innerText('CO₂ Concentration')); + this.charts_.co2_concentration.render(chart_container); + + // this.charts_.x_axis.render(chart_container); + + // Sync extremes and crosshair. + Object.values(this.charts_).forEach(function(source_chart) { + Object.values(self.charts_).forEach(function(target_chart) { + target_chart.sync_extremes(source_chart); + target_chart.sync_crosshair(source_chart); + }); + }); + + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; + + var required_begin; + var required_end; + if (beestat.setting('air_quality_detail_range_type') === 'dynamic') { + required_begin = moment() + .subtract( + beestat.setting('air_quality_detail_range_dynamic'), + 'day' + ) + .second(0); + + required_end = moment() + .subtract(1, 'hour') + .second(0); + } else { + required_begin = moment( + beestat.setting('air_quality_detail_range_static_begin') + ' 00:00:00' + ); + required_end = moment( + beestat.setting('air_quality_detail_range_static_end') + ' 23:59:59' + ); + } + + // Don't go before there's data. + required_begin = moment.max( + required_begin, + moment.utc(thermostat.first_connected) + ); + + // Don't go after now. + required_end = moment.min( + required_end, + moment().subtract(1, 'hour') + ); + + /** + * If the needed data exists in the database and the runtime_sensor + * cache is empty, then query the data. If the needed data does not exist in + * the database, check every 2 seconds until it does. + */ + if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) { + if (beestat.cache.data.air_quality_detail__runtime_sensor === undefined) { + this.show_loading_('Fetching'); + + var value; + var operator; + + if (beestat.setting('air_quality_detail_range_type') === 'dynamic') { + value = required_begin.format(); + operator = '>='; + } else { + value = [ + required_begin.format(), + required_end.format() + ]; + operator = 'between'; + } + + var api_call = new beestat.api(); + beestat.sensor.get_sorted().forEach(function(sensor) { + if (sensor.thermostat_id === self.thermostat_id_) { + api_call.add_call( + 'runtime_sensor', + 'read', + { + 'attributes': { + 'sensor_id': sensor.sensor_id, + 'timestamp': { + 'value': value, + 'operator': operator + } + } + }, + 'runtime_sensor_' + sensor.sensor_id + ); + } + }); + + api_call.add_call( + 'runtime_thermostat', + 'read', + { + 'attributes': { + 'thermostat_id': thermostat.thermostat_id, + 'timestamp': { + 'value': value, + 'operator': operator + } + } + }, + 'runtime_thermostat' + ); + + api_call.set_callback(function(response) { + var runtime_sensors = []; + for (var alias in response) { + var r = response[alias]; + if (alias === 'runtime_thermostat') { + beestat.cache.set('data.air_quality_detail__runtime_thermostat', r); + } else { + runtime_sensors = runtime_sensors.concat(r); + } + } + beestat.cache.set('data.air_quality_detail__runtime_sensor', runtime_sensors); + }); + + api_call.send(); + } else if (this.has_data_() === false) { + chart_container.style('filter', 'blur(3px)'); + var no_data = $.createElement('div'); + no_data.style({ + 'position': 'absolute', + 'top': 0, + 'left': 0, + 'width': '100%', + 'height': '100%', + 'display': 'flex', + 'flex-direction': 'column', + 'justify-content': 'center', + 'text-align': 'center' + }); + no_data.innerText('No data to display'); + container.appendChild(no_data); + } + } else { + this.show_loading_('Syncing'); + window.setTimeout(function() { + new beestat.api() + .add_call( + 'thermostat', + 'read_id', + { + 'attributes': { + 'inactive': 0 + } + }, + 'thermostat' + ) + .set_callback(function(response) { + beestat.cache.set('thermostat', response); + self.rerender(); + }) + .send(); + }, 2000); + } +}; + +/** + * Decorate the menu + * + * @param {rocket.Elements} parent + */ +beestat.component.card.air_quality_detail.prototype.decorate_top_right_ = function(parent) { + var self = this; + + var menu = (new beestat.component.menu()).render(parent); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 1 Day') + .set_icon('numeric_1_box') + .set_callback(function() { + if ( + beestat.setting('air_quality_detail_range_dynamic') !== 1 || + beestat.setting('air_quality_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('data.air_quality_detail__runtime_sensor'); + beestat.setting({ + 'air_quality_detail_range_dynamic': 1, + 'air_quality_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 3 Days') + .set_icon('numeric_3_box') + .set_callback(function() { + if ( + beestat.setting('air_quality_detail_range_dynamic') !== 3 || + beestat.setting('air_quality_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('data.air_quality_detail__runtime_sensor'); + beestat.setting({ + 'air_quality_detail_range_dynamic': 3, + 'air_quality_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 7 Days') + .set_icon('numeric_7_box') + .set_callback(function() { + if ( + beestat.setting('air_quality_detail_range_dynamic') !== 7 || + beestat.setting('air_quality_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('data.air_quality_detail__runtime_sensor'); + beestat.setting({ + 'air_quality_detail_range_dynamic': 7, + 'air_quality_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Custom') + .set_icon('calendar_edit') + .set_callback(function() { + (new beestat.component.modal.air_quality_detail_custom()).render(); + })); + + if (this.has_data_() === true) { + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Reset Zoom') + .set_icon('magnify_minus') + .set_callback(function() { + self.charts_.air_quality.reset_zoom(); + })); + } + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Help') + .set_icon('help_circle') + .set_callback(function() { + window.open('https://doc.beestat.io/2685b4aae86a4f5d80dd0a4e5dd88201'); + })); +}; + +/** + * Whether or not there is data to display on the chart. + * + * @return {boolean} Whether or not there is data to display on the chart. + */ +beestat.component.card.air_quality_detail.prototype.has_data_ = function() { + var data = this.get_data_(); + for (var series_code in data.metadata.series) { + if ( + series_code !== 'dummy' && + data.metadata.series[series_code].active === true + ) { + return true; + } + } + + return false; +}; + +/** + * Get data. This doesn't directly or indirectly make any API calls, but it + * caches the data so it doesn't have to loop over everything more than once. + * + * @param {boolean} force Force get the data? + * + * @return {object} The data. + */ +beestat.component.card.air_quality_detail.prototype.get_data_ = function(force) { + if (this.data_ === undefined || force === true) { + var range = { + 'type': beestat.setting('air_quality_detail_range_type'), + 'dynamic': beestat.setting('air_quality_detail_range_dynamic'), + 'static_begin': beestat.setting('air_quality_detail_range_static_begin'), + 'static_end': beestat.setting('air_quality_detail_range_static_end') + }; + + var sensor_data = beestat.runtime_sensor.get_data( + this.thermostat_id_, + range, + 'air_quality_detail__runtime_sensor' + ); + var thermostat_data = beestat.runtime_thermostat.get_data( + this.thermostat_id_, + range, + 'air_quality_detail__runtime_thermostat' + ); + + this.data_ = sensor_data; + + Object.assign(this.data_.series, thermostat_data.series); + Object.assign(this.data_.metadata.series, thermostat_data.metadata.series); + + this.data_.metadata.chart.title = this.get_title_(); + this.data_.metadata.chart.subtitle = this.get_subtitle_(); + } + + return this.data_; +}; + +/** + * Get the title of the card. + * + * @return {string} Title + */ +beestat.component.card.air_quality_detail.prototype.get_title_ = function() { + return 'Air Quality Detail'; +}; + +/** + * Get the subtitle of the card. + * + * @return {string} Subtitle + */ +beestat.component.card.air_quality_detail.prototype.get_subtitle_ = function() { + if (beestat.setting('air_quality_detail_range_type') === 'dynamic') { + var s = (beestat.setting('air_quality_detail_range_dynamic') > 1) ? 's' : ''; + + return 'Past ' + + beestat.setting('air_quality_detail_range_dynamic') + + ' day' + + s; + } + + var begin = moment(beestat.setting('air_quality_detail_range_static_begin')) + .format('MMM D, YYYY'); + var end = moment(beestat.setting('air_quality_detail_range_static_end')) + .format('MMM D, YYYY'); + + return begin + ' to ' + end; +}; diff --git a/js/component/card/early_access.js b/js/component/card/early_access.js index 6d2344e..669a821 100644 --- a/js/component/card/early_access.js +++ b/js/component/card/early_access.js @@ -13,5 +13,5 @@ beestat.extend(beestat.component.card.early_access, beestat.component.card); */ beestat.component.card.early_access.prototype.decorate_contents_ = function(parent) { parent.style('background', beestat.style.color.green.base); - parent.appendChild($.createElement('p').innerText('Experimental early access features below! ⤵')); + parent.appendChild($.createElement('p').innerText('Welcome to the early access release for Air Quality in beestat! Please let me know if you have any feedback or issues.')); }; diff --git a/js/component/card/runtime_sensor_detail.js b/js/component/card/runtime_sensor_detail.js index aa3196f..11ffc90 100644 --- a/js/component/card/runtime_sensor_detail.js +++ b/js/component/card/runtime_sensor_detail.js @@ -27,8 +27,8 @@ beestat.component.card.runtime_sensor_detail = function(thermostat_id) { [ 'setting.runtime_sensor_detail_range_type', 'setting.runtime_sensor_detail_range_dynamic', - 'cache.data.runtime_thermostat_sensor_detail', - 'cache.data.runtime_sensor' + 'cache.data.runtime_sensor_detail__runtime_thermostat', + 'cache.data.runtime_sensor_detail__runtime_sensor' ], change_function ); @@ -126,12 +126,12 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func ); /** - * If the needed data exists in the database and the runtime_sensor - * cache is empty, then query the data. If the needed data does not exist in - * the database, check every 2 seconds until it does. + * If the needed data exists in the database and the runtime_sensor cache is + * empty, then query the data. If the needed data does not exist in the + * database, check every 2 seconds until it does. */ if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) { - if (beestat.cache.runtime_sensor === undefined) { + if (beestat.cache.data.runtime_sensor_detail__runtime_sensor === undefined) { this.show_loading_('Fetching'); var value; @@ -188,12 +188,12 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func for (var alias in response) { var r = response[alias]; if (alias === 'runtime_thermostat') { - beestat.cache.set('data.runtime_thermostat_sensor_detail', r); + beestat.cache.set('data.runtime_sensor_detail__runtime_thermostat', r); } else { runtime_sensors = runtime_sensors.concat(r); } } - beestat.cache.set('runtime_sensor', runtime_sensors); + beestat.cache.set('data.runtime_sensor_detail__runtime_sensor', runtime_sensors); }); api_call.send(); @@ -255,7 +255,7 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = fun beestat.setting('runtime_sensor_detail_range_dynamic') !== 1 || beestat.setting('runtime_sensor_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('runtime_sensor'); + beestat.cache.delete('data.runtime_sensor_detail__runtime_sensor'); beestat.setting({ 'runtime_sensor_detail_range_dynamic': 1, 'runtime_sensor_detail_range_type': 'dynamic' @@ -271,7 +271,7 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = fun beestat.setting('runtime_sensor_detail_range_dynamic') !== 3 || beestat.setting('runtime_sensor_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('runtime_sensor'); + beestat.cache.delete('data.runtime_sensor_detail__runtime_sensor'); beestat.setting({ 'runtime_sensor_detail_range_dynamic': 3, 'runtime_sensor_detail_range_type': 'dynamic' @@ -287,7 +287,7 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = fun beestat.setting('runtime_sensor_detail_range_dynamic') !== 7 || beestat.setting('runtime_sensor_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('runtime_sensor'); + beestat.cache.delete('data.runtime_sensor_detail__runtime_sensor'); beestat.setting({ 'runtime_sensor_detail_range_dynamic': 7, 'runtime_sensor_detail_range_type': 'dynamic' @@ -362,11 +362,15 @@ beestat.component.card.runtime_sensor_detail.prototype.get_data_ = function(forc 'static_end': beestat.setting('runtime_sensor_detail_range_static_end') }; - var sensor_data = beestat.runtime_sensor.get_data(this.thermostat_id_, range); + var sensor_data = beestat.runtime_sensor.get_data( + this.thermostat_id_, + range, + 'runtime_sensor_detail__runtime_sensor' + ); var thermostat_data = beestat.runtime_thermostat.get_data( this.thermostat_id_, range, - 'runtime_thermostat_sensor_detail' + 'runtime_sensor_detail__runtime_thermostat' ); this.data_ = sensor_data; diff --git a/js/component/card/runtime_thermostat_detail.js b/js/component/card/runtime_thermostat_detail.js index acab48b..8804bf1 100644 --- a/js/component/card/runtime_thermostat_detail.js +++ b/js/component/card/runtime_thermostat_detail.js @@ -27,7 +27,7 @@ beestat.component.card.runtime_thermostat_detail = function(thermostat_id) { [ 'setting.runtime_thermostat_detail_range_type', 'setting.runtime_thermostat_detail_range_dynamic', - 'cache.data.runtime_thermostat_thermostat_detail', + 'cache.data.runtime_thermostat_detail__runtime_thermostat', 'cache.thermostat' ], change_function @@ -150,7 +150,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ = * the database, check every 2 seconds until it does. */ if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) { - if (beestat.cache.data.runtime_thermostat_thermostat_detail === undefined) { + if (beestat.cache.data.runtime_thermostat_detail__runtime_thermostat === undefined) { this.show_loading_('Fetching'); var value; @@ -182,7 +182,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ = } ) .set_callback(function(response) { - beestat.cache.set('data.runtime_thermostat_thermostat_detail', response); + beestat.cache.set('data.runtime_thermostat_detail__runtime_thermostat', response); }) .send(); } else if (this.has_data_() === false) { @@ -242,7 +242,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = beestat.setting('runtime_thermostat_detail_range_dynamic') !== 1 || beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('data.runtime_thermostat_thermostat_detail'); + beestat.cache.delete('data.runtime_thermostat_detail__runtime_thermostat'); beestat.setting({ 'runtime_thermostat_detail_range_dynamic': 1, 'runtime_thermostat_detail_range_type': 'dynamic' @@ -258,7 +258,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = beestat.setting('runtime_thermostat_detail_range_dynamic') !== 3 || beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('data.runtime_thermostat_thermostat_detail'); + beestat.cache.delete('data.runtime_thermostat_detail__runtime_thermostat'); beestat.setting({ 'runtime_thermostat_detail_range_dynamic': 3, 'runtime_thermostat_detail_range_type': 'dynamic' @@ -274,7 +274,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = beestat.setting('runtime_thermostat_detail_range_dynamic') !== 7 || beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('data.runtime_thermostat_thermostat_detail'); + beestat.cache.delete('data.runtime_thermostat_detail__runtime_thermostat'); beestat.setting({ 'runtime_thermostat_detail_range_dynamic': 7, 'runtime_thermostat_detail_range_type': 'dynamic' @@ -352,7 +352,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.get_data_ = function( this.data_ = beestat.runtime_thermostat.get_data( this.thermostat_id_, range, - 'runtime_thermostat_thermostat_detail' + 'runtime_thermostat_detail__runtime_thermostat' ); this.data_.metadata.chart.title = this.get_title_(); diff --git a/js/component/card/voc_summary.js b/js/component/card/voc_summary.js new file mode 100644 index 0000000..c66508a --- /dev/null +++ b/js/component/card/voc_summary.js @@ -0,0 +1,393 @@ +/** + * Air Quality card. Shows a chart with comfort profiles, occupancy, and air + * quality data. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for + */ +beestat.component.card.voc_summary = function(thermostat_id) { + var self = this; + + this.thermostat_id_ = thermostat_id; + + /* + * When a setting is changed clear all of the data. Then rerender which will + * trigger the loading state. Also do this when the cache changes. + * + * Debounce so that multiple setting changes don't re-trigger the same + * event. This fires on the trailing edge so that all changes are accounted + * for when rerendering. + */ + var change_function = beestat.debounce(function() { + self.rerender(); + }, 10); + + beestat.dispatcher.addEventListener( + [ + 'setting.voc_summary_range_type', + 'setting.voc_summary_range_dynamic', + 'cache.data.voc_summary' + ], + change_function + ); + + beestat.component.card.apply(this, arguments); +}; +beestat.extend(beestat.component.card.voc_summary, beestat.component.card); + +beestat.component.card.voc_summary.prototype.rerender_on_breakpoint_ = true; + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.card.voc_summary.prototype.decorate_contents_ = function(parent) { + var self = this; + + var container = $.createElement('div').style({ + 'position': 'relative' + }); + parent.appendChild(container); + + var chart_container = $.createElement('div'); + container.appendChild(chart_container); + + this.decorate_chart_(chart_container); + + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; + + var required_begin; + var required_end; + if (beestat.setting('voc_summary_range_type') === 'dynamic') { + required_begin = moment() + .subtract( + beestat.setting('voc_summary_range_dynamic'), + 'day' + ) + .second(0); + + required_end = moment() + .subtract(1, 'hour') + .second(0); + } else { + required_begin = moment( + beestat.setting('voc_summary_range_static_begin') + ' 00:00:00' + ); + required_end = moment( + beestat.setting('voc_summary_range_static_end') + ' 23:59:59' + ); + } + + // Don't go before there's data. + required_begin = moment.max( + required_begin, + moment.utc(thermostat.first_connected) + ); + + // Don't go after now. + required_end = moment.min( + required_end, + moment().subtract(1, 'hour') + ); + + /** + * If the needed data exists in the database and the runtime_sensor + * cache is empty, then query the data. If the needed data does not exist in + * the database, check every 2 seconds until it does. + */ + if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) { + if (beestat.cache.data.voc_summary === undefined) { + this.show_loading_('Fetching'); + + var value; + var operator; + + if (beestat.setting('voc_summary_range_type') === 'dynamic') { + value = required_begin.format(); + operator = '>='; + } else { + value = [ + required_begin.format(), + required_end.format() + ]; + operator = 'between'; + } + + var api_call = new beestat.api(); + beestat.sensor.get_sorted().forEach(function(sensor) { + if ( + sensor.thermostat_id === self.thermostat_id_ && + sensor.type === 'thermostat' + ) { + api_call.add_call( + 'runtime_sensor', + 'read', + { + 'attributes': { + 'sensor_id': sensor.sensor_id, + 'timestamp': { + 'value': value, + 'operator': operator + } + } + }, + 'runtime_sensor_' + sensor.sensor_id + ); + } + }); + + api_call.set_callback(function(response) { + var runtime_sensors = []; + for (var alias in response) { + var r = response[alias]; + runtime_sensors = runtime_sensors.concat(r); + } + beestat.cache.set('data.voc_summary', runtime_sensors); + }); + + api_call.send(); + } else if (this.has_data_() === false) { + chart_container.style('filter', 'blur(3px)'); + var no_data = $.createElement('div'); + no_data.style({ + 'position': 'absolute', + 'top': 0, + 'left': 0, + 'width': '100%', + 'height': '100%', + 'display': 'flex', + 'flex-direction': 'column', + 'justify-content': 'center', + 'text-align': 'center' + }); + no_data.innerText('No data to display'); + container.appendChild(no_data); + } + } else { + this.show_loading_('Syncing'); + window.setTimeout(function() { + new beestat.api() + .add_call( + 'thermostat', + 'read_id', + { + 'attributes': { + 'inactive': 0 + } + }, + 'thermostat' + ) + .set_callback(function(response) { + beestat.cache.set('thermostat', response); + self.rerender(); + }) + .send(); + }, 2000); + } +}; + +/** + * Decorate chart + * + * @param {rocket.Elements} parent + */ +beestat.component.card.voc_summary.prototype.decorate_chart_ = function(parent) { + var grid_data = {}; + for (var runtime_sensor_id in beestat.cache.data.voc_summary) { + var runtime_sensor = beestat.cache.data.voc_summary[runtime_sensor_id]; + var key = moment(runtime_sensor.timestamp).format('d_H'); + if (grid_data[key] === undefined) { + grid_data[key] = []; + } + if (runtime_sensor.voc_concentration !== null) { + grid_data[key].push(runtime_sensor.voc_concentration); + } + } + + var table = $.createElement('table'); + table.style({ + 'table-layout': 'fixed', + 'border-collapse': 'collapse', + 'width': '100%' + }); + + var tr; + var td; + + tr = $.createElement('tr'); + tr.appendChild($.createElement('td').style({'width': '50px'})); + table.appendChild(tr); + + var days_of_week = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + var day; + var hour; + + // Header row + for (hour = 0; hour < 24; hour++) { + var meridiem = hour >= 12 ? 'p' : 'a'; + var new_hour = (hour % 12) || 12; + + tr.appendChild( + $.createElement('td') + .innerText(new_hour + (beestat.width > 700 ? meridiem : '')) + .style({ + 'text-align': 'center', + 'font-size': beestat.style.font_size.small + }) + ); + } + + for (day = 0; day < 7; day++) { + tr = $.createElement('tr'); + table.appendChild(tr); + + td = $.createElement('td') + .innerHTML(days_of_week[day]); + tr.appendChild(td); + + for (hour = 0; hour < 24; hour++) { + var cell_value = grid_data[day + '_' + hour]; + td = $.createElement('td'); + var background = beestat.style.color.bluegray.light; + + if (cell_value !== undefined) { + var average = grid_data[day + '_' + hour].reduce(function(a, b) { + return a + b; + }) / grid_data[day + '_' + hour].length; + + td.setAttribute('title', Math.round(average) + ' ppb'); + + if (average < 1) { + background = beestat.style.color.bluegray.light; + } else if (average < 75) { + background = beestat.style.color.green.light; + } else if (average < 150) { + background = beestat.style.color.green.base; + } else if (average < 225) { + background = beestat.style.color.green.dark; + } else if (average < 375) { + background = beestat.style.color.yellow.light; + } else if (average < 525) { + background = beestat.style.color.yellow.base; + } else if (average < 675) { + background = beestat.style.color.yellow.dark; + } else if (average < 925) { + background = beestat.style.color.orange.light; + } else if (average < 1175) { + background = beestat.style.color.orange.base; + } else if (average < 1425) { + background = beestat.style.color.orange.dark; + } else if (average < 1675) { + background = beestat.style.color.red.light; + } else if (average < 1925) { + background = beestat.style.color.red.base; + } else if (average < 2175) { + background = beestat.style.color.red.dark; + } else { + background = beestat.style.color.red.dark; + } + } + + td.style({ + 'height': '20px', + 'background': background + }); + + tr.appendChild(td); + } + } + + parent.appendChild(table); +}; + +/** + * Decorate the menu + * + * @param {rocket.Elements} parent + */ +beestat.component.card.voc_summary.prototype.decorate_top_right_ = function(parent) { + var menu = (new beestat.component.menu()).render(parent); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 1 Week') + .set_icon('numeric_1_box') + .set_callback(function() { + if ( + beestat.setting('voc_summary_range_dynamic') !== 7 || + beestat.setting('voc_summary_range_type') !== 'dynamic' + ) { + beestat.cache.delete('data.voc_summary'); + beestat.setting({ + 'voc_summary_range_dynamic': 7, + 'voc_summary_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 4 Weeks') + .set_icon('numeric_4_box') + .set_callback(function() { + if ( + beestat.setting('voc_summary_range_dynamic') !== 28 || + beestat.setting('voc_summary_range_type') !== 'dynamic' + ) { + beestat.cache.delete('data.voc_summary'); + beestat.setting({ + 'voc_summary_range_dynamic': 28, + 'voc_summary_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Help') + .set_icon('help_circle') + .set_callback(function() { + window.open('https://doc.beestat.io/25a1a894a7f4432ead5831bef48770bd'); + })); +}; + +/** + * Whether or not there is data to display on the chart. + * + * @return {boolean} Whether or not there is data to display on the chart. + */ +beestat.component.card.voc_summary.prototype.has_data_ = function() { + return beestat.cache.data.voc_summary && + beestat.cache.data.voc_summary.length > 0; +}; + +/** + * Get the title of the card. + * + * @return {string} Title + */ +beestat.component.card.voc_summary.prototype.get_title_ = function() { + return 'TVOC Summary'; +}; + +/** + * Get the subtitle of the card. + * + * @return {string} Subtitle + */ +beestat.component.card.voc_summary.prototype.get_subtitle_ = function() { + if (beestat.setting('voc_summary_range_type') === 'dynamic') { + var s = ((beestat.setting('voc_summary_range_dynamic') / 7) > 1) ? 's' : ''; + + return 'Past ' + + (beestat.setting('voc_summary_range_dynamic') / 7) + + ' week' + + s; + } + + var begin = moment(beestat.setting('voc_summary_range_static_begin')) + .format('MMM D, YYYY'); + var end = moment(beestat.setting('voc_summary_range_static_end')) + .format('MMM D, YYYY'); + + return begin + ' to ' + end; +}; diff --git a/js/component/chart.js b/js/component/chart.js index c6d2262..9d98be4 100644 --- a/js/component/chart.js +++ b/js/component/chart.js @@ -182,6 +182,7 @@ beestat.component.chart.prototype.get_options_chart_ = function() { // For consistent left spacing on charts with no y-axis values 'marginLeft': this.get_options_chart_marginLeft_(), 'marginRight': this.get_options_chart_marginRight_(), + 'marginBottom': this.get_options_chart_marginBottom_(), 'zoomType': this.get_options_chart_zoomType_(), 'panning': true, 'panKey': 'ctrl', @@ -214,6 +215,15 @@ beestat.component.chart.prototype.get_options_chart_marginRight_ = function() { return undefined; }; +/** + * Get the bottom margin for the chart. + * + * @return {number} The right margin for the chart. + */ +beestat.component.chart.prototype.get_options_chart_marginBottom_ = function() { + return undefined; +}; + /** * Get the spacing for the chart. * @@ -578,15 +588,17 @@ beestat.component.chart.prototype.tooltip_formatter_helper_ = function(title, se 'box-shadow': '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)' }); - var title_div = $.createElement('div') - .style({ - 'font-weight': beestat.style.font_weight.bold, - 'font-size': beestat.style.font_size.large, - 'margin-bottom': beestat.style.size.gutter / 4, - 'color': beestat.style.color.gray.light - }) - .innerText(title); - tooltip.appendChild(title_div); + if (title !== null) { + var title_div = $.createElement('div') + .style({ + 'font-weight': beestat.style.font_weight.bold, + 'font-size': beestat.style.font_size.large, + 'margin-bottom': beestat.style.size.gutter / 4, + 'color': beestat.style.color.gray.light + }) + .innerText(title); + tooltip.appendChild(title_div); + } var table = $.createElement('table') .setAttribute({ diff --git a/js/component/chart/air_quality.js b/js/component/chart/air_quality.js new file mode 100644 index 0000000..fb12df5 --- /dev/null +++ b/js/component/chart/air_quality.js @@ -0,0 +1,168 @@ +/** + * Air Quality chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.air_quality = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.air_quality, beestat.component.chart); + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.air_quality.prototype.get_options_xAxis_labels_formatter_ = function() { + return function() { + return null; + }; +}; + +/** + * Override for get_options_series_. + * + * @return {Array} All of the series to display on the chart. + */ +beestat.component.chart.air_quality.prototype.get_options_series_ = function() { + var self = this; + var series = []; + + // Sensors + this.data_.metadata.sensors.forEach(function(sensor) { + if (sensor.type === 'thermostat') { + series.push({ + 'name': 'air_quality_' + sensor.sensor_id, + 'data': self.data_.series['air_quality_' + sensor.sensor_id], + 'color': beestat.series.air_quality.color, + 'yAxis': 0, + 'type': 'spline', + 'lineWidth': 1 + }); + } + }); + + series.push({ + 'name': '', + 'data': self.data_.series.dummy, + 'yAxis': 0, + 'type': 'line', + 'lineWidth': 0, + 'showInLegend': false + }); + + return series; +}; + +/** + * Override for get_options_yAxis_. + * + * @return {Array} The y-axis options. + */ +beestat.component.chart.air_quality.prototype.get_options_yAxis_ = function() { + return [ + { + 'gridLineColor': beestat.style.color.bluegray.light, + 'gridLineDashStyle': 'longdash', + 'allowDecimals': false, + 'title': {'text': null}, + 'labels': { + 'style': {'color': beestat.style.color.gray.base}, + 'formatter': function() { + return this.value; + } + } + } + ]; +}; + +/** + * Override for get_options_tooltip_formatter_. + * + * @return {Function} The tooltip formatter. + */ +beestat.component.chart.air_quality.prototype.get_options_tooltip_formatter_ = function() { + var self = this; + + return function() { + var x = this.x; + + var sections = []; + var groups = { + 'data': [] + }; + + $.values(beestat.cache.sensor).forEach(function(sensor) { + if ( + sensor.thermostat_id === beestat.setting('thermostat_id') && + sensor.type === 'thermostat' + ) { + groups.data.push({ + 'label': beestat.series.air_quality.name, + 'value': (self.data_.metadata.series['air_quality_' + sensor.sensor_id].data[x.valueOf()]), + 'color': beestat.series.air_quality.color + }); + } + }); + + sections.push(groups.data); + + var title = this.x.format('ddd, MMM D @ h:mma'); + + return self.tooltip_formatter_helper_( + title, + sections + ); + }; +}; + +/** + * Get the tooltip positioner y value. + * + * @param {number} tooltip_width Tooltip width. + * @param {number} tooltip_height Tooltip height. + * @param {point} point Highcharts current point. + * + * @return {number} The tooltip y value. + */ +beestat.component.chart.air_quality.prototype.get_options_tooltip_positioner_y_ = function() { + return 0; +}; + +/** + * Get the height of the chart. + * + * @return {number} The height of the chart. + */ +beestat.component.chart.air_quality.prototype.get_options_chart_height_ = function() { + return 75; +}; + +/** + * Get the left margin for the chart. + * + * @return {number} The left margin for the chart. + */ +beestat.component.chart.air_quality.prototype.get_options_chart_marginLeft_ = function() { + return 60; +}; + +/** + * Get the legend enabled options. + * + * @return {Function} The legend enabled options. + */ +beestat.component.chart.air_quality.prototype.get_options_legend_enabled_ = function() { + return false; +}; + +/** + * Get the bottom margin for the chart. + * + * @return {number} The right margin for the chart. + */ +beestat.component.chart.air_quality.prototype.get_options_chart_marginBottom_ = function() { + return 10; +}; diff --git a/js/component/chart/co2_concentration.js b/js/component/chart/co2_concentration.js new file mode 100644 index 0000000..838fa54 --- /dev/null +++ b/js/component/chart/co2_concentration.js @@ -0,0 +1,174 @@ +/** + * CO2 Concentration chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.co2_concentration = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.co2_concentration, beestat.component.chart); + +/** + * Override for get_options_series_. + * + * @return {Array} All of the series to display on the chart. + */ +beestat.component.chart.co2_concentration.prototype.get_options_series_ = function() { + var self = this; + var series = []; + + // Sensors + this.data_.metadata.sensors.forEach(function(sensor) { + if (sensor.type === 'thermostat') { + series.push({ + 'name': 'co2_concentration_' + sensor.sensor_id, + 'data': self.data_.series['co2_concentration_' + sensor.sensor_id], + 'color': beestat.series.co2_concentration.color, + 'yAxis': 0, + 'type': 'spline', + 'lineWidth': 1 + }); + } + }); + + series.push({ + 'name': '', + 'data': self.data_.series.dummy, + 'yAxis': 0, + 'type': 'line', + 'lineWidth': 0, + 'showInLegend': false + }); + + return series; +}; + +/** + * Override for get_options_yAxis_. + * + * @return {Array} The y-axis options. + */ +beestat.component.chart.co2_concentration.prototype.get_options_yAxis_ = function() { + return [ + { + 'gridLineColor': beestat.style.color.bluegray.light, + 'gridLineDashStyle': 'longdash', + 'allowDecimals': false, + 'title': {'text': null}, + 'labels': { + 'style': {'color': beestat.style.color.gray.base}, + 'formatter': function() { + return this.value; + } + } + } + ]; +}; + +/** + * Override for get_options_tooltip_formatter_. + * + * @return {Function} The tooltip formatter. + */ +beestat.component.chart.co2_concentration.prototype.get_options_tooltip_formatter_ = function() { + var self = this; + + return function() { + var x = this.x; + + var sections = []; + var groups = { + 'data': [] + }; + + $.values(beestat.cache.sensor).forEach(function(sensor) { + if ( + sensor.thermostat_id === beestat.setting('thermostat_id') && + sensor.type === 'thermostat' + ) { + groups.data.push({ + 'label': beestat.series.co2_concentration.name, + 'value': (self.data_.metadata.series['co2_concentration_' + sensor.sensor_id].data[x.valueOf()]) + ' ppm', + 'color': beestat.series.co2_concentration.color + }); + } + }); + + sections.push(groups.data); + + return self.tooltip_formatter_helper_( + null, + sections + ); + }; +}; + +/** + * Get the tooltip positioner y value. + * + * @param {number} tooltip_width Tooltip width. + * @param {number} tooltip_height Tooltip height. + * @param {point} point Highcharts current point. + * + * @return {number} The tooltip y value. + */ +beestat.component.chart.co2_concentration.prototype.get_options_tooltip_positioner_y_ = function() { + return 0; +}; + +/** + * Get the height of the chart. + * + * @return {number} The height of the chart. + */ +beestat.component.chart.co2_concentration.prototype.get_options_chart_height_ = function() { + return 135; +}; + +/** + * Get the left margin for the chart. + * + * @return {number} The left margin for the chart. + */ +beestat.component.chart.co2_concentration.prototype.get_options_chart_marginLeft_ = function() { + return 60; +}; + +/** + * Get the legend enabled options. + * + * @return {Function} The legend enabled options. + */ +beestat.component.chart.co2_concentration.prototype.get_options_legend_enabled_ = function() { + return false; +}; + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.co2_concentration.prototype.get_options_xAxis_labels_formatter_ = function() { + var current_day; + var current_hour; + + return function() { + var hour = this.value.format('ha'); + var day = this.value.format('ddd'); + + var label_parts = []; + if (day !== current_day) { + label_parts.push(day); + } + if (hour !== current_hour) { + label_parts.push(hour); + } + + current_hour = hour; + current_day = day; + + return label_parts.join(' '); + }; +}; diff --git a/js/component/chart/runtime_sensor_detail_occupancy.js b/js/component/chart/runtime_sensor_detail_occupancy.js index 0c4e99b..dbe1897 100644 --- a/js/component/chart/runtime_sensor_detail_occupancy.js +++ b/js/component/chart/runtime_sensor_detail_occupancy.js @@ -30,33 +30,6 @@ beestat.component.chart.runtime_sensor_detail_occupancy.prototype.get_options_se var self = this; var series = []; - var colors = [ - beestat.style.color.blue.base, - beestat.style.color.red.base, - beestat.style.color.yellow.base, - beestat.style.color.green.base, - beestat.style.color.orange.base, - beestat.style.color.bluegreen.base, - beestat.style.color.purple.base, - beestat.style.color.lightblue.base, - beestat.style.color.blue.light, - beestat.style.color.red.light, - beestat.style.color.yellow.light, - beestat.style.color.green.light, - beestat.style.color.orange.light, - beestat.style.color.bluegreen.light, - beestat.style.color.purple.light, - beestat.style.color.lightblue.light, - beestat.style.color.blue.dark, - beestat.style.color.red.dark, - beestat.style.color.yellow.dark, - beestat.style.color.green.dark, - beestat.style.color.orange.dark, - beestat.style.color.bluegreen.dark, - beestat.style.color.purple.dark, - beestat.style.color.lightblue.dark - ]; - /** * This chart does not need the entire dummy series, but it does need the * first series to have *some* non-null data or Highcharts does not find a @@ -77,7 +50,7 @@ beestat.component.chart.runtime_sensor_detail_occupancy.prototype.get_options_se series.push({ 'name': 'occupancy_' + sensor.sensor_id, 'data': self.data_.series['occupancy_' + sensor.sensor_id], - 'color': colors[i], + 'color': self.data_.metadata.series['occupancy_' + sensor.sensor_id].color, 'yAxis': 0, 'type': 'line', 'lineWidth': beestat.component.chart.runtime_sensor_detail_occupancy.get_swimlane_properties(self.data_.metadata.sensors.length, 1).line_width, diff --git a/js/component/chart/runtime_sensor_detail_temperature.js b/js/component/chart/runtime_sensor_detail_temperature.js index 4fadcc2..7eaedf5 100644 --- a/js/component/chart/runtime_sensor_detail_temperature.js +++ b/js/component/chart/runtime_sensor_detail_temperature.js @@ -60,39 +60,12 @@ beestat.component.chart.runtime_sensor_detail_temperature.prototype.get_options_ var self = this; var series = []; - var colors = [ - beestat.style.color.blue.base, - beestat.style.color.red.base, - beestat.style.color.yellow.base, - beestat.style.color.green.base, - beestat.style.color.orange.base, - beestat.style.color.bluegreen.base, - beestat.style.color.purple.base, - beestat.style.color.lightblue.base, - beestat.style.color.blue.light, - beestat.style.color.red.light, - beestat.style.color.yellow.light, - beestat.style.color.green.light, - beestat.style.color.orange.light, - beestat.style.color.bluegreen.light, - beestat.style.color.purple.light, - beestat.style.color.lightblue.light, - beestat.style.color.blue.dark, - beestat.style.color.red.dark, - beestat.style.color.yellow.dark, - beestat.style.color.green.dark, - beestat.style.color.orange.dark, - beestat.style.color.bluegreen.dark, - beestat.style.color.purple.dark, - beestat.style.color.lightblue.dark - ]; - // Sensors - this.data_.metadata.sensors.forEach(function(sensor, i) { + this.data_.metadata.sensors.forEach(function(sensor) { series.push({ 'name': 'temperature_' + sensor.sensor_id, 'data': self.data_.series['temperature_' + sensor.sensor_id], - 'color': colors[i], + 'color': self.data_.metadata.series['temperature_' + sensor.sensor_id].color, 'yAxis': 0, 'type': 'spline', 'lineWidth': 1 diff --git a/js/component/chart/voc_concentration.js b/js/component/chart/voc_concentration.js new file mode 100644 index 0000000..2eb9f5d --- /dev/null +++ b/js/component/chart/voc_concentration.js @@ -0,0 +1,168 @@ +/** + * VOC Concentration chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.voc_concentration = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.voc_concentration, beestat.component.chart); + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.voc_concentration.prototype.get_options_xAxis_labels_formatter_ = function() { + return function() { + return null; + }; +}; + +/** + * Override for get_options_series_. + * + * @return {Array} All of the series to display on the chart. + */ +beestat.component.chart.voc_concentration.prototype.get_options_series_ = function() { + var self = this; + var series = []; + + // Sensors + this.data_.metadata.sensors.forEach(function(sensor, i) { + if (sensor.type === 'thermostat') { + series.push({ + 'name': 'voc_concentration_' + sensor.sensor_id, + 'data': self.data_.series['voc_concentration_' + sensor.sensor_id], + 'color': beestat.series.voc_concentration.color, + 'yAxis': 0, + 'type': 'spline', + 'lineWidth': 1 + }); + } + }); + + series.push({ + 'name': '', + 'data': self.data_.series.dummy, + 'yAxis': 0, + 'type': 'line', + 'lineWidth': 0, + 'showInLegend': false + }); + + return series; +}; + +/** + * Override for get_options_yAxis_. + * + * @return {Array} The y-axis options. + */ +beestat.component.chart.voc_concentration.prototype.get_options_yAxis_ = function() { + return [ + { + 'gridLineColor': beestat.style.color.bluegray.light, + 'gridLineDashStyle': 'longdash', + 'allowDecimals': false, + 'title': {'text': null}, + 'labels': { + 'style': {'color': beestat.style.color.gray.base}, + 'formatter': function() { + return this.value; + } + } + } + ]; +}; + +/** + * Override for get_options_tooltip_formatter_. + * + * @return {Function} The tooltip formatter. + */ +beestat.component.chart.voc_concentration.prototype.get_options_tooltip_formatter_ = function() { + var self = this; + + return function() { + var x = this.x; + + var sections = []; + var groups = { + 'data': [] + }; + + $.values(beestat.cache.sensor).forEach(function(sensor) { + if ( + sensor.thermostat_id === beestat.setting('thermostat_id') && + sensor.type === 'thermostat' + ) { + groups.data.push({ + 'label': beestat.series.voc_concentration.name, + 'value': (self.data_.metadata.series['voc_concentration_' + sensor.sensor_id].data[x.valueOf()]) + ' ppb', + 'color': beestat.series.voc_concentration.color + }); + } + }); + + sections.push(groups.data); + + // var title = this.x.format('ddd, MMM D @ h:mma'); + + return self.tooltip_formatter_helper_( + null, + sections + ); + }; +}; + +/** + * Get the tooltip positioner y value. + * + * @param {number} tooltip_width Tooltip width. + * @param {number} tooltip_height Tooltip height. + * @param {point} point Highcharts current point. + * + * @return {number} The tooltip y value. + */ +beestat.component.chart.voc_concentration.prototype.get_options_tooltip_positioner_y_ = function() { + return 0; +}; + +/** + * Get the height of the chart. + * + * @return {number} The height of the chart. + */ +beestat.component.chart.voc_concentration.prototype.get_options_chart_height_ = function() { + return 75; +}; + +/** + * Get the left margin for the chart. + * + * @return {number} The left margin for the chart. + */ +beestat.component.chart.voc_concentration.prototype.get_options_chart_marginLeft_ = function() { + return 60; +}; + +/** + * Get the legend enabled options. + * + * @return {Function} The legend enabled options. + */ +beestat.component.chart.voc_concentration.prototype.get_options_legend_enabled_ = function() { + return false; +}; + +/** + * Get the bottom margin for the chart. + * + * @return {number} The right margin for the chart. + */ +beestat.component.chart.voc_concentration.prototype.get_options_chart_marginBottom_ = function() { + return 10; +}; diff --git a/js/component/header.js b/js/component/header.js index d91833f..687eb36 100644 --- a/js/component/header.js +++ b/js/component/header.js @@ -24,6 +24,11 @@ beestat.component.header.prototype.rerender_on_breakpoint_ = true; beestat.component.header.prototype.decorate_ = function(parent) { var self = this; + const thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + const ecobee_thermostat = beestat.cache.ecobee_thermostat[ + thermostat.ecobee_thermostat_id + ]; + var pages; pages = [ @@ -44,6 +49,17 @@ beestat.component.header.prototype.decorate_ = function(parent) { } ]; + if ( + beestat.user.has_early_access() === true && + ecobee_thermostat.model_number === 'aresSmart' + ) { + pages.push({ + 'layer': 'air_quality', + 'text': 'Air Quality', + 'icon': 'weather_windy' + }); + } + var gutter = beestat.style.size.gutter; var row = $.createElement('div').style({ @@ -89,7 +105,7 @@ beestat.component.header.prototype.decorate_ = function(parent) { .set_icon(page.icon) .set_text_color(beestat.style.color.bluegray.dark); - if (beestat.width > 650) { + if (beestat.width > 800) { button.set_text(page.text); } diff --git a/js/component/input/checkbox.js b/js/component/input/checkbox.js index 0fa5c85..1feea20 100644 --- a/js/component/input/checkbox.js +++ b/js/component/input/checkbox.js @@ -34,12 +34,10 @@ beestat.component.input.checkbox.prototype.decorate_ = function(parent) { .innerText(this.label_) .addEventListener('click', function() { self.input_[0].click(); - // self.input_.checked(!self.input_.checked()); }); div.appendChild(text_label); this.input_.addEventListener('change', function() { - // console.log('input changed'); self.dispatchEvent('change'); }); diff --git a/js/component/modal/air_quality_detail_custom.js b/js/component/modal/air_quality_detail_custom.js new file mode 100644 index 0000000..5a8f7f0 --- /dev/null +++ b/js/component/modal/air_quality_detail_custom.js @@ -0,0 +1,374 @@ +/** + * Custom date range for the Air Quality Detail chart. + */ +beestat.component.modal.air_quality_detail_custom = function() { + beestat.component.modal.apply(this, arguments); + this.state_.air_quality_detail_range_type = beestat.setting('air_quality_detail_range_type'); + this.state_.air_quality_detail_range_dynamic = beestat.setting('air_quality_detail_range_dynamic'); + this.state_.air_quality_detail_range_static_begin = beestat.setting('air_quality_detail_range_static_begin'); + this.state_.air_quality_detail_range_static_end = beestat.setting('air_quality_detail_range_static_end'); + this.state_.error = { + 'max_range': false, + 'invalid_range_begin': false, + 'invalid_range_end': false, + 'out_of_sync_range': false + }; +}; +beestat.extend(beestat.component.modal.air_quality_detail_custom, beestat.component.modal); + +/** + * Decorate. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.air_quality_detail_custom.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Choose a custom range to display on the Air Quality Detail chart. Max range is 7 days at a time and 90 days in the past.')); + + this.decorate_range_type_(parent); + + if (this.state_.air_quality_detail_range_type === 'dynamic') { + this.decorate_range_dynamic_(parent); + } else { + this.decorate_range_static_(parent); + } + + this.decorate_error_(parent); +}; + +/** + * Decorate the range type selector. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.air_quality_detail_custom.prototype.decorate_range_type_ = function(parent) { + var self = this; + + var button_group = new beestat.component.button_group(); + + button_group.add_button(new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_background_color( + this.state_.air_quality_detail_range_type === 'dynamic' + ? beestat.style.color.lightblue.base + : beestat.style.color.bluegray.base + ) + .set_text('Dynamic') + .addEventListener('click', function() { + self.state_.air_quality_detail_range_type = 'dynamic'; + self.rerender(); + })); + + button_group.add_button(new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_background_color( + this.state_.air_quality_detail_range_type === 'static' + ? beestat.style.color.lightblue.base + : beestat.style.color.bluegray.base + ) + .set_text('Static') + .addEventListener('click', function() { + self.state_.air_quality_detail_range_type = 'static'; + self.rerender(); + })); + + (new beestat.component.title('Range Type')).render(parent); + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + button_group.render(column); +}; + +/** + * Decorate the static range inputs. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.air_quality_detail_custom.prototype.decorate_range_static_ = function(parent) { + var self = this; + + var air_quality_detail_static_range_begin; + var air_quality_detail_static_range_end; + + /** + * Check whether or not a value is outside of where data is synced. + */ + var check_out_of_sync_range = function() { + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + var min = moment.max( + moment(thermostat.sync_begin), + moment().subtract(1, 'month') + ); + var max = moment(thermostat.sync_end); + + var begin = moment.min( + moment(air_quality_detail_static_range_begin.get_value()), + moment(air_quality_detail_static_range_end.get_value()) + ); + + var end = moment.max( + moment(air_quality_detail_static_range_begin.get_value() + ' 00:00:00'), + moment(air_quality_detail_static_range_end.get_value() + ' 23:59:59') + ); + + if ( + begin.isBefore(min) === true || + end.isAfter(max) === true + ) { + self.state_.error.out_of_sync_range = true; + } else { + self.state_.error.out_of_sync_range = false; + } + }; + + air_quality_detail_static_range_begin = new beestat.component.input.text() + .set_style({ + 'width': 110, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('calendar') + .set_value(this.state_.air_quality_detail_range_static_begin); + + air_quality_detail_static_range_begin.addEventListener('blur', function() { + var m = moment(this.get_value()); + if (m.isValid() === true) { + self.state_.error.invalid_range_begin = false; + + var value = m.format('M/D/YYYY'); + + var diff = Math.abs(m.diff(moment(air_quality_detail_static_range_end.get_value()), 'day')) + 1; + if (diff > 7) { + self.state_.error.max_range = true; + } else { + self.state_.error.max_range = false; + } + + check_out_of_sync_range(); + + self.state_.air_quality_detail_range_static_begin = value; + self.rerender(); + } else { + self.state_.air_quality_detail_range_static_begin = this.get_value(); + self.state_.error.invalid_range_begin = true; + self.rerender(); + } + }); + + air_quality_detail_static_range_end = new beestat.component.input.text() + .set_style({ + 'width': 110, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('calendar') + .set_value(this.state_.air_quality_detail_range_static_end); + + air_quality_detail_static_range_end.addEventListener('blur', function() { + var m = moment(this.get_value()); + if (m.isValid() === true) { + self.state_.error.invalid_range_end = false; + + var value = m.format('M/D/YYYY'); + + var diff = Math.abs(m.diff(moment(air_quality_detail_static_range_begin.get_value()), 'day')) + 1; + if (diff > 7) { + self.state_.error.max_range = true; + } else { + self.state_.error.max_range = false; + } + + check_out_of_sync_range(); + + self.state_.air_quality_detail_range_static_end = value; + self.rerender(); + } else { + self.state_.air_quality_detail_range_static_end = this.get_value(); + self.state_.error.invalid_range_end = true; + self.rerender(); + } + }); + + var span; + + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + + span = $.createElement('span').style('display', 'inline-block'); + air_quality_detail_static_range_begin.render(span); + column.appendChild(span); + + span = $.createElement('span') + .style({ + 'display': 'inline-block', + 'margin-left': beestat.style.size.gutter, + 'margin-right': beestat.style.size.gutter + }) + .innerText('to'); + column.appendChild(span); + + span = $.createElement('span').style('display', 'inline-block'); + air_quality_detail_static_range_end.render(span); + column.appendChild(span); +}; + +/** + * Decorate the dynamic range input. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.air_quality_detail_custom.prototype.decorate_range_dynamic_ = function(parent) { + var self = this; + + var air_quality_detail_range_dynamic = new beestat.component.input.text() + .set_style({ + 'width': 75, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 1 + }) + .set_icon('pound') + .set_value(beestat.setting('air_quality_detail_range_dynamic')); + + air_quality_detail_range_dynamic.addEventListener('blur', function() { + var value = parseInt(this.get_value(), 10); + if (isNaN(value) === true || value === 0) { + value = 1; + } else if (value > 7) { + value = 7; + } + this.set_value(value); + self.state_.air_quality_detail_range_dynamic = value; + }); + + var span; + + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + + span = $.createElement('span').style('display', 'inline-block'); + air_quality_detail_range_dynamic.render(span); + column.appendChild(span); + + span = $.createElement('span') + .style({ + 'display': 'inline-block', + 'margin-left': beestat.style.size.gutter + }) + .innerText('days'); + column.appendChild(span); +}; + +/** + * Decorate the error area. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.air_quality_detail_custom.prototype.decorate_error_ = function(parent) { + var div = $.createElement('div').style('color', beestat.style.color.red.base); + if (this.state_.error.max_range === true) { + div.appendChild($.createElement('div').innerText('Max range is 7 days.')); + } + if (this.state_.error.invalid_range_begin === true) { + div.appendChild($.createElement('div').innerText('Invalid begin date.')); + } + if (this.state_.error.invalid_range_end === true) { + div.appendChild($.createElement('div').innerText('Invalid end date.')); + } + if (this.state_.error.out_of_sync_range === true) { + div.appendChild($.createElement('div').innerText('Detail not available for this range.')); + } + parent.appendChild(div); +}; + +/** + * Get title. + * + * @return {string} Title + */ +beestat.component.modal.air_quality_detail_custom.prototype.get_title_ = function() { + return 'Air Quality Detail - Custom Range'; +}; + +/** + * Get the buttons that go on the bottom of this modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.air_quality_detail_custom.prototype.get_buttons_ = function() { + var self = this; + + var cancel = new beestat.component.button() + .set_background_color('#fff') + .set_text_color(beestat.style.color.gray.base) + .set_text_hover_color(beestat.style.color.red.base) + .set_text('Cancel') + .addEventListener('click', function() { + self.dispose(); + }); + + var save; + if ( + this.state_.error.max_range === true || + this.state_.error.invalid_range_begin === true || + this.state_.error.invalid_range_end === true || + this.state_.error.out_of_sync_range === true + ) { + save = new beestat.component.button() + .set_background_color(beestat.style.color.gray.base) + .set_text_color('#fff') + .set_text('Save'); + } else { + save = new beestat.component.button() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('Save') + .addEventListener('click', function() { + this + .set_background_color(beestat.style.color.gray.base) + .set_background_hover_color() + .removeEventListener('click'); + + if (moment(self.state_.air_quality_detail_range_static_begin).isAfter(moment(self.state_.air_quality_detail_range_static_end)) === true) { + var temp = self.state_.air_quality_detail_range_static_begin; + self.state_.air_quality_detail_range_static_begin = self.state_.air_quality_detail_range_static_end; + self.state_.air_quality_detail_range_static_end = temp; + } + + beestat.cache.delete('runtime_thermostat'); + beestat.cache.delete('runtime_sensor'); + beestat.setting( + { + 'air_quality_detail_range_type': self.state_.air_quality_detail_range_type, + 'air_quality_detail_range_dynamic': self.state_.air_quality_detail_range_dynamic, + 'air_quality_detail_range_static_begin': self.state_.air_quality_detail_range_static_begin, + 'air_quality_detail_range_static_end': self.state_.air_quality_detail_range_static_end + }, + undefined, + function() { + self.dispose(); + } + ); + }); + } + + return [ + cancel, + save + ]; +}; diff --git a/js/js.php b/js/js.php index 75db081..577096d 100755 --- a/js/js.php +++ b/js/js.php @@ -46,6 +46,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; // Component echo '' . PHP_EOL; @@ -67,6 +68,8 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; @@ -74,6 +77,9 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; @@ -96,6 +102,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; diff --git a/js/layer/air_quality.js b/js/layer/air_quality.js new file mode 100644 index 0000000..8240895 --- /dev/null +++ b/js/layer/air_quality.js @@ -0,0 +1,64 @@ +/** + * Air Quality layer. + */ +beestat.layer.air_quality = function() { + beestat.layer.apply(this, arguments); +}; +beestat.extend(beestat.layer.air_quality, beestat.layer); + +beestat.layer.air_quality.prototype.decorate_ = function(parent) { + const thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + /* + * Set the overflow on the body so the scrollbar is always present so + * highcharts graphs render properly. + */ + $('body').style({ + 'overflow-y': 'scroll', + 'background': beestat.style.color.bluegray.light, + 'padding': '0 ' + beestat.style.size.gutter + 'px' + }); + + (new beestat.component.header('air_quality')).render(parent); + + // All the cards + var cards = []; + + if (window.is_demo === true) { + cards.push([ + { + 'card': new beestat.component.card.demo(), + 'size': 12 + } + ]); + } + + cards.push([ + { + 'card': new beestat.component.card.early_access( + thermostat.thermostat_id + ), + 'size': 12 + } + ]); + + cards.push([ + { + 'card': new beestat.component.card.air_quality_detail( + thermostat.thermostat_id + ), + 'size': 12 + } + ]); + + cards.push([ + { + 'card': new beestat.component.card.voc_summary( + thermostat.thermostat_id + ), + 'size': 12 + } + ]); + + (new beestat.component.layout(cards)).render(parent); +}; diff --git a/js/lib/highcharts/highcharts.js b/js/lib/highcharts/highcharts.js index 881e9fc..71fa69d 100755 --- a/js/lib/highcharts/highcharts.js +++ b/js/lib/highcharts/highcharts.js @@ -1,3 +1,5 @@ +/* eslint-disable */ + /* Highcharts JS vv7.1.2 custom build (2019-07-17)