/** * Temperature Profiles. * * @param {number} thermostat_id The thermostat_id this card is displaying * data for. */ beestat.component.card.temperature_profiles = function(thermostat_id) { this.thermostat_id_ = thermostat_id; var self = this; /* * 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( ['cache.thermostat', 'cache.profile'], change_function ); beestat.component.card.apply(this, arguments); }; beestat.extend(beestat.component.card.temperature_profiles, beestat.component.card); /** * Override subtitle to store a reference for live updates. * * @param {rocket.Elements} parent */ beestat.component.card.temperature_profiles.prototype.decorate_subtitle_ = function(parent) { this.subtitle_element_ = $.createElement('div') .style({ 'font-weight': beestat.style.font_weight.light, 'margin-bottom': (beestat.style.size.gutter / 4) }); this.subtitle_element_.innerHTML(this.get_subtitle_() || ''); parent.appendChild(this.subtitle_element_); }; /** * Decorate card. * * @param {rocket.Elements} parent */ beestat.component.card.temperature_profiles.prototype.decorate_contents_ = function(parent) { var container = $.createElement('div').style({ 'position': 'relative' }); parent.appendChild(container); var data = this.get_data_(); var chart_container = $.createElement('div'); container.appendChild(chart_container); 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.innerHTML('No data to display.
Why?'); container.appendChild(no_data); } this.chart_ = new beestat.component.chart.temperature_profiles(data); this.chart_.render(chart_container); // Historical profile slider var profiles = this.get_profiles_(); if (profiles.length >= 2) { var self = this; if (this.profile_index_ === undefined || this.profile_index_ >= profiles.length) { this.profile_index_ = profiles.length - 1; } var slider_container = $.createElement('div').style({ 'display': 'flex', 'align-items': 'center', 'margin-top': '8px' }); parent.appendChild(slider_container); var range_container = $.createElement('div').style({ 'flex-grow': '1' }); slider_container.appendChild(range_container); var date_label = $.createElement('div').style({ 'white-space': 'nowrap', 'margin-left': '8px', 'min-width': '100px', 'text-align': 'right' }); date_label.innerText( moment(profiles[this.profile_index_].profile.metadata.generated_at).format('MMM D, YYYY') ); slider_container.appendChild(date_label); var range = new beestat.component.input.range(); range .set_min(0) .set_max(profiles.length - 1) .set_value(this.profile_index_) .render(range_container); range.addEventListener('input', function() { self.profile_index_ = parseInt(range.get_value(), 10); date_label.innerText( moment(profiles[self.profile_index_].profile.metadata.generated_at).format('MMM D, YYYY') ); var new_data = self.get_data_(); // Update the chart data in-place to avoid DOM recreation self.chart_.data_ = new_data; var new_series = self.chart_.get_options_series_(); new_series.forEach(function(s) { if (s.data === undefined) { s.data = []; } }); self.chart_.chart_.update({ 'series': new_series, 'exporting': { 'filename': self.chart_.get_options_exporting_filename_(), 'chartOptions': { 'subtitle': { 'text': new_data.metadata.chart.subtitle } } } }, true, false, false); // Update the card subtitle self.subtitle_element_.innerHTML(self.get_subtitle_() || ''); }); } }; /** * Get all of the series data. * * @return {object} The series data. */ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { const self = this; var thermostat = beestat.cache.thermostat[this.thermostat_id_]; var data = { 'x': [], 'series': {}, 'metadata': { 'series': {}, 'chart': { 'title': this.get_title_(), 'subtitle': this.get_subtitle_(), 'outdoor_temperature': beestat.temperature({ 'temperature': thermostat.weather.temperature, 'round': 0 }) } } }; var selected_profile = this.get_selected_profile_(); var profiles = this.get_profiles_(); var is_current = (this.profile_index_ === undefined || this.profile_index_ === profiles.length - 1); if ( is_current && ( selected_profile === null || moment().diff(moment(selected_profile.metadata.generated_at), 'days') >= 7 ) ) { this.show_loading_('Fetching'); new beestat.api() .add_call( 'thermostat', 'generate_profile', { 'thermostat_id': this.thermostat_id_ } ) .add_call( 'thermostat', 'read_id', { 'attributes': { 'inactive': 0 } }, 'thermostat' ) .set_callback(function(response) { beestat.cache.set('thermostat', response.thermostat); }) .send(); } else if (selected_profile !== null) { const profile_extremes = this.get_profile_extremes_(5); var y_min = Infinity; var y_max = -Infinity; for (var type in selected_profile.temperature) { // Cloned because I mutate this data for temperature conversions. var profile = beestat.clone( selected_profile.temperature[type] ); if (profile !== null) { // Convert the data to Celsius if necessary var deltas_converted = {}; for (var key in profile.deltas) { deltas_converted[beestat.temperature({'temperature': key})] = beestat.temperature({ 'temperature': (profile.deltas[key]), 'delta': true, 'round': 3 }); } profile.deltas = deltas_converted; var linear_trendline = beestat.math.get_linear_trendline(profile.deltas); var min_max_keys = Object.keys(profile.deltas); if (type.includes('heat') === true) { min_max_keys.push(beestat.temperature(profile_extremes.heat.min)); min_max_keys.push(beestat.temperature(profile_extremes.heat.max)); } else if (type.includes('cool') === true) { min_max_keys.push(beestat.temperature(profile_extremes.cool.min)); min_max_keys.push(beestat.temperature(profile_extremes.cool.max)); } else if (type.includes('resist') === true) { min_max_keys.push(beestat.temperature(profile_extremes.resist.min)); min_max_keys.push(beestat.temperature(profile_extremes.heat.min)); min_max_keys.push(beestat.temperature(profile_extremes.resist.max)); min_max_keys.push(beestat.temperature(profile_extremes.cool.max)); } // Filter out nulls min_max_keys = min_max_keys.filter(Boolean); // This specific trendline x range. var this_x_min = Math.min.apply(null, min_max_keys); var this_x_max = Math.max.apply(null, min_max_keys); data.series['trendline_' + type] = []; data.series['raw_' + type] = []; /** * Data is stored internally as °F with 1 value per degree. That data * gets converted to °C which then requires additional precision * (increment). * * The additional precision introduces floating point error, so * convert the x value to a fixed string. * * The string then needs converted to a number for highcharts, so * later on use parseFloat to get back to that. * * Stupid Celsius. */ var increment; var fixed; if (beestat.setting('units.temperature') === '°F') { increment = 1; fixed = 0; } else { increment = 0.1; fixed = 1; } for (var x = this_x_min; x <= this_x_max; x += increment) { var x_fixed = x.toFixed(fixed); var y = (linear_trendline.slope * x_fixed) + linear_trendline.intercept; data.series['trendline_' + type].push([ parseFloat(x_fixed), y ]); if (profile.deltas[x_fixed] !== undefined) { data.series['raw_' + type].push([ parseFloat(x_fixed), profile.deltas[x_fixed] ]); y_min = Math.min(y_min, profile.deltas[x_fixed]); y_max = Math.max(y_max, profile.deltas[x_fixed]); } data.metadata.chart.y_min = y_min; data.metadata.chart.y_max = y_max; } } } // Lock the y-axis scale to the most recent profile so historical // profiles don't cause the chart to jump around. if (is_current) { this.current_y_min_ = y_min; this.current_y_max_ = y_max; } else if (this.current_y_min_ !== undefined) { data.metadata.chart.y_min = this.current_y_min_; data.metadata.chart.y_max = this.current_y_max_; } } return data; }; /** * Get the title of the card. * * @return {string} The title. */ beestat.component.card.temperature_profiles.prototype.get_title_ = function() { return 'Temperature Profiles'; }; /** * Get the subtitle of the card. * * @return {string} The subtitle. */ beestat.component.card.temperature_profiles.prototype.get_subtitle_ = function() { var selected_profile = this.get_selected_profile_(); // If the profile has not yet been generated. if (selected_profile === null) { return null; } const generated_at_m = moment( selected_profile.metadata.generated_at ); let duration_text = ''; // How much data was used to generate this. const duration_weeks = Math.round(selected_profile.metadata.duration / 7); duration_text += ' from the past'; if (duration_weeks === 0) { duration_text += ' few days'; } else if (duration_weeks === 1) { duration_text += ' week'; } else if (duration_weeks >= 52) { duration_text += ' year'; } else { duration_text += ' ' + duration_weeks + ' weeks'; } duration_text += ' of data'; return 'Generated ' + generated_at_m.format('MMM Do, YYYY @ h a') + duration_text + ' (updated weekly).'; }; /** * Decorate the menu. * * @param {rocket.Elements} parent */ beestat.component.card.temperature_profiles.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('Download Chart') .set_icon('download') .set_callback(function() { self.chart_.export(); })); if (this.has_data_() === true) { menu.add_menu_item(new beestat.component.menu_item() .set_text('Download Profile') .set_icon('code_tags') .set_callback(function() { var profile = self.get_selected_profile_(); var blob = new Blob( [JSON.stringify(profile, null, 2)], {'type': 'application/json'} ); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'Temperature Profile - ' + moment(profile.metadata.generated_at).format('YYYY-MM-DD') + '.json'; a.click(); URL.revokeObjectURL(a.href); })); menu.add_menu_item(new beestat.component.menu_item() .set_text('More Info') .set_icon('information') .set_callback(function() { new beestat.component.modal.temperature_profiles_info(self.get_selected_profile_()).render(); })); } 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/9c0fba6793dd4bc68f798c1516f0ea25'); })); }; /** * Get the profile extremes for heat, cool, and resist. * * @param {number} padding How much extra to pad the extremes by. * * @return {object} */ beestat.component.card.temperature_profiles.prototype.get_profile_extremes_ = function(padding) { const thermostat = beestat.cache.thermostat[this.thermostat_id_]; const extremes = { 'heat': { 'min': Infinity, 'max': -Infinity }, 'cool': { 'min': Infinity, 'max': -Infinity }, 'resist': { 'min': Infinity, 'max': -Infinity } }; for (let type in thermostat.profile.temperature) { const profile = thermostat.profile.temperature[type]; if (profile !== null) { let parent_type; if (type.includes('cool')) { parent_type = 'cool'; } else if (type.includes('heat')) { parent_type = 'heat'; } else if (type.includes('resist')) { parent_type = 'resist'; } extremes[parent_type].min = Math.min( extremes[parent_type].min, Math.min.apply(null, Object.keys(profile.deltas)) - padding ); extremes[parent_type].max = Math.max( extremes[parent_type].max, Math.max.apply(null, Object.keys(profile.deltas)) + padding ); // Extend to weather if ( thermostat.weather !== null && thermostat.weather.temperature !== null ) { if ( parent_type === 'resist' || parent_type === 'heat' ) { extremes[parent_type].min = Math.min( extremes[parent_type].min, thermostat.weather.temperature - padding ); } if ( parent_type === 'resist' || parent_type === 'cool' ) { extremes[parent_type].max = Math.max( extremes[parent_type].max, thermostat.weather.temperature + padding ); } } } } // Convert +/-Infinity to null for (let parent_type in extremes) { if (extremes[parent_type].min === Infinity) { extremes[parent_type].min = null; } if (extremes[parent_type].max === -Infinity) { extremes[parent_type].max = null; } } return extremes; }; /** * Get all historical profiles for this thermostat, sorted by generated_at ascending. * * @return {Array} */ beestat.component.card.temperature_profiles.prototype.get_profiles_ = function() { if (beestat.cache.profile === undefined) { return []; } var self = this; return Object.values(beestat.cache.profile) .filter(function(p) { return p.thermostat_id === self.thermostat_id_; }) .sort(function(a, b) { return a.profile.metadata.generated_at > b.profile.metadata.generated_at ? 1 : -1; }); }; /** * Get the currently selected profile object (the one to render on the chart). * Falls back to the current thermostat profile when no historical selection. * * @return {object|null} */ beestat.component.card.temperature_profiles.prototype.get_selected_profile_ = function() { var profiles = this.get_profiles_(); if ( this.profile_index_ !== undefined && profiles.length > 0 && profiles[this.profile_index_] !== undefined ) { return profiles[this.profile_index_].profile; } return beestat.cache.thermostat[this.thermostat_id_].profile; }; /** * Get whether or not there is any data to be displayed. * * @return {boolean} */ beestat.component.card.temperature_profiles.prototype.has_data_ = function() { const data = this.get_data_(); return Object.keys(data.series).length > 0; };