diff --git a/src/components/chart/sankey-chart.ts b/src/components/chart/sankey-chart.ts
new file mode 100644
index 0000000000..46a6b5980b
--- /dev/null
+++ b/src/components/chart/sankey-chart.ts
@@ -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`
+
+ ${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`
+ ${node.label}
+
`;
+ })
+ : 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();
+ const accountedOut = new Map();
+ 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 = {};
+ 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;
+ }
+}
diff --git a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts
new file mode 100644
index 0000000000..a80f4b480d
--- /dev/null
+++ b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts
@@ -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 {
+ 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 = {
+ no_area: {
+ value: 0,
+ devices: [],
+ },
+ };
+ const floors: Record = {
+ 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`
+
+
+ ${hasData
+ ? html``
+ : html`${this.hass.localize(
+ "ui.panel.lovelace.cards.energy.no_data_period"
+ )}`}
+
+
+ `;
+ }
+
+ 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;
+ }
+}
diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts
index 8dcaea0dd1..7045e90aa6 100644
--- a/src/panels/lovelace/cards/types.ts
+++ b/src/panels/lovelace/cards/types.ts
@@ -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;
diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts
index e11338cc0d..64ee2e0c53 100644
--- a/src/panels/lovelace/create-element/create-card-element.ts
+++ b/src/panels/lovelace/create-element/create-card-element.ts
@@ -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"),
diff --git a/src/translations/en.json b/src/translations/en.json
index 48c93626d2..ee8566aa46 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -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": {