mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-09 18:36: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;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnergySankeyCardConfig extends EnergyCardBaseConfig {
|
||||||
|
type: "energy-sankey";
|
||||||
|
title?: string;
|
||||||
|
layout?: "vertical" | "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntityFilterCardConfig extends LovelaceCardConfig {
|
export interface EntityFilterCardConfig extends LovelaceCardConfig {
|
||||||
type: "entity-filter";
|
type: "entity-filter";
|
||||||
entities: Array<EntityFilterEntityConfig | string>;
|
entities: Array<EntityFilterEntityConfig | string>;
|
||||||
|
@ -65,6 +65,7 @@ const LAZY_LOAD_TYPES = {
|
|||||||
import("../cards/energy/hui-energy-sources-table-card"),
|
import("../cards/energy/hui-energy-sources-table-card"),
|
||||||
"energy-usage-graph": () =>
|
"energy-usage-graph": () =>
|
||||||
import("../cards/energy/hui-energy-usage-graph-card"),
|
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"),
|
"entity-filter": () => import("../cards/hui-entity-filter-card"),
|
||||||
error: () => import("../cards/hui-error-card"),
|
error: () => import("../cards/hui-error-card"),
|
||||||
gauge: () => import("../cards/hui-gauge-card"),
|
gauge: () => import("../cards/hui-gauge-card"),
|
||||||
|
@ -7708,7 +7708,8 @@
|
|||||||
"energy_distribution_title": "Energy distribution",
|
"energy_distribution_title": "Energy distribution",
|
||||||
"energy_sources_table_title": "Sources",
|
"energy_sources_table_title": "Sources",
|
||||||
"energy_devices_graph_title": "Individual devices total usage",
|
"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": {
|
"history": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user