From 4b7acbb766701825a1920901a97fc551daf03576 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Sun, 22 Dec 2024 18:25:13 +0200 Subject: [PATCH] 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 --- src/components/chart/sankey-chart.ts | 544 ++++++++++++++++++ .../cards/energy/hui-energy-sankey-card.ts | 441 ++++++++++++++ src/panels/lovelace/cards/types.ts | 6 + .../create-element/create-card-element.ts | 1 + src/translations/en.json | 3 +- 5 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 src/components/chart/sankey-chart.ts create mode 100644 src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts 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` + + + ${paths.map( + (path, i) => svg` + + + + + ` + )} + + ${paths.map( + (path, i) => + svg` + + ` + )} + ${nodes.map((node) => + node.passThrough + ? nothing + : svg` + + + ${node.tooltip} + + ${ + this.vertical + ? nothing + : svg` + ${node.label} + ` + } + + ` + )} + + ${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": {