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 <paul.bottein@gmail.com>

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
karwosts 2024-02-20 08:14:04 -05:00 committed by GitHub
parent 50cf6d2af9
commit 2a803e09a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 447 additions and 5 deletions

View File

@ -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`<div class="chartLegend">
<ul>
${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`<li
.datasetIndex=${index}
@ -228,8 +241,8 @@ export class HaChartBase extends LitElement {
${this.extraData?.[index]?.legend_label ??
dataset.label}
</div>
</li>`
)}
</li>`;
})}
</ul>
</div>`
: ""}

View File

@ -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"

View File

@ -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<string>();
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> | 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`
<ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div
class="content ${classMap({
"has-header": !!this._config.title,
})}"
>
<ha-chart-base
externalHidden
.hass=${this.hass}
.data=${this._chartData}
.extraData=${this._chartDatasetExtra}
.options=${this._createOptions(
this._start,
this._end,
this.hass.locale,
this.hass.config,
this._unit,
this._compareStart,
this._compareEnd
)}
chart-type="bar"
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
</div>
</ha-card>
`;
}
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<void> {
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<ChartDatasetExtra>(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<ChartDatasetExtra>(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<string, StatisticsMetaData>,
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;
}
}

View File

@ -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;

View File

@ -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"),

View File

@ -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": {