From 2a803e09a4e200b23266abbc146a0270791a107e Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 20 Feb 2024 08:14:04 -0500 Subject: [PATCH] Detail view for energy devices graph (#19068) * Detail view for energy devices graph * Use getCommonOptions * Remove visibility toggle on horizontal bar chart * make a new card * unneeded translations * graph titles * Update src/translations/en.json Co-authored-by: Paul Bottein --------- Co-authored-by: Paul Bottein --- src/components/chart/ha-chart-base.ts | 21 +- .../energy/strategies/energy-view-strategy.ts | 7 + .../hui-energy-devices-detail-graph-card.ts | 412 ++++++++++++++++++ src/panels/lovelace/cards/types.ts | 7 + .../create-element/create-card-element.ts | 2 + src/translations/en.json | 3 +- 6 files changed, 447 insertions(+), 5 deletions(-) create mode 100644 src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index edccd2d22cbd..7bf2acc7392c 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -75,6 +75,8 @@ export class HaChartBase extends LitElement { private _paddingYAxisInternal = 0; + private _datasetOrder: number[] = []; + public disconnectedCallback() { super.disconnectedCallback(); this._releaseCanvas(); @@ -165,7 +167,17 @@ export class HaChartBase extends LitElement { } } + // put the legend labels in sorted order if provided if (changedProps.has("data")) { + this._datasetOrder = this.data.datasets.map((_, index) => index); + if (this.data?.datasets.some((dataset) => dataset.order)) { + this._datasetOrder.sort( + (a, b) => + (this.data.datasets[a].order || 0) - + (this.data.datasets[b].order || 0) + ); + } + if (this.externalHidden) { this._hiddenDatasets = new Set(); if (this.data?.datasets) { @@ -205,8 +217,9 @@ export class HaChartBase extends LitElement { ${this.options?.plugins?.legend?.display === true ? html`
    - ${this.data.datasets.map((dataset, index) => - this.extraData?.[index]?.show_legend === false + ${this._datasetOrder.map((index) => { + const dataset = this.data.datasets[index]; + return this.extraData?.[index]?.show_legend === false ? nothing : html`
  • -
  • ` - )} + `; + })}
` : ""} diff --git a/src/panels/energy/strategies/energy-view-strategy.ts b/src/panels/energy/strategies/energy-view-strategy.ts index 27a0a9137184..d41ea668ba59 100644 --- a/src/panels/energy/strategies/energy-view-strategy.ts +++ b/src/panels/energy/strategies/energy-view-strategy.ts @@ -154,6 +154,13 @@ export class EnergyViewStrategy extends ReactiveElement { // Only include if we have at least 1 device in the config. if (prefs.device_consumption.length) { + view.cards!.push({ + title: hass.localize( + "ui.panel.energy.cards.energy_devices_detail_graph_title" + ), + type: "energy-devices-detail-graph", + collection_key: "energy_dashboard", + }); view.cards!.push({ title: hass.localize( "ui.panel.energy.cards.energy_devices_graph_title" diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts new file mode 100644 index 000000000000..c13f2dac8eb6 --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts @@ -0,0 +1,412 @@ +import { + ChartData, + ChartDataset, + ChartOptions, + ScatterDataPoint, +} from "chart.js"; +import { differenceInDays, endOfToday, startOfToday } from "date-fns/esm"; +import { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { getColorByIndex } from "../../../../common/color/colors"; +import { ChartDatasetExtra } from "../../../../components/chart/ha-chart-base"; +import "../../../../components/ha-card"; +import { + DeviceConsumptionEnergyPreference, + EnergyData, + getEnergyDataCollection, +} from "../../../../data/energy"; +import { + calculateStatisticSumGrowth, + fetchStatistics, + getStatisticLabel, + Statistics, + StatisticsMetaData, + StatisticsUnitConfiguration, +} from "../../../../data/recorder"; +import { FrontendLocaleData } from "../../../../data/translation"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../../../types"; +import { LovelaceCard } from "../../types"; +import { EnergyDevicesDetailGraphCardConfig } from "../types"; +import { hasConfigChanged } from "../../common/has-changed"; +import { getCommonOptions } from "./common/energy-chart-options"; + +@customElement("hui-energy-devices-detail-graph-card") +export class HuiEnergyDevicesDetailGraphCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergyDevicesDetailGraphCardConfig; + + @state() private _chartData: ChartData = { datasets: [] }; + + @state() private _chartDatasetExtra: ChartDatasetExtra[] = []; + + @state() private _data?: EnergyData; + + @state() private _statistics?: Statistics; + + @state() private _compareStatistics?: Statistics; + + @state() private _start = startOfToday(); + + @state() private _end = endOfToday(); + + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + + @state() private _unit?: string; + + @state() private _hiddenStats = new Set(); + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config?.collection_key, + }).subscribe(async (data) => { + this._data = data; + await this._getStatistics(this._data); + this._processStatistics(); + }), + ]; + } + + public getCardSize(): Promise | number { + return 3; + } + + public setConfig(config: EnergyDevicesDetailGraphCardConfig): void { + this._config = config; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return ( + hasConfigChanged(this, changedProps) || + changedProps.size > 1 || + !changedProps.has("hass") + ); + } + + protected willUpdate(changedProps: PropertyValues) { + if (changedProps.has("_hiddenStats") && this._statistics) { + this._processStatistics(); + } + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + ${this._config.title + ? html`

${this._config.title}

` + : ""} +
+ +
+
+ `; + } + + private _datasetHidden(ev) { + ev.stopPropagation(); + this._hiddenStats.add( + this._data!.prefs.device_consumption[ev.detail.index].stat_consumption + ); + this.requestUpdate("_hiddenStats"); + } + + private _datasetUnhidden(ev) { + ev.stopPropagation(); + this._hiddenStats.delete( + this._data!.prefs.device_consumption[ev.detail.index].stat_consumption + ); + this.requestUpdate("_hiddenStats"); + } + + private _createOptions = memoizeOne( + ( + start: Date, + end: Date, + locale: FrontendLocaleData, + config: HassConfig, + unit?: string, + compareStart?: Date, + compareEnd?: Date + ): ChartOptions => { + const commonOptions = getCommonOptions( + start, + end, + locale, + config, + unit, + compareStart, + compareEnd + ); + + const options: ChartOptions = { + ...commonOptions, + interaction: { + mode: "nearest", + }, + plugins: { + ...commonOptions.plugins!, + legend: { + display: true, + labels: { + usePointStyle: true, + }, + }, + }, + }; + return options; + } + ); + + private async _getStatistics(energyData: EnergyData): Promise { + const dayDifference = differenceInDays( + energyData.end || new Date(), + energyData.start + ); + + const devices = energyData.prefs.device_consumption.map( + (device) => device.stat_consumption + ); + + const period = + dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"; + + const lengthUnit = this.hass.config.unit_system.length || ""; + const units: StatisticsUnitConfiguration = { + energy: "kWh", + volume: lengthUnit === "km" ? "m³" : "ft³", + }; + this._unit = "kWh"; + + const statistics = await fetchStatistics( + this.hass, + energyData.start, + energyData.end, + devices, + period, + units, + ["change"] + ); + + let compareStatistics: Statistics | undefined; + + if (energyData.startCompare && energyData.endCompare) { + compareStatistics = await fetchStatistics( + this.hass, + energyData.startCompare, + energyData.endCompare, + devices, + period, + units, + ["change"] + ); + } + this._statistics = statistics; + this._compareStatistics = compareStatistics; + } + + private async _processStatistics() { + const energyData = this._data!; + const data = this._statistics!; + const compareData = this._compareStatistics; + + const growthValues = {}; + energyData.prefs.device_consumption.forEach((device) => { + const value = + device.stat_consumption in data + ? calculateStatisticSumGrowth(data[device.stat_consumption]) || 0 + : 0; + + growthValues[device.stat_consumption] = value; + }); + + const sorted_devices = energyData.prefs.device_consumption.map( + (device) => device.stat_consumption + ); + sorted_devices.sort((a, b) => growthValues[b] - growthValues[a]); + + const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const datasetExtras: ChartDatasetExtra[] = []; + + datasets.push( + ...this._processDataSet( + data, + energyData.statsMetadata, + energyData.prefs.device_consumption, + sorted_devices + ) + ); + + const items = datasets.length; + datasetExtras.push(...Array(items).fill({})); + + if (compareData) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasetExtras.push({ + show_legend: false, + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + datasetExtras.push({ + show_legend: false, + }); + + datasets.push( + ...this._processDataSet( + compareData, + energyData.statsMetadata, + energyData.prefs.device_consumption, + sorted_devices, + true + ) + ); + datasetExtras.push( + ...Array(items).fill({ show_legend: false }) + ); + } + + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + this._chartData = { + datasets, + }; + this._chartDatasetExtra = datasetExtras; + } + + private _processDataSet( + statistics: Statistics, + statisticsMetaData: Record, + devices: DeviceConsumptionEnergyPreference[], + sorted_devices: string[], + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + + devices.forEach((source, idx) => { + const color = getColorByIndex(idx); + + let prevStart: number | null = null; + + const consumptionData: ScatterDataPoint[] = []; + + // Process gas consumption data. + if (source.stat_consumption in statistics) { + const stats = statistics[source.stat_consumption]; + let end; + + for (const point of stats) { + if (point.change === null || point.change === undefined) { + continue; + } + if (prevStart === point.start) { + continue; + } + const date = new Date(point.start); + consumptionData.push({ + x: date.getTime(), + y: point.change, + }); + prevStart = point.start; + end = point.end; + } + if (consumptionData.length === 1) { + consumptionData.push({ + x: end, + y: 0, + }); + } + } + + data.push({ + label: getStatisticLabel( + this.hass, + source.stat_consumption, + statisticsMetaData[source.stat_consumption] + ), + hidden: this._hiddenStats.has(source.stat_consumption), + borderColor: compare ? color + "7F" : color, + backgroundColor: compare ? color + "32" : color + "7F", + data: consumptionData, + order: 1 + sorted_devices.indexOf(source.stat_consumption), + stack: "devices", + pointStyle: compare ? false : "circle", + xAxisID: compare ? "xAxisCompare" : undefined, + }); + }); + return data; + } + + static get styles(): CSSResultGroup { + return css` + .card-header { + padding-bottom: 0; + } + .content { + padding: 16px; + } + .has-header { + padding-top: 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-devices-detail-graph-card": HuiEnergyDevicesDetailGraphCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index d9a26be158b7..f87dde26cd4a 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -159,6 +159,13 @@ export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig { max_devices?: number; } +export interface EnergyDevicesDetailGraphCardConfig extends LovelaceCardConfig { + type: "energy-devices-detail-graph"; + title?: string; + collection_key?: string; + max_devices?: number; +} + export interface EnergySourcesTableCardConfig extends LovelaceCardConfig { type: "energy-sources-table"; title?: string; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 1884c79cc18c..3619a13c8a0e 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -44,6 +44,8 @@ const LAZY_LOAD_TYPES = { import("../cards/energy/hui-energy-date-selection-card"), "energy-devices-graph": () => import("../cards/energy/hui-energy-devices-graph-card"), + "energy-devices-detail-graph": () => + import("../cards/energy/hui-energy-devices-detail-graph-card"), "energy-distribution": () => import("../cards/energy/hui-energy-distribution-card"), "energy-gas-graph": () => import("../cards/energy/hui-energy-gas-graph-card"), diff --git a/src/translations/en.json b/src/translations/en.json index d0cda35a1675..1195ceb346c7 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6535,7 +6535,8 @@ "energy_water_graph_title": "Water consumption", "energy_distribution_title": "Energy distribution", "energy_sources_table_title": "Sources", - "energy_devices_graph_title": "Monitor individual devices" + "energy_devices_graph_title": "Individual devices total usage", + "energy_devices_detail_graph_title": "Individual devices detail usage" } }, "history": {