mirror of
https://github.com/beestat/app.git
synced 2026-02-26 13:10:23 -05:00
559 lines
16 KiB
JavaScript
559 lines
16 KiB
JavaScript
/**
|
|
* 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.<br/><strong><a target="_blank" href="https://www.notion.so/beestat/Temperature-Profiles-9c0fba6793dd4bc68f798c1516f0ea25#a5e176aba4c847acb9e2b773f7aba73b">Why?</a></strong>');
|
|
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;
|
|
};
|