mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-09 02:16:35 +00:00
Energy Sankey chart card (#23002)
* WIP: sankey chart * basic sankey chart * dynamic size of last section * basic energy-sankey card * add floors, areas & passthrough * order by floor level, add colors & exess energy nodes * tweak nodes * add tooltips and better layout and responsiveness * WIP vertical layout * fix height when not in sections * handle labels in vertical mode * remove from energy dashboard for now * lint fix * PR comments * use ResizeController instead of ResizeObserver * look up device area * code clarity improvement
This commit is contained in:
parent
7900eb4054
commit
4b7acbb766
544
src/components/chart/sankey-chart.ts
Normal file
544
src/components/chart/sankey-chart.ts
Normal file
@ -0,0 +1,544 @@
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { LitElement, html, css, svg, nothing } from "lit";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export type Node = {
|
||||
id: string;
|
||||
value: number;
|
||||
index: number; // like z-index but for x/y
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
color?: string;
|
||||
passThrough?: boolean;
|
||||
};
|
||||
export type Link = { source: string; target: string; value?: number };
|
||||
|
||||
export type SankeyChartData = {
|
||||
nodes: Node[];
|
||||
links: Link[];
|
||||
};
|
||||
|
||||
type ProcessedNode = Node & {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ProcessedLink = Link & {
|
||||
value: number;
|
||||
offset: {
|
||||
source: number;
|
||||
target: number;
|
||||
};
|
||||
passThroughNodeIds: string[];
|
||||
};
|
||||
|
||||
type Section = {
|
||||
nodes: ProcessedNode[];
|
||||
offset: number;
|
||||
index: number;
|
||||
totalValue: number;
|
||||
statePerPixel: number;
|
||||
};
|
||||
|
||||
const MIN_SIZE = 3;
|
||||
const DEFAULT_COLOR = "var(--primary-color)";
|
||||
const NODE_WIDTH = 15;
|
||||
const FONT_SIZE = 12;
|
||||
const MIN_DISTANCE = FONT_SIZE / 2;
|
||||
|
||||
@customElement("sankey-chart")
|
||||
export class SankeyChart extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public data: SankeyChartData = {
|
||||
nodes: [],
|
||||
links: [],
|
||||
};
|
||||
|
||||
@property({ type: Boolean }) public vertical = false;
|
||||
|
||||
@property({ attribute: false }) public loadingText?: string;
|
||||
|
||||
private _statePerPixel = 0;
|
||||
|
||||
private _textMeasureCanvas?: HTMLCanvasElement;
|
||||
|
||||
private _sizeController = new ResizeController(this, {
|
||||
callback: (entries) => entries[0]?.contentRect,
|
||||
});
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._textMeasureCanvas = undefined;
|
||||
}
|
||||
|
||||
willUpdate() {
|
||||
this._statePerPixel = 0;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._sizeController.value) {
|
||||
return this.loadingText ?? nothing;
|
||||
}
|
||||
|
||||
const { width, height } = this._sizeController.value;
|
||||
const { nodes, paths } = this._processNodesAndPaths(
|
||||
this.data.nodes,
|
||||
this.data.links
|
||||
);
|
||||
|
||||
return html`
|
||||
<svg
|
||||
width=${width}
|
||||
height=${height}
|
||||
viewBox="0 0 ${width} ${height}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
${paths.map(
|
||||
(path, i) => svg`
|
||||
<linearGradient id="gradient${path.sourceNode.id}.${path.targetNode.id}.${i}" gradientTransform="${
|
||||
this.vertical ? "rotate(90)" : ""
|
||||
}">
|
||||
<stop offset="0%" stop-color="${path.sourceNode.color}"></stop>
|
||||
<stop offset="100%" stop-color="${path.targetNode.color}"></stop>
|
||||
</linearGradient>
|
||||
`
|
||||
)}
|
||||
</defs>
|
||||
${paths.map(
|
||||
(path, i) =>
|
||||
svg`
|
||||
<path d="${path.path.map(([cmd, x, y]) => `${cmd}${x},${y}`).join(" ")} Z"
|
||||
fill="url(#gradient${path.sourceNode.id}.${path.targetNode.id}.${i})" fill-opacity="0.4" />
|
||||
`
|
||||
)}
|
||||
${nodes.map((node) =>
|
||||
node.passThrough
|
||||
? nothing
|
||||
: svg`
|
||||
<g transform="translate(${node.x},${node.y})">
|
||||
<rect
|
||||
class="node"
|
||||
width=${this.vertical ? node.size : NODE_WIDTH}
|
||||
height=${this.vertical ? NODE_WIDTH : node.size}
|
||||
style="fill: ${node.color}"
|
||||
>
|
||||
<title>${node.tooltip}</title>
|
||||
</rect>
|
||||
${
|
||||
this.vertical
|
||||
? nothing
|
||||
: svg`
|
||||
<text
|
||||
class="node-label"
|
||||
x=${NODE_WIDTH + 5}
|
||||
y=${node.size / 2}
|
||||
text-anchor="start"
|
||||
dominant-baseline="middle"
|
||||
>${node.label}</text>
|
||||
`
|
||||
}
|
||||
</g>
|
||||
`
|
||||
)}
|
||||
</svg>
|
||||
${this.vertical
|
||||
? nodes.map((node) => {
|
||||
if (!node.label) {
|
||||
return nothing;
|
||||
}
|
||||
const labelWidth = MIN_DISTANCE + node.size;
|
||||
const fontSize = this._getVerticalLabelFontSize(
|
||||
node.label,
|
||||
labelWidth
|
||||
);
|
||||
return html`<div
|
||||
class="node-label vertical"
|
||||
style="
|
||||
left: ${node.x - MIN_DISTANCE / 2}px;
|
||||
top: ${node.y + NODE_WIDTH}px;
|
||||
width: ${labelWidth}px;
|
||||
height: ${FONT_SIZE * 3}px;
|
||||
font-size: ${fontSize}px;
|
||||
line-height: ${fontSize}px;
|
||||
"
|
||||
title=${node.label}
|
||||
>
|
||||
${node.label}
|
||||
</div>`;
|
||||
})
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _processNodesAndPaths = memoizeOne(
|
||||
(rawNodes: Node[], rawLinks: Link[]) => {
|
||||
const filteredNodes = rawNodes.filter((n) => n.value > 0);
|
||||
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
|
||||
const { links, passThroughNodes } = this._processLinks(
|
||||
filteredNodes,
|
||||
indexes,
|
||||
rawLinks
|
||||
);
|
||||
const nodes = this._processNodes(
|
||||
[...filteredNodes, ...passThroughNodes],
|
||||
indexes
|
||||
);
|
||||
const paths = this._processPaths(nodes, links);
|
||||
return { nodes, paths };
|
||||
}
|
||||
);
|
||||
|
||||
private _processLinks(nodes: Node[], indexes: number[], rawLinks: Link[]) {
|
||||
const accountedIn = new Map<string, number>();
|
||||
const accountedOut = new Map<string, number>();
|
||||
const links: ProcessedLink[] = [];
|
||||
const passThroughNodes: Node[] = [];
|
||||
rawLinks.forEach((link) => {
|
||||
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||
const targetNode = nodes.find((n) => n.id === link.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return;
|
||||
}
|
||||
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
|
||||
const targetAccounted = accountedIn.get(targetNode.id) || 0;
|
||||
|
||||
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
|
||||
const sourceRemaining = sourceNode.value - sourceAccounted;
|
||||
const targetRemaining = targetNode.value - targetAccounted;
|
||||
// ensure the value is not greater than the remaining capacity of the nodes
|
||||
const value = Math.min(
|
||||
link.value ?? sourceRemaining,
|
||||
sourceRemaining,
|
||||
targetRemaining
|
||||
);
|
||||
|
||||
accountedIn.set(targetNode.id, targetAccounted + value);
|
||||
accountedOut.set(sourceNode.id, sourceAccounted + value);
|
||||
|
||||
// handle links across sections
|
||||
const sourceIndex = indexes.findIndex((i) => i === sourceNode.index);
|
||||
const targetIndex = indexes.findIndex((i) => i === targetNode.index);
|
||||
const passThroughSections = indexes.slice(sourceIndex + 1, targetIndex);
|
||||
// create pass-through nodes to reserve space
|
||||
const passThroughNodeIds = passThroughSections.map((index) => {
|
||||
const node = {
|
||||
passThrough: true,
|
||||
id: `${sourceNode.id}-${targetNode.id}-${index}`,
|
||||
value,
|
||||
index,
|
||||
};
|
||||
passThroughNodes.push(node);
|
||||
return node.id;
|
||||
});
|
||||
|
||||
if (value > 0) {
|
||||
links.push({
|
||||
...link,
|
||||
value,
|
||||
offset: {
|
||||
source: sourceAccounted / (sourceNode.value || 1),
|
||||
target: targetAccounted / (targetNode.value || 1),
|
||||
},
|
||||
passThroughNodeIds,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { links, passThroughNodes };
|
||||
}
|
||||
|
||||
private _processNodes(filteredNodes: Node[], indexes: number[]) {
|
||||
// add MIN_DISTANCE as padding
|
||||
const sectionSize = this.vertical
|
||||
? this._sizeController.value!.width - MIN_DISTANCE * 2
|
||||
: this._sizeController.value!.height - MIN_DISTANCE * 2;
|
||||
|
||||
const nodesPerSection: Record<number, Node[]> = {};
|
||||
filteredNodes.forEach((node) => {
|
||||
if (!nodesPerSection[node.index]) {
|
||||
nodesPerSection[node.index] = [node];
|
||||
} else {
|
||||
nodesPerSection[node.index].push(node);
|
||||
}
|
||||
});
|
||||
|
||||
const sectionFlexSize = this._getSectionFlexSize(
|
||||
Object.values(nodesPerSection)
|
||||
);
|
||||
|
||||
const sections: Section[] = indexes.map((index, i) => {
|
||||
const nodes: ProcessedNode[] = nodesPerSection[index].map(
|
||||
(node: Node) => ({
|
||||
...node,
|
||||
color: node.color || DEFAULT_COLOR,
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 0,
|
||||
})
|
||||
);
|
||||
const availableSpace =
|
||||
sectionSize - (nodes.length * MIN_DISTANCE - MIN_DISTANCE);
|
||||
const totalValue = nodes.reduce(
|
||||
(acc: number, node: Node) => acc + node.value,
|
||||
0
|
||||
);
|
||||
const { nodes: sizedNodes, statePerPixel } = this._setNodeSizes(
|
||||
nodes,
|
||||
availableSpace,
|
||||
totalValue
|
||||
);
|
||||
return {
|
||||
nodes: sizedNodes,
|
||||
offset: sectionFlexSize * i,
|
||||
index,
|
||||
totalValue,
|
||||
statePerPixel,
|
||||
};
|
||||
});
|
||||
|
||||
sections.forEach((section) => {
|
||||
// calc sizes again with the best statePerPixel
|
||||
let totalSize = 0;
|
||||
if (section.statePerPixel !== this._statePerPixel) {
|
||||
section.nodes.forEach((node) => {
|
||||
const size = Math.max(
|
||||
MIN_SIZE,
|
||||
Math.floor(node.value / this._statePerPixel)
|
||||
);
|
||||
totalSize += size;
|
||||
node.size = size;
|
||||
});
|
||||
} else {
|
||||
totalSize = section.nodes.reduce((sum, b) => sum + b.size, 0);
|
||||
}
|
||||
// calc margin betwee boxes
|
||||
const emptySpace = sectionSize - totalSize;
|
||||
const spacerSize = emptySpace / (section.nodes.length - 1);
|
||||
|
||||
// account for MIN_DISTANCE padding and center single node sections
|
||||
let offset =
|
||||
section.nodes.length > 1 ? MIN_DISTANCE : emptySpace / 2 + MIN_DISTANCE;
|
||||
// calc positions - swap x/y for vertical layout
|
||||
section.nodes.forEach((node) => {
|
||||
if (this.vertical) {
|
||||
node.x = offset;
|
||||
node.y = section.offset;
|
||||
} else {
|
||||
node.x = section.offset;
|
||||
node.y = offset;
|
||||
}
|
||||
offset += node.size + spacerSize;
|
||||
});
|
||||
});
|
||||
|
||||
return sections.flatMap((section) => section.nodes);
|
||||
}
|
||||
|
||||
private _processPaths(nodes: ProcessedNode[], links: ProcessedLink[]) {
|
||||
const flowDirection = this.vertical ? "y" : "x";
|
||||
const orthDirection = this.vertical ? "x" : "y"; // orthogonal to the flow
|
||||
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||||
return links.map((link) => {
|
||||
const { source, target, value, offset, passThroughNodeIds } = link;
|
||||
const pathNodes = [source, ...passThroughNodeIds, target].map(
|
||||
(id) => nodesById.get(id)!
|
||||
);
|
||||
const offsets = [
|
||||
offset.source,
|
||||
...link.passThroughNodeIds.map(() => 0),
|
||||
offset.target,
|
||||
];
|
||||
|
||||
const sourceNode = pathNodes[0];
|
||||
const targetNode = pathNodes[pathNodes.length - 1];
|
||||
|
||||
let path: [string, number, number][] = [
|
||||
[
|
||||
"M",
|
||||
sourceNode[flowDirection] + NODE_WIDTH,
|
||||
sourceNode[orthDirection] + offset.source * sourceNode.size,
|
||||
],
|
||||
]; // starting point
|
||||
|
||||
// traverse the path forwards. stop before the last node
|
||||
for (let i = 0; i < pathNodes.length - 1; i++) {
|
||||
const node = pathNodes[i];
|
||||
const nextNode = pathNodes[i + 1];
|
||||
const flowMiddle =
|
||||
(nextNode[flowDirection] - node[flowDirection]) / 2 +
|
||||
node[flowDirection];
|
||||
const orthStart = node[orthDirection] + offsets[i] * node.size;
|
||||
const orthEnd =
|
||||
nextNode[orthDirection] + offsets[i + 1] * nextNode.size;
|
||||
path.push(
|
||||
["L", node[flowDirection] + NODE_WIDTH, orthStart],
|
||||
["C", flowMiddle, orthStart],
|
||||
["", flowMiddle, orthEnd],
|
||||
["", nextNode[flowDirection], orthEnd]
|
||||
);
|
||||
}
|
||||
// traverse the path backwards. stop before the first node
|
||||
for (let i = pathNodes.length - 1; i > 0; i--) {
|
||||
const node = pathNodes[i];
|
||||
const prevNode = pathNodes[i - 1];
|
||||
const flowMiddle =
|
||||
(node[flowDirection] - prevNode[flowDirection]) / 2 +
|
||||
prevNode[flowDirection];
|
||||
const orthStart =
|
||||
node[orthDirection] +
|
||||
offsets[i] * node.size +
|
||||
Math.max((value / (node.value || 1)) * node.size, 0);
|
||||
const orthEnd =
|
||||
prevNode[orthDirection] +
|
||||
offsets[i - 1] * prevNode.size +
|
||||
Math.max((value / (prevNode.value || 1)) * prevNode.size, 0);
|
||||
path.push(
|
||||
["L", node[flowDirection], orthStart],
|
||||
["C", flowMiddle, orthStart],
|
||||
["", flowMiddle, orthEnd],
|
||||
["", prevNode[flowDirection] + NODE_WIDTH, orthEnd]
|
||||
);
|
||||
}
|
||||
|
||||
if (this.vertical) {
|
||||
// Just swap x and y coordinates for vertical layout
|
||||
path = path.map((c) => [c[0], c[2], c[1]]);
|
||||
}
|
||||
return {
|
||||
sourceNode,
|
||||
targetNode,
|
||||
value,
|
||||
path,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _setNodeSizes(
|
||||
nodes: ProcessedNode[],
|
||||
availableSpace: number,
|
||||
totalValue: number
|
||||
): { nodes: ProcessedNode[]; statePerPixel: number } {
|
||||
const statePerPixel = totalValue / availableSpace;
|
||||
if (statePerPixel > this._statePerPixel) {
|
||||
this._statePerPixel = statePerPixel;
|
||||
}
|
||||
let deficitHeight = 0;
|
||||
const result = nodes.map((node) => {
|
||||
if (node.size === MIN_SIZE) {
|
||||
return node;
|
||||
}
|
||||
let size = Math.floor(node.value / this._statePerPixel);
|
||||
if (size < MIN_SIZE) {
|
||||
deficitHeight += MIN_SIZE - size;
|
||||
size = MIN_SIZE;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
size,
|
||||
};
|
||||
});
|
||||
if (deficitHeight > 0) {
|
||||
return this._setNodeSizes(
|
||||
result,
|
||||
availableSpace - deficitHeight,
|
||||
totalValue
|
||||
);
|
||||
}
|
||||
return { nodes: result, statePerPixel: this._statePerPixel };
|
||||
}
|
||||
|
||||
private _getSectionFlexSize(nodesPerSection: Node[][]): number {
|
||||
const fullSize = this.vertical
|
||||
? this._sizeController.value!.height
|
||||
: this._sizeController.value!.width;
|
||||
if (nodesPerSection.length < 2) {
|
||||
return fullSize;
|
||||
}
|
||||
let lastSectionFlexSize: number;
|
||||
if (this.vertical) {
|
||||
lastSectionFlexSize = FONT_SIZE * 2 + NODE_WIDTH; // estimated based on the font size + some margin
|
||||
} else {
|
||||
// Estimate the width needed for the last section based on label length
|
||||
const lastIndex = nodesPerSection.length - 1;
|
||||
const lastSectionNodes = nodesPerSection[lastIndex];
|
||||
const TEXT_PADDING = 5; // Padding between node and text
|
||||
lastSectionFlexSize =
|
||||
lastSectionNodes.length > 0
|
||||
? Math.max(
|
||||
...lastSectionNodes.map(
|
||||
(node) =>
|
||||
NODE_WIDTH +
|
||||
TEXT_PADDING +
|
||||
(node.label ? this._getTextWidth(node.label) : 0)
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
// Calculate the flex size for other sections
|
||||
const remainingSize = fullSize - lastSectionFlexSize;
|
||||
const flexSize = remainingSize / (nodesPerSection.length - 1);
|
||||
// if the last section is bigger than the others, we make them all the same size
|
||||
// this is to prevent the last section from squishing the others
|
||||
return lastSectionFlexSize < flexSize
|
||||
? flexSize
|
||||
: fullSize / nodesPerSection.length;
|
||||
}
|
||||
|
||||
private _getTextWidth(text: string): number {
|
||||
if (!this._textMeasureCanvas) {
|
||||
this._textMeasureCanvas = document.createElement("canvas");
|
||||
}
|
||||
const context = this._textMeasureCanvas.getContext("2d");
|
||||
if (!context) return 0;
|
||||
|
||||
// Match the font style from CSS
|
||||
context.font = `${FONT_SIZE}px sans-serif`;
|
||||
return context.measureText(text).width;
|
||||
}
|
||||
|
||||
private _getVerticalLabelFontSize(label: string, labelWidth: number): number {
|
||||
// reduce the label font size so the longest word fits on one line
|
||||
const longestWord = label
|
||||
.split(" ")
|
||||
.reduce(
|
||||
(longest, current) =>
|
||||
longest.length > current.length ? longest : current,
|
||||
""
|
||||
);
|
||||
const wordWidth = this._getTextWidth(longestWord);
|
||||
return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
background: var(--ha-card-background, var(--card-background-color, #000));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
svg {
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
}
|
||||
.node-label {
|
||||
font-size: ${FONT_SIZE}px;
|
||||
fill: var(--primary-text-color, white);
|
||||
}
|
||||
.node-label.vertical {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"sankey-chart": SankeyChart;
|
||||
}
|
||||
}
|
441
src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts
Normal file
441
src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts
Normal file
@ -0,0 +1,441 @@
|
||||
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 "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
energySourcesByType,
|
||||
getEnergyDataCollection,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
calculateStatisticsSumGrowth,
|
||||
calculateStatisticSumGrowth,
|
||||
getStatisticLabel,
|
||||
} from "../../../../data/recorder";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { EnergySankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
|
||||
@customElement("hui-energy-sankey-card")
|
||||
class HuiEnergySankeyCard
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements LovelaceCard
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config?: EnergySankeyCardConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public setConfig(config: EnergySankeyCardConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
getEnergyDataCollection(this.hass, {
|
||||
key: this._config?.collection_key,
|
||||
}).subscribe((data) => {
|
||||
this._data = data;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public getCardSize(): Promise<number> | number {
|
||||
return 5;
|
||||
}
|
||||
|
||||
getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 12,
|
||||
min_columns: 6,
|
||||
rows: 6,
|
||||
min_rows: 2,
|
||||
};
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
return changedProps.has("_config") || changedProps.has("_data");
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this._data) {
|
||||
return html`${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.loading"
|
||||
)}`;
|
||||
}
|
||||
|
||||
const prefs = this._data.prefs;
|
||||
const types = energySourcesByType(prefs);
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
|
||||
const homeNode: Node = {
|
||||
id: "home",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.home"
|
||||
),
|
||||
value: 0,
|
||||
color: "var(--primary-color)",
|
||||
index: 1,
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
if (types.grid) {
|
||||
const totalFromGrid =
|
||||
calculateStatisticsSumGrowth(
|
||||
this._data.stats,
|
||||
types.grid![0].flow_from.map((flow) => flow.stat_energy_from)
|
||||
) ?? 0;
|
||||
|
||||
nodes.push({
|
||||
id: "grid",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.grid"
|
||||
),
|
||||
value: totalFromGrid,
|
||||
tooltip: `${formatNumber(totalFromGrid, this.hass.locale)} kWh`,
|
||||
color: "var(--energy-grid-consumption-color)",
|
||||
index: 0,
|
||||
});
|
||||
|
||||
links.push({
|
||||
source: "grid",
|
||||
target: "home",
|
||||
});
|
||||
}
|
||||
|
||||
// Add battery source if available
|
||||
if (types.battery) {
|
||||
const totalBatteryOut =
|
||||
calculateStatisticsSumGrowth(
|
||||
this._data.stats,
|
||||
types.battery.map((source) => source.stat_energy_from)
|
||||
) || 0;
|
||||
|
||||
nodes.push({
|
||||
id: "battery",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
|
||||
),
|
||||
value: totalBatteryOut,
|
||||
tooltip: `${formatNumber(totalBatteryOut, this.hass.locale)} kWh`,
|
||||
color: "var(--energy-battery-out-color)",
|
||||
index: 0,
|
||||
});
|
||||
links.push({
|
||||
source: "battery",
|
||||
target: "home",
|
||||
});
|
||||
}
|
||||
|
||||
// Add solar if available
|
||||
if (types.solar) {
|
||||
const totalSolarProduction =
|
||||
calculateStatisticsSumGrowth(
|
||||
this._data.stats,
|
||||
types.solar.map((source) => source.stat_energy_from)
|
||||
) || 0;
|
||||
|
||||
nodes.push({
|
||||
id: "solar",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.solar"
|
||||
),
|
||||
value: totalSolarProduction,
|
||||
tooltip: `${formatNumber(totalSolarProduction, this.hass.locale)} kWh`,
|
||||
color: "var(--energy-solar-color)",
|
||||
index: 0,
|
||||
});
|
||||
|
||||
links.push({
|
||||
source: "solar",
|
||||
target: "home",
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total home consumption from all source nodes
|
||||
homeNode.value = nodes
|
||||
.filter((node) => node.index === 0)
|
||||
.reduce((sum, node) => sum + (node.value || 0), 0);
|
||||
|
||||
// Add battery sink if available
|
||||
if (types.battery) {
|
||||
const totalBatteryIn =
|
||||
calculateStatisticsSumGrowth(
|
||||
this._data.stats,
|
||||
types.battery.map((source) => source.stat_energy_to)
|
||||
) || 0;
|
||||
|
||||
nodes.push({
|
||||
id: "battery_in",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
|
||||
),
|
||||
value: totalBatteryIn,
|
||||
tooltip: `${formatNumber(totalBatteryIn, this.hass.locale)} kWh`,
|
||||
color: "var(--energy-battery-in-color)",
|
||||
index: 1,
|
||||
});
|
||||
nodes.forEach((node) => {
|
||||
// Link all sources to battery_in
|
||||
if (node.index === 0) {
|
||||
links.push({
|
||||
source: node.id,
|
||||
target: "battery_in",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
homeNode.value -= totalBatteryIn;
|
||||
}
|
||||
|
||||
// Add grid return if available
|
||||
if (types.grid && types.grid[0].flow_to) {
|
||||
const totalToGrid =
|
||||
calculateStatisticsSumGrowth(
|
||||
this._data.stats,
|
||||
types.grid[0].flow_to.map((flow) => flow.stat_energy_to)
|
||||
) ?? 0;
|
||||
|
||||
nodes.push({
|
||||
id: "grid_return",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.grid"
|
||||
),
|
||||
value: totalToGrid,
|
||||
tooltip: `${formatNumber(totalToGrid, this.hass.locale)} kWh`,
|
||||
color: "var(--energy-grid-return-color)",
|
||||
index: 1,
|
||||
});
|
||||
nodes.forEach((node) => {
|
||||
// Link all non-grid sources to grid_return
|
||||
if (node.index === 0 && node.id !== "grid") {
|
||||
links.push({
|
||||
source: node.id,
|
||||
target: "grid_return",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
homeNode.value -= totalToGrid;
|
||||
}
|
||||
|
||||
// Group devices by areas and floors
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const computedStyle = getComputedStyle(this);
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
const entity = this.hass.entities[device.stat_consumption];
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
untrackedConsumption -= value;
|
||||
const deviceNode: Node = {
|
||||
id: device.stat_consumption,
|
||||
label:
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this._data!.statsMetadata[device.stat_consumption]
|
||||
),
|
||||
value,
|
||||
tooltip: `${formatNumber(value, this.hass.locale)} kWh`,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
const entityAreaId =
|
||||
entity?.area_id ??
|
||||
(entity.device_id && this.hass.devices[entity.device_id]?.area_id);
|
||||
if (entityAreaId && entityAreaId in this.hass.areas) {
|
||||
const area = this.hass.areas[entityAreaId];
|
||||
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (area.floor_id && area.floor_id in this.hass.floors) {
|
||||
if (area.floor_id in floors) {
|
||||
floors[area.floor_id].value += deviceNode.value;
|
||||
if (!floors[area.floor_id].areas.includes(area.area_id)) {
|
||||
floors[area.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[area.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor") {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
tooltip: `${formatNumber(floors[floorId].value, this.hass.locale)} kWh`,
|
||||
index: 2,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let areaNodeId = `area_${areaId}`;
|
||||
if (areaId === "no_area") {
|
||||
// link "no_area" devices to home
|
||||
areaNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]!.name,
|
||||
value: areas[areaId].value,
|
||||
tooltip: `${formatNumber(areas[areaId].value, this.hass.locale)} kWh`,
|
||||
index: 3,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
}
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
nodes.push(device);
|
||||
links.push({
|
||||
source: areaNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > 0) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
tooltip: `${formatNumber(untrackedConsumption, this.hass.locale)} kWh`,
|
||||
color: "var(--state-unavailable-color)",
|
||||
index: 4,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
} else if (untrackedConsumption < 0) {
|
||||
// if untracked consumption is negative, then the sources are not enough
|
||||
homeNode.value -= untrackedConsumption;
|
||||
}
|
||||
homeNode.tooltip = `${formatNumber(homeNode.value, this.hass.locale)} kWh`;
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
return html`
|
||||
<ha-card .header=${this._config.title}>
|
||||
<div class="card-content">
|
||||
${hasData
|
||||
? html`<sankey-chart
|
||||
.data=${{ nodes, links }}
|
||||
.vertical=${this._config.layout === "vertical"}
|
||||
.loadingText=${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.loading"
|
||||
)}
|
||||
></sankey-chart>`
|
||||
: html`${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.no_data_period"
|
||||
)}`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: calc(
|
||||
var(--row-size, 8) *
|
||||
(var(--row-height, 50px) + var(--row-gap, 0px)) - var(--row-gap, 0px)
|
||||
);
|
||||
}
|
||||
ha-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-energy-sankey-card": HuiEnergySankeyCard;
|
||||
}
|
||||
}
|
@ -196,6 +196,12 @@ export interface EnergyCarbonGaugeCardConfig extends EnergyCardBaseConfig {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface EnergySankeyCardConfig extends EnergyCardBaseConfig {
|
||||
type: "energy-sankey";
|
||||
title?: string;
|
||||
layout?: "vertical" | "horizontal";
|
||||
}
|
||||
|
||||
export interface EntityFilterCardConfig extends LovelaceCardConfig {
|
||||
type: "entity-filter";
|
||||
entities: Array<EntityFilterEntityConfig | string>;
|
||||
|
@ -65,6 +65,7 @@ const LAZY_LOAD_TYPES = {
|
||||
import("../cards/energy/hui-energy-sources-table-card"),
|
||||
"energy-usage-graph": () =>
|
||||
import("../cards/energy/hui-energy-usage-graph-card"),
|
||||
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
|
||||
"entity-filter": () => import("../cards/hui-entity-filter-card"),
|
||||
error: () => import("../cards/hui-error-card"),
|
||||
gauge: () => import("../cards/hui-gauge-card"),
|
||||
|
@ -7708,7 +7708,8 @@
|
||||
"energy_distribution_title": "Energy distribution",
|
||||
"energy_sources_table_title": "Sources",
|
||||
"energy_devices_graph_title": "Individual devices total usage",
|
||||
"energy_devices_detail_graph_title": "Individual devices detail usage"
|
||||
"energy_devices_detail_graph_title": "Individual devices detail usage",
|
||||
"energy_sankey_title": "Energy flow"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user