Files
homeassistant/www/community/pvpc-hourly-pricing-card/pvpc-hourly-pricing-card.js
2020-12-27 17:22:59 +01:00

741 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const LitElement =
window.LitElement || Object.getPrototypeOf(customElements.get('home-assistant') || customElements.get('hui-view'));
const { html, css } = LitElement.prototype;
const locale = {
da: {
minPrice: 'Minimumspris i dag:',
maxPrice: 'Maksimal pris i dag:',
minPriceNextDay: 'Minimumspris i morgen:',
maxPriceNextDay: 'Maksimal pris i morgen:',
infoNoNextDay: 'Morgendagens data er endnu ikke tilgængelige',
from: 'fra',
to: 'til'
},
de: {
minPrice: 'Mindestpreis heute:',
maxPrice: 'Maximaler preis heute:',
minPriceNextDay: 'Mindestpreis morgen:',
maxPriceNextDay: 'Maximaler preis morgen:',
infoNoNextDay: 'Die Daten von morgen sind noch nicht verfügbar',
from: 'von',
to: 'bis'
},
en: {
minPrice: 'Lowest price today:',
maxPrice: 'Highest price today:',
minPriceNextDay: 'Lowest price tomorrow:',
maxPriceNextDay: 'Highest price tomorrow:',
infoNoNextDay: "Tomorrow's data is not yet available",
from: 'from',
to: 'to'
},
es: {
minPrice: 'Precio mínimo hoy:',
maxPrice: 'Precio máximo hoy:',
minPriceNextDay: 'Precio mínimo mañana:',
maxPriceNextDay: 'Precio máximo mañana:',
infoNoNextDay: 'Los datos de mañana no están disponibles aún',
from: 'de',
to: 'a'
},
fr: {
minPrice: "Prix minimum aujourd'hui:",
maxPrice: "Prix maximum aujourd'hui:",
minPriceNextDay: 'Prix minimum demain:',
maxPriceNextDay: 'Prix maximum demain:',
infoNoNextDay: 'Les données de demain ne sont pas encore disponibles',
from: 'de',
to: 'à'
},
nl: {
minPrice: 'Minimumspris i dag:',
maxPrice: 'Maksimal pris i dag:',
minPriceNextDay: 'Minimum prijs morgen:',
maxPriceNextDay: 'Maximale prijs morgen:',
infoNoNextDay: 'De gegevens van morgen zijn nog niet beschikbaar',
from: 'fra',
to: 'til'
},
ru: {
minPrice: 'Минимальная цена сегодня:',
maxPrice: 'Максимальная цена сегодня:',
minPriceNextDay: 'Минимальная цена завтра:',
maxPriceNextDay: 'Максимальная цена завтра:',
infoNoNextDay: 'Данные завтра еще не доступны',
from: 'С',
to: 'до'
},
sv: {
minPrice: 'Lägsta pris idag:',
maxPrice: 'Maxpris idag:',
minPriceNextDay: 'Lägsta pris imorgon:',
maxPriceNextDay: 'Maxpris i morgon:',
infoNoNextDay: 'Morgondagens data är ännu inte tillgängliga',
from: '',
to: 'till'
}
};
const tariffPeriodIconColors = {
error: '--error-color',
normal: '--warning-color',
peak: '--error-color',
valley: '--success-color',
'super-valley': '--info-color'
};
const tariffPeriodIcons = {
error:
'M 28.342306,10.429944 27.798557,32.995546 H 24.243272 L 23.657695,10.429944 Z M 28.133172,41.570057 H 23.86683 v -4.412736 h 4.266342 z',
normal:
'M 31.032172,16.612305 20.999855,32.113255 15.66609,25.065424 H 0.97821381 a 25.017275,25.017275 0 0 0 -0.0332829,0.949884 25.017275,25.017275 0 0 0 0.0468985,0.940092 H 14.800215 l 6.199595,8.453119 10.03232,-15.502917 5.335714,7.049798 h 14.578421 a 25.017275,25.017275 0 0 0 0.03328,-0.940092 25.017275,25.017275 0 0 0 -0.0469,-0.949884 H 37.233737 Z',
peak:
'M 2.5238392,34.768609 A 25.003164,25.003164 0 0 1 1.9104804,32.879664 h 8.6436716 l 15.49805,-22.870055 15.121052,22.870055 h 8.891749 a 25.003164,25.003164 0 0 1 -0.628986,1.888945 H 40.038344 L 26.052202,13.679995 12.06606,34.768609 Z',
valley:
'm 2.5238392,17.238401 a 25.003164,25.003164 0 0 0 -0.6133588,1.888945 h 8.6436716 l 15.49805,22.870055 15.121052,-22.870055 h 8.891749 A 25.003164,25.003164 0 0 0 49.436017,17.238401 H 40.038344 L 26.052202,38.327015 12.06606,17.238401 Z',
'super-valley':
'm 30.867213,27.342466 c 0,0.670334 -0.543413,1.213747 -1.213747,1.213746 -0.670333,-10e-7 -1.213744,-0.543413 -1.213744,-1.213746 0,-0.670333 0.543411,-1.213745 1.213744,-1.213746 0.670334,-1e-6 1.213747,0.543412 1.213747,1.213746 z m -7.282476,0 c 0,0.670333 -0.543412,1.213746 -1.213745,1.213746 -0.670334,0 -1.213746,-0.543412 -1.213746,-1.213746 0,-0.670334 0.543412,-1.213746 1.213746,-1.213746 0.670333,0 1.213745,0.543413 1.213745,1.213746 z m 8.026907,-6.869803 c -0.161832,-0.477407 -0.614966,-0.817256 -1.149013,-0.817256 h -8.900804 c -0.534048,0 -0.979088,0.339849 -1.149012,0.817256 l -1.683061,4.846893 v 6.473312 c 0,0.445039 0.364123,0.809164 0.809163,0.809164 h 0.809164 c 0.445041,0 0.809165,-0.364125 0.809165,-0.809164 v -0.809165 h 9.709967 v 0.809165 c 0,0.445039 0.364125,0.809164 0.809164,0.809164 h 0.809165 c 0.445039,0 0.809163,-0.364125 0.809163,-0.809164 v -6.473312 z m -9.800018,0.767664 h 8.393115 l 0.841531,2.49431 H 20.970096 Z m 9.89816,8.158458 H 20.314672 v -3.837522 l 0.0971,-0.275116 h 11.209006 l 0.089,0.275116 z M 25.208235,17.875001 v -1.607989 h -3.215979 l 4.823966,-2.411981 v 1.607988 H 30.0322 Z M 2.5904451,17.061236 C 2.3615878,17.681074 2.1574473,18.309759 1.9785073,18.945805 H 10.602006 V 37.331696 H 41.150085 V 18.945805 h 8.871408 c -0.184075,-0.636272 -0.393416,-1.26496 -0.62753,-1.884569 H 38.720725 V 35.001194 H 12.908652 V 17.061236 Z'
};
const fireEvent = (node, type, detail, options) => {
options = options || {};
detail = detail === null || detail === undefined ? {} : detail;
const event = new Event(type, {
bubbles: options.bubbles === undefined ? true : options.bubbles,
cancelable: Boolean(options.cancelable),
composed: options.composed === undefined ? true : options.composed
});
event.detail = detail;
node.dispatchEvent(event);
return event;
};
function hasConfigOrEntityChanged(element, changedProps) {
if (changedProps.has('_config')) {
return true;
}
const oldHass = changedProps.get('hass');
if (oldHass) {
return oldHass.states[element._config.entity] !== element.hass.states[element._config.entity];
}
return true;
}
class PVPCHourlyPricingCard extends LitElement {
static get properties() {
return {
_config: { type: Object },
hass: { type: Object }
};
}
static async getConfigElement() {
await import('./pvpc-hourly-pricing-card-editor.js');
return document.createElement('pvpc-hourly-pricing-card-editor');
}
static getStubConfig(hass, entities, entitiesFallback) {
const entity = Object.keys(hass.states).find((eid) =>
Object.keys(hass.states[eid].attributes).some((aid) => aid == 'min_price_at')
);
return { entity: entity };
}
setConfig(config) {
if (!config.entity) {
throw new Error('Please define a "Spain electricity hourly pricing (PVPC)" entity');
}
this._config = config;
this.setPVPCHourlyPricingObj();
}
setPVPCHourlyPricingObj() {
if (!this.hass) return;
this.pvpcHourlyPricingObj = this._config.entity in this.hass.states ? this.hass.states[this._config.entity] : null;
if (!this.pvpcHourlyPricingObj) return;
this.despiction = this.getDespiction(this.pvpcHourlyPricingObj.attributes);
}
shouldUpdate(changedProps) {
return hasConfigOrEntityChanged(this, changedProps);
}
updated(param) {
this.setPVPCHourlyPricingObj();
let chart = this.shadowRoot.getElementById('Chart');
if (chart) {
chart.data = this.ChartData;
chart.hass = this.hass;
}
}
render() {
if (!this._config || !this.hass) {
return html``;
}
this.setPVPCHourlyPricingObj();
this.numberElements = 0;
this.lang = this.hass.selectedLanguage || this.hass.language;
if (!this.pvpcHourlyPricingObj) {
return html`
<style>
.not-found {
flex: 1;
background-color: yellow;
padding: 8px;
}
</style>
<ha-card>
<div class="not-found">Entity not available: ${this._config.entity}</div>
</ha-card>
`;
}
return html`
<ha-card header="${this._config.name ? this._config.name : ''}">
${this._config.current !== false ? this.renderCurrent() : ''}
${this._config.details !== false ? this.renderDetails() : ''}
${this._config.graph !== false ? this.renderGraph() : ''}
${this._config.info !== false ? this.renderInfo() : ''}
</ha-card>
`;
}
renderCurrent() {
this.numberElements++;
const tariffPeriod = this.getTariffPeriod(this.pvpcHourlyPricingObj.attributes.tariff);
const style = getComputedStyle(document.body);
const iconColor = style.getPropertyValue(tariffPeriodIconColors[tariffPeriod]);
return html`
<div class="current tappable ${this.numberElements > 1 ? 'spacer' : ''}" @click="${this._handleClick}">
<svg class="period-icon" viewBox="0 0 52 52">
<circle fill="${iconColor}" r="25" cy="26" cx="26" />
<path fill="#f9f9f9" d="${tariffPeriodIcons[tariffPeriod]}" />
</svg>
<span class="currentPrice">${this.getFixedFloat(this.pvpcHourlyPricingObj.state)}</span>
<span class="currentPriceUnit"> ${this.pvpcHourlyPricingObj.attributes.unit_of_measurement}</span>
</div>
`;
}
renderDetails() {
if (!this.despiction) {
return html``;
}
const minPrice = this.getFixedFloat(this.despiction.minPrice);
const minPriceFrom = this.getTimeString(new Date().setHours(this.despiction.minIndex, 0));
const minPriceTo = this.getTimeString(new Date().setHours(this.despiction.minIndex + 1, 0));
const maxPrice = this.getFixedFloat(this.despiction.maxPrice);
const maxPriceFrom = this.getTimeString(new Date().setHours(this.despiction.maxIndex, 0));
const maxPriceTo = this.getTimeString(new Date().setHours(this.despiction.maxIndex + 1, 0));
const minPriceNextDay = this.getFixedFloat(this.despiction.minPriceNextDay);
const minPriceFromNextDay = this.getTimeString(new Date().setHours(this.despiction.minIndexNextDay, 0));
const minPriceToNextDay = this.getTimeString(new Date().setHours(this.despiction.minIndexNextDay + 1, 0));
const maxPriceNextDay = this.getFixedFloat(this.despiction.maxPriceNextDay);
const maxPriceFromNextDay = this.getTimeString(new Date().setHours(this.despiction.maxIndexNextDay, 0));
const maxPriceToNextDay = this.getTimeString(new Date().setHours(this.despiction.maxIndexNextDay + 1, 0));
this.numberElements++;
return html`
<ul class="details tappable ${this.numberElements > 1 ? 'spacer' : ''}" @click="${this._handleClick}">
<li>
<ha-icon icon="mdi:thumb-up-outline"></ha-icon>
${this.ll('minPrice')} ${minPrice}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement}
${this.ll('from')} ${minPriceFrom} ${this.ll('to')} ${minPriceTo}
</li>
<li>
<ha-icon icon="mdi:thumb-down-outline"></ha-icon>
${this.ll('maxPrice')} ${maxPrice}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement}
${this.ll('from')} ${maxPriceFrom} ${this.ll('to')} ${maxPriceTo}
</li>
${this.despiction.minPriceNextDay
? html` <li>
<ha-icon icon="mdi:thumb-up-outline"></ha-icon>
${this.ll('minPriceNextDay')}
${minPriceNextDay}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement} ${this.ll('from')}
${minPriceFromNextDay} ${this.ll('to')} ${minPriceToNextDay}
</li>
<li>
<ha-icon icon="mdi:thumb-down-outline"></ha-icon>
${this.ll('maxPriceNextDay')}
${maxPriceNextDay}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement} ${this.ll('from')}
${maxPriceFromNextDay} ${this.ll('to')} ${maxPriceToNextDay}
</li>`
: ''}
</ul>
`;
}
renderGraph() {
if (!this.despiction) {
return html``;
}
this.numberElements++;
this.drawChart();
return html`
<div class="clear ${this.numberElements > 1 ? 'spacer' : ''}">
<ha-chart-base id="Chart"></ha-chart-base>
</div>
`;
}
renderInfo() {
if (!this.despiction) {
return html``;
}
this.numberElements++;
if (!this.despiction.minPriceNextDay) {
return html`
<div class="info clear ${this.numberElements > 1 ? 'spacer' : ''}">${this.ll('infoNoNextDay')}</div>
`;
} else {
return html``;
}
}
drawChart() {
if (!this.despiction) return;
const that = this;
const style = getComputedStyle(document.body);
const legendTextColor = style.getPropertyValue('--primary-text-color');
const axisTextColor = style.getPropertyValue('--secondary-text-color');
const dividerColor = style.getPropertyValue('--divider-color');
const selectionColor = style.getPropertyValue('--paper-grey-500');
const today = new Date();
const minIndex = this.despiction.minIndex;
const maxIndex = this.despiction.maxIndex;
const minIndexNextDay = this.despiction.minIndexNextDay;
const maxIndexNextDay = this.despiction.maxIndexNextDay;
const hasNextDayData = this.despiction.pricesNextDay[0] !== undefined;
const minIcon = '▼';
const maxIcon = '▲';
const chartOptions = {
type: 'line',
data: {
labels: this.despiction.dateTime,
datasets: [
{
label: that.getDateString(today),
type: 'line',
data: this.despiction.prices,
borderWidth: 2.0,
pointRadius: 0.0,
pointHitRadius: 0.0,
fill: false,
steppedLine: true
}
]
},
options: {
animation: {
duration: 300,
easing: 'linear',
onComplete: function () {
const chartInstance = this.chart;
const ctx = chartInstance.ctx;
const fontSize = 12;
const fontStyle = 'normal';
const fontFamily = 'Roboto';
ctx.font = Chart.helpers.fontString(fontSize, fontStyle, fontFamily);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const meta = chartInstance.controller.getDatasetMeta(0);
const minBarStart = meta.data[minIndex];
const minBarEnd = meta.data[minIndex + 1];
const pointToPointCenterXOffset = (minBarEnd._model.x - minBarStart._model.x) / 2;
const maxBar = meta.data[maxIndex];
const iconYOffset = 8;
ctx.fillStyle = meta.dataset._model.borderColor;
ctx.fillText(minIcon, minBarStart._model.x + pointToPointCenterXOffset, minBarStart._model.y - iconYOffset);
ctx.fillText(maxIcon, maxBar._model.x + pointToPointCenterXOffset, maxBar._model.y - iconYOffset);
ctx.save();
const selectedIndex =
chartInstance.tooltip._active &&
chartInstance.tooltip._active.length > 0 &&
chartInstance.tooltip._active[0]._index < 24
? chartInstance.tooltip._active[0]._index
: today.getHours();
const yaxis = meta.controller.chart.scales['y-axis-0'];
const xBarStart = meta.data[selectedIndex]._model.x;
const xBarEnd = meta.data[selectedIndex + 1]._model.x;
const yBarStart = yaxis.top;
const yBarEnd = yaxis.bottom;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.moveTo(xBarStart, yBarStart);
ctx.lineTo(xBarStart, yBarEnd);
ctx.strokeStyle = selectionColor;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(xBarEnd, yBarStart);
ctx.lineTo(xBarEnd, yBarEnd);
ctx.strokeStyle = selectionColor;
ctx.stroke();
ctx.globalAlpha = 0.3;
ctx.fillStyle = selectionColor;
ctx.fillRect(xBarStart, yBarStart, xBarEnd - xBarStart, yBarEnd - yBarStart);
ctx.restore();
if (hasNextDayData) {
const meta_next_day = chartInstance.controller.getDatasetMeta(1);
const minNextDayBar = meta_next_day.data[minIndexNextDay];
const maxNextDayBar = meta_next_day.data[maxIndexNextDay];
ctx.fillStyle = meta_next_day.dataset._model.borderColor;
ctx.fillText(
minIcon,
minNextDayBar._model.x + pointToPointCenterXOffset,
minNextDayBar._model.y - iconYOffset
);
ctx.fillText(
maxIcon,
maxNextDayBar._model.x + pointToPointCenterXOffset,
maxNextDayBar._model.y - iconYOffset
);
}
}
},
legend: {
display: true,
labels: {
fontColor: legendTextColor,
fontSize: 14,
usePointStyle: true,
boxWidth: 6
}
},
scales: {
xAxes: [
{
type: 'time',
maxBarThickness: 15,
display: false,
ticks: {
display: false
},
gridLines: {
display: false
}
},
{
position: 'bottom',
gridLines: {
display: true,
drawTicks: false,
drawBorder: false,
color: dividerColor
},
ticks: {
display: true,
padding: 10,
source: 'labels',
autoSkip: true,
fontColor: axisTextColor,
maxRotation: 0,
callback: function (value, index, values) {
return that.getHourString.call(that, value);
}
}
}
],
yAxes: [
{
position: 'left',
gridLines: {
display: true,
drawBorder: false,
drawTicks: false,
color: dividerColor,
borderDash: [4, 6]
},
ticks: {
display: true,
padding: 10,
fontColor: axisTextColor
}
}
]
},
tooltips: {
mode: 'index',
intersect: false,
callbacks: {
title: function (items, data) {
const index = items[0].index != 24 ? items[0].index : (items[0].index = 23);
const date = new Date(data.labels[index]);
const initDate = that.getTimeString(date);
const endDate = that.getTimeString(date.setHours(date.getHours() + 1));
return initDate + ' - ' + endDate;
},
label: function (tooltipItems, data) {
let icon;
const index = tooltipItems.index != 24 ? tooltipItems.index : (tooltipItems.index = 23);
if (tooltipItems.datasetIndex === 0) {
if (index == minIndex) {
icon = minIcon;
} else if (index == maxIndex) {
icon = maxIcon;
}
} else if (tooltipItems.datasetIndex === 1) {
if (index == minIndexNextDay) {
icon = minIcon;
} else if (index == maxIndexNextDay) {
icon = maxIcon;
}
}
const labelTitle = data.datasets[tooltipItems.datasetIndex].label || '';
const label =
labelTitle +
': ' +
parseFloat(tooltipItems.value).toFixed(5) +
' ' +
that.pvpcHourlyPricingObj.attributes.unit_of_measurement +
' ';
return icon ? label + icon : label;
}
}
}
}
};
if (hasNextDayData) {
chartOptions.data.datasets.push({
label: that.getDateString(today.setDate(today.getDate() + 1)),
type: 'line',
data: this.despiction.pricesNextDay,
borderWidth: 2.0,
pointRadius: 0.0,
pointHitRadius: 0.0,
fill: false,
steppedLine: true
});
}
this.ChartData = chartOptions;
}
getDespiction(attributes) {
const priceRegex = /price_\d\dh/;
const priceNextDayRegex = /price_(next|last)_day_\d\dh/;
const priceArray = Object.keys(attributes)
.filter((key) => priceRegex.test(key))
.map((key) => attributes[key]);
const priceNextDayArray = Object.keys(attributes)
.filter((key) => priceNextDayRegex.test(key))
.map((key) => attributes[key]);
let data = [];
let dateTime = [];
let prices = [];
let pricesNextDay = [];
for (let index = 0; index < 24; index++) {
dateTime.push(new Date().setHours(index, 0));
prices.push(priceArray[index]);
pricesNextDay.push(priceNextDayArray[index]);
}
dateTime.push(new Date().setHours(24, 0));
prices.push(priceArray[23]);
pricesNextDay.push(priceNextDayArray[23]);
data.dateTime = dateTime;
data.prices = prices;
data.pricesNextDay = pricesNextDay;
data.minPrice = Math.min.apply(null, prices);
data.maxPrice = Math.max.apply(null, prices);
data.minIndex = prices.indexOf(data.minPrice);
data.maxIndex = prices.indexOf(data.maxPrice);
data.minPriceNextDay = Math.min.apply(null, pricesNextDay);
data.maxPriceNextDay = Math.max.apply(null, pricesNextDay);
data.minIndexNextDay = pricesNextDay.indexOf(data.minPriceNextDay);
data.maxIndexNextDay = pricesNextDay.indexOf(data.maxPriceNextDay);
return data;
}
getTariffPeriod(tariff) {
let period;
switch (tariff) {
case 'normal':
period = 'normal';
break;
case 'discrimination':
const utcHours = new Date().getUTCHours();
if (utcHours >= 21 || utcHours < 11) {
period = 'valley';
} else {
period = 'peak';
}
break;
case 'electric_car':
const hours = new Date().getHours();
if (hours >= 1 && hours < 7) {
period = 'super-valley';
} else if (hours >= 13 && hours < 23) {
period = 'peak';
} else {
period = 'valley';
}
break;
default:
period = 'error';
}
return period;
}
getDateString(datetime) {
return new Date(datetime).toLocaleDateString(this.lang, {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
getHourString(datetime) {
return new Date(datetime).toLocaleTimeString(this.lang, { hour: '2-digit', hour12: false });
}
getTimeString(datetime) {
return new Date(datetime).toLocaleTimeString(this.lang, { hour: '2-digit', minute: '2-digit', hour12: false });
}
getFixedFloat(number) {
return parseFloat(number).toFixed(5);
}
_handleClick() {
fireEvent(this, 'hass-more-info', { entityId: this._config.entity });
}
getCardSize() {
return this.numberElements || 3;
}
static get styles() {
return css`
ha-card {
margin: auto;
padding-top: 1.3em;
padding-bottom: 1.3em;
padding-left: 1em;
padding-right: 1em;
position: relative;
}
ha-icon {
color: var(--paper-item-icon-color);
}
.spacer {
padding-top: 1em;
}
.clear {
clear: both;
}
.tappable {
cursor: pointer;
}
.current {
height: 5.5em;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
}
.period-icon {
padding-left: 16px;
padding-right: 16px;
width: 5.5em;
height: 5.5em;
}
.currentPrice {
font-weight: 300;
font-size: 4em;
color: var(--primary-text-color);
margin-top: 0.5em;
margin-right: 8px;
}
.currentPriceUnit {
font-weight: 300;
font-size: 1.5em;
vertical-align: super;
color: var(--primary-text-color);
right: 0em;
top: 0em;
position: absolute;
margin-right: 8px;
}
.details {
font-weight: 300;
color: var(--primary-text-color);
list-style: none;
padding-right: 1em;
padding-left: 1em;
}
.details li {
display: flex;
align-items: center;
justify-content: flex-start;
}
.details ha-icon {
height: 22px;
margin-right: 4px;
}
.info {
color: var(--primary-text-color);
text-align: center;
padding-right: 1em;
padding-left: 1em;
}
`;
}
ll(str) {
if (locale[this.lang] === undefined) return locale.en[str];
return locale[this.lang][str];
}
}
customElements.define('pvpc-hourly-pricing-card', PVPCHourlyPricingCard);