mirror of
https://github.com/home-assistant/frontend.git
synced 2025-12-24 20:55:49 +00:00
Add pie chart mode to energy devices graph (#27282)
* Add pie chart mode to energy devices graph * universal transition * format * Add hide_compound_stats option to energy-devices-graph-card (#27263) * Add hide_compound_stats option to energy-devices-graph-card * Update src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts Co-authored-by: Bram Kragten <mail@bramkragten.nl> * format --------- Co-authored-by: Bram Kragten <mail@bramkragten.nl> * Save chart type in storage * show untracked compound energy and total energy * Update dependency lint-staged to v16.2.3 (#27285) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Update dependency @codemirror/view to v6.38.4 (#27288) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Add a sub-editor to hui-entity-editor (#27157) * Add a sub-editor to hui-entity-editor * item styling * fix compare order * handle label click in pie chart * order compare data based on current data * show untracked energy in tooltip * Apply suggestions from code review Co-authored-by: Bram Kragten <mail@bramkragten.nl> --------- Co-authored-by: Bram Kragten <mail@bramkragten.nl> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: karwosts <32912880+karwosts@users.noreply.github.com>
This commit is contained in:
@@ -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`
|
||||
<ha-card>
|
||||
${this._config.title
|
||||
? html`<h1 class="card-header">${this._config.title}</h1>`
|
||||
: ""}
|
||||
<div class="card-header">
|
||||
<span>${this._config.title ? this._config.title : nothing}</span>
|
||||
<ha-icon-button
|
||||
.path=${this._chartType === "pie" ? mdiChartBar : mdiChartDonut}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_graph.change_chart_type"
|
||||
)}
|
||||
@click=${this._handleChartTypeChange}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
"has-header": !!this._config.title,
|
||||
@@ -87,9 +112,10 @@ export class HuiEnergyDevicesGraphCard
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.data=${this._chartData}
|
||||
.options=${this._createOptions(this._chartData)}
|
||||
.height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`}
|
||||
.options=${this._createOptions(this._chartData, this._chartType)}
|
||||
.height=${`${Math.max(300, (this._chartData[0]?.data?.length || 0) * 28 + 50)}px`}
|
||||
@chart-click=${this._handleChartClick}
|
||||
.extraComponents=${[PieChart]}
|
||||
></ha-chart-base>
|
||||
</div>
|
||||
</ha-card>
|
||||
@@ -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 = `<h4 style="text-align: center; margin: 0;">${deviceName}</h4>`;
|
||||
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,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 5,
|
||||
left: 5,
|
||||
right: 40,
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: this._renderTooltip.bind(this),
|
||||
},
|
||||
};
|
||||
});
|
||||
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<BarSeriesOption["data"]> = [];
|
||||
const chartDataCompare: NonNullable<BarSeriesOption["data"]> = [];
|
||||
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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -7030,7 +7030,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",
|
||||
|
||||
Reference in New Issue
Block a user