diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts index 90b6f0361b0e..8962eed28a78 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts @@ -2,16 +2,22 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { mdiChartDonut, mdiChartBar } from "@mdi/js"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; -import type { BarSeriesOption } from "echarts/charts"; +import type { BarSeriesOption, PieSeriesOption } from "echarts/charts"; +import { PieChart } from "echarts/charts"; import type { ECElementEvent } from "echarts/types/dist/shared"; import { filterXSS } from "../../../../common/util/xss"; import { getGraphColorByIndex } from "../../../../common/color/colors"; import { formatNumber } from "../../../../common/number/format_number"; import "../../../../components/chart/ha-chart-base"; import type { EnergyData } from "../../../../data/energy"; -import { getEnergyDataCollection } from "../../../../data/energy"; +import { + computeConsumptionData, + getEnergyDataCollection, + getSummedData, +} from "../../../../data/energy"; import { calculateStatisticSumGrowth, getStatisticLabel, @@ -26,6 +32,8 @@ import type { ECOption } from "../../../../resources/echarts"; import "../../../../components/ha-card"; import { fireEvent } from "../../../../common/dom/fire_event"; import { measureTextWidth } from "../../../../util/text"; +import "../../../../components/ha-icon-button"; +import { storage } from "../../../../common/decorators/storage"; @customElement("hui-energy-devices-graph-card") export class HuiEnergyDevicesGraphCard @@ -36,10 +44,20 @@ export class HuiEnergyDevicesGraphCard @state() private _config?: EnergyDevicesGraphCardConfig; - @state() private _chartData: BarSeriesOption[] = []; + @state() private _chartData: (BarSeriesOption | PieSeriesOption)[] = []; @state() private _data?: EnergyData; + @state() + @storage({ + key: "energy-devices-graph-chart-type", + state: true, + subscribe: false, + }) + private _chartType: "bar" | "pie" = "bar"; + + private _compoundStats: string[] = []; + protected hassSubscribeRequiredHostProps = ["_config"]; public hassSubscribe(): UnsubscribeFunc[] { @@ -76,9 +94,16 @@ export class HuiEnergyDevicesGraphCard return html` - ${this._config.title - ? html`

${this._config.title}

` - : ""} +
+ ${this._config.title ? this._config.title : nothing} + +
@@ -97,71 +123,86 @@ export class HuiEnergyDevicesGraphCard } private _renderTooltip(params: any) { - const deviceName = filterXSS(this._getDeviceName(params.value[1])); + const deviceName = filterXSS(this._getDeviceName(params.name)); const title = `

${deviceName}

`; const value = `${formatNumber( params.value[0] as number, this.hass.locale, - params.value[0] < 0.1 ? { maximumFractionDigits: 3 } : undefined + params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined )} kWh`; return `${title}${params.marker} ${params.seriesName}: ${value}`; } - private _createOptions = memoizeOne((data: BarSeriesOption[]): ECOption => { - const isMobile = window.matchMedia( - "all and (max-width: 450px), all and (max-height: 500px)" - ).matches; - return { - xAxis: { - type: "value", - name: "kWh", - }, - yAxis: { - type: "category", - inverse: true, - triggerEvent: true, - // take order from data - data: data[0]?.data?.map((d: any) => d.value[1]), - axisLabel: { - formatter: this._getDeviceName.bind(this), - overflow: "truncate", - fontSize: 12, - margin: 5, - width: Math.min( - isMobile ? 100 : 200, - Math.max( - ...(data[0]?.data?.map( - (d: any) => - measureTextWidth(this._getDeviceName(d.value[1]), 12) + 5 - ) || []) - ) - ), + private _createOptions = memoizeOne( + ( + data: (BarSeriesOption | PieSeriesOption)[], + chartType: "bar" | "pie" + ): ECOption => { + const options: ECOption = { + grid: { + top: 5, + left: 5, + right: 40, + bottom: 0, + containLabel: true, + }, + tooltip: { + show: true, + formatter: this._renderTooltip.bind(this), }, - }, - grid: { - top: 5, - left: 5, - right: 40, - bottom: 0, - containLabel: true, - }, - tooltip: { - show: true, - formatter: this._renderTooltip.bind(this), - }, - }; - }); + xAxis: { show: false }, + yAxis: { show: false }, + }; + if (chartType === "bar") { + const isMobile = window.matchMedia( + "all and (max-width: 450px), all and (max-height: 500px)" + ).matches; + options.xAxis = { + show: true, + type: "value", + name: "kWh", + }; + options.yAxis = { + show: true, + type: "category", + inverse: true, + triggerEvent: true, + // take order from data + data: data[0]?.data?.map((d: any) => d.name), + axisLabel: { + formatter: this._getDeviceName.bind(this), + overflow: "truncate", + fontSize: 12, + margin: 5, + width: Math.min( + isMobile ? 100 : 200, + Math.max( + ...(data[0]?.data?.map( + (d: any) => + measureTextWidth(this._getDeviceName(d.name), 12) + 5 + ) || []) + ) + ), + }, + }; + } + return options; + } + ); private _getDeviceName(statisticId: string): string { + const suffix = this._compoundStats.includes(statisticId) + ? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})` + : ""; return ( - this._data?.prefs.device_consumption.find( + (this._data?.prefs.device_consumption.find( (d) => d.stat_consumption === statisticId )?.name || - getStatisticLabel( - this.hass, - statisticId, - this._data?.statsMetadata[statisticId] - ) + getStatisticLabel( + this.hass, + statisticId, + this._data?.statsMetadata[statisticId] + )) + suffix ); } @@ -169,60 +210,105 @@ export class HuiEnergyDevicesGraphCard const data = energyData.stats; const compareData = energyData.statsCompare; - const chartData: NonNullable = []; - const chartDataCompare: NonNullable = []; + const chartData: NonNullable<(BarSeriesOption | PieSeriesOption)["data"]> = + []; + const chartDataCompare: NonNullable< + (BarSeriesOption | PieSeriesOption)["data"] + > = []; - const datasets: BarSeriesOption[] = [ + const datasets: (BarSeriesOption | PieSeriesOption)[] = [ { - type: "bar", + type: this._chartType, + radius: [compareData ? "50%" : "40%", "70%"], + universalTransition: true, name: this.hass.localize( "ui.panel.lovelace.cards.energy.energy_devices_graph.energy_usage" ), itemStyle: { - borderRadius: [0, 4, 4, 0], + borderRadius: this._chartType === "bar" ? [0, 4, 4, 0] : 4, }, data: chartData, barWidth: compareData ? 10 : 20, cursor: "default", - }, + minShowLabelAngle: 15, + label: + this._chartType === "pie" + ? { + formatter: ({ name }) => this._getDeviceName(name), + } + : undefined, + } as BarSeriesOption | PieSeriesOption, ]; if (compareData) { datasets.push({ - type: "bar", + type: this._chartType, + radius: ["30%", "50%"], + universalTransition: true, name: this.hass.localize( "ui.panel.lovelace.cards.energy.energy_devices_graph.previous_energy_usage" ), itemStyle: { - borderRadius: [0, 4, 4, 0], + borderRadius: this._chartType === "bar" ? [0, 4, 4, 0] : 4, }, data: chartDataCompare, barWidth: 10, cursor: "default", - }); + label: this._chartType === "pie" ? { show: false } : undefined, + emphasis: + this._chartType === "pie" + ? { + focus: "series", + blurScope: "global", + } + : undefined, + } as BarSeriesOption | PieSeriesOption); } const computedStyle = getComputedStyle(this); - const exclude = this._config?.hide_compound_stats - ? energyData.prefs.device_consumption - .map((d) => d.included_in_stat) - .filter(Boolean) - : []; + this._compoundStats = energyData.prefs.device_consumption + .map((d) => d.included_in_stat) + .filter(Boolean) as string[]; - energyData.prefs.device_consumption.forEach((device, id) => { - if (exclude.includes(device.stat_consumption)) { - return; - } - const value = + const devices = energyData.prefs.device_consumption; + const devicesTotals: Record = {}; + devices.forEach((device) => { + devicesTotals[device.stat_consumption] = device.stat_consumption in data ? calculateStatisticSumGrowth(data[device.stat_consumption]) || 0 : 0; - const color = getGraphColorByIndex(id, computedStyle); + }); + const devicesTotalsCompare: Record = {}; + if (compareData) { + devices.forEach((device) => { + devicesTotalsCompare[device.stat_consumption] = + device.stat_consumption in compareData + ? calculateStatisticSumGrowth( + compareData[device.stat_consumption] + ) || 0 + : 0; + }); + } + devices.forEach((device, idx) => { + let value = devicesTotals[device.stat_consumption]; + if (!this._config?.hide_compound_stats) { + const childSum = devices.reduce((acc, d) => { + if (d.included_in_stat === device.stat_consumption) { + return acc + devicesTotals[d.stat_consumption]; + } + return acc; + }, 0); + value -= Math.min(value, childSum); + } else if (this._compoundStats.includes(device.stat_consumption)) { + return; + } + const color = getGraphColorByIndex(idx, computedStyle); chartData.push({ - id, - value: [value, device.stat_consumption], + id: device.stat_consumption, + value: [value, device.stat_consumption] as any, + name: device.stat_consumption, itemStyle: { color: color + "7F", borderColor: color, @@ -230,16 +316,24 @@ export class HuiEnergyDevicesGraphCard }); if (compareData) { - const compareValue = + let compareValue = device.stat_consumption in compareData ? calculateStatisticSumGrowth( compareData[device.stat_consumption] ) || 0 : 0; + const compareChildSum = devices.reduce((acc, d) => { + if (d.included_in_stat === device.stat_consumption) { + return acc + devicesTotalsCompare[d.stat_consumption]; + } + return acc; + }, 0); + compareValue -= Math.min(compareValue, compareChildSum); chartDataCompare.push({ - id, - value: [compareValue, device.stat_consumption], + id: device.stat_consumption, + value: [compareValue, device.stat_consumption] as any, + name: device.stat_consumption, itemStyle: { color: color + "32", borderColor: color + "7F", @@ -249,11 +343,62 @@ export class HuiEnergyDevicesGraphCard }); chartData.sort((a: any, b: any) => b.value[0] - a.value[0]); + if (compareData) { + datasets[1].data = chartData.map((d) => + chartDataCompare.find((d2) => (d2 as any).id === d.id) + ) as typeof chartDataCompare; + } - chartData.length = Math.min( - this._config?.max_devices || Infinity, - chartData.length - ); + datasets.forEach((dataset) => { + dataset.data!.length = Math.min( + this._config?.max_devices || Infinity, + dataset.data!.length + ); + }); + + if (this._chartType === "pie") { + const { summedData } = getSummedData(energyData); + const { consumption } = computeConsumptionData(summedData); + const totalUsed = consumption.total.used_total; + const showUntracked = + "from_grid" in summedData || + "solar" in summedData || + "from_battery" in summedData; + const untracked = showUntracked + ? totalUsed - + chartData.reduce((acc: number, d: any) => acc + d.value[0], 0) + : 0; + datasets.push({ + type: "pie", + radius: ["0%", compareData ? "30%" : "40%"], + name: this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_devices_graph.total_energy_usage" + ), + data: [totalUsed], + label: { + show: true, + position: "center", + color: computedStyle.getPropertyValue("--secondary-text-color"), + fontSize: computedStyle.getPropertyValue("--ha-font-size-l"), + lineHeight: 24, + fontWeight: "bold", + formatter: `{a}\n${formatNumber(totalUsed, this.hass.locale)} kWh`, + }, + cursor: "default", + itemStyle: { + color: "rgba(0, 0, 0, 0)", + }, + tooltip: { + formatter: () => + untracked > 0 + ? this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_devices_graph.includes_untracked", + { num: formatNumber(untracked, this.hass.locale) } + ) + : "", + }, + }); + } this._chartData = datasets; await this.updateComplete; @@ -268,11 +413,26 @@ export class HuiEnergyDevicesGraphCard fireEvent(this, "hass-more-info", { entityId: e.detail.value as string, }); + } else if ( + e.detail.seriesType === "pie" && + e.detail.event?.target?.type === "tspan" // label + ) { + fireEvent(this, "hass-more-info", { + entityId: (e.detail.data as any).id as string, + }); } } + private _handleChartTypeChange(): void { + this._chartType = this._chartType === "pie" ? "bar" : "pie"; + this._getStatistics(this._data!); + } + static styles = css` .card-header { + display: flex; + justify-content: space-between; + align-items: center; padding-bottom: 0; } .content { @@ -284,6 +444,11 @@ export class HuiEnergyDevicesGraphCard ha-chart-base { --chart-max-height: none; } + ha-icon-button { + transform: rotate(90deg); + color: var(--secondary-text-color); + cursor: pointer; + } `; } diff --git a/src/translations/en.json b/src/translations/en.json index d49ff1e4f8ed..49bd3bd90725 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7029,7 +7029,11 @@ }, "energy_devices_graph": { "energy_usage": "Energy usage", - "previous_energy_usage": "Previous energy usage" + "previous_energy_usage": "Previous energy usage", + "total_energy_usage": "Total energy usage", + "change_chart_type": "Change chart type", + "untracked": "untracked", + "includes_untracked": "Includes {num} kWh of untracked energy" }, "energy_devices_detail_graph": { "untracked_consumption": "Untracked consumption",