diff --git a/src/components/chart/ha-sankey-chart.ts b/src/components/chart/ha-sankey-chart.ts index 00d0a96d6c..dded2b6b99 100644 --- a/src/components/chart/ha-sankey-chart.ts +++ b/src/components/chart/ha-sankey-chart.ts @@ -106,6 +106,10 @@ export class HaSankeyChart extends LitElement { private _createData = memoizeOne((data: SankeyChartData, width = 0) => { const filteredNodes = data.nodes.filter((n) => n.value > 0); const indexes = [...new Set(filteredNodes.map((n) => n.index))]; + const depthMap = new Map(); + indexes.sort().forEach((index, i) => { + depthMap.set(index, i); + }); const links = this._processLinks(filteredNodes, data.links); const sectionWidth = width / indexes.length; const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE; @@ -119,7 +123,7 @@ export class HaSankeyChart extends LitElement { itemStyle: { color: node.color, }, - depth: node.index, + depth: depthMap.get(node.index), })), links, draggable: false, diff --git a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts index 216dd3e08d..45118abe93 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts @@ -22,6 +22,12 @@ import "../../../../components/chart/ha-sankey-chart"; import type { Link, Node } from "../../../../components/chart/ha-sankey-chart"; import { getGraphColorByIndex } from "../../../../common/color/colors"; import { formatNumber } from "../../../../common/number/format_number"; +import { getEntityContext } from "../../../../common/entity/context/get_entity_context"; + +const DEFAULT_CONFIG: Partial = { + group_by_floor: true, + group_by_area: true, +}; @customElement("hui-energy-sankey-card") class HuiEnergySankeyCard @@ -37,7 +43,7 @@ class HuiEnergySankeyCard protected hassSubscribeRequiredHostProps = ["_config"]; public setConfig(config: EnergySankeyCardConfig): void { - this._config = config; + this._config = { ...DEFAULT_CONFIG, ...config }; } public hassSubscribe(): UnsubscribeFunc[] { @@ -219,22 +225,9 @@ class HuiEnergySankeyCard 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 deviceNodes: Node[] = []; prefs.device_consumption.forEach((device, idx) => { - const entity = this.hass.entities[device.stat_consumption]; const value = device.stat_consumption in this._data!.stats ? calculateStatisticSumGrowth( @@ -245,7 +238,7 @@ class HuiEnergySankeyCard return; } untrackedConsumption -= value; - const deviceNode: Node = { + deviceNodes.push({ id: device.stat_consumption, label: device.name || @@ -258,103 +251,85 @@ class HuiEnergySankeyCard 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, - color: computedStyle.getPropertyValue("--primary-color"), - }); - 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"; + const { group_by_area, group_by_floor } = this._config; + if (group_by_area || group_by_floor) { + const { areas, floors } = this._groupByFloorAndArea(deviceNodes); + + 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" || !group_by_floor) { + // link "no_floor" areas to home + floorNodeId = "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, + id: floorNodeId, + label: this.hass.floors[floorId].name, + value: floors[floorId].value, + tooltip: `${formatNumber(floors[floorId].value, this.hass.locale)} kWh`, + index: 2, color: computedStyle.getPropertyValue("--primary-color"), }); links.push({ - source: floorNodeId, - target: areaNodeId, - value: areas[areaId].value, + source: "home", + target: floorNodeId, }); } - areas[areaId].devices.forEach((device) => { - nodes.push(device); - links.push({ - source: areaNodeId, - target: device.id, - value: device.value, + floors[floorId].areas.forEach((areaId) => { + let targetNodeId: string; + + if (areaId === "no_area" || !group_by_area) { + // If group_by_area is false, link devices to floor or home + targetNodeId = floorNodeId; + } else { + // Create area node and link it to floor + const areaNodeId = `area_${areaId}`; + 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, + color: computedStyle.getPropertyValue("--primary-color"), + }); + links.push({ + source: floorNodeId, + target: areaNodeId, + value: areas[areaId].value, + }); + targetNodeId = areaNodeId; + } + + // Link devices to the appropriate target (area, floor, or home) + areas[areaId].devices.forEach((device) => { + nodes.push(device); + links.push({ + source: targetNodeId, + target: device.id, + value: device.value, + }); }); }); }); + } else { + deviceNodes.forEach((deviceNode) => { + nodes.push(deviceNode); + links.push({ + source: "home", + target: deviceNode.id, + value: deviceNode.value, + }); }); + } + // untracked consumption if (untrackedConsumption > 0) { nodes.push({ @@ -400,6 +375,59 @@ class HuiEnergySankeyCard private _valueFormatter = (value: number) => `${formatNumber(value, this.hass.locale)} kWh`; + protected _groupByFloorAndArea(deviceNodes: Node[]) { + const areas: Record = { + no_area: { + value: 0, + devices: [], + }, + }; + const floors: Record = { + no_floor: { + value: 0, + areas: ["no_area"], + }, + }; + deviceNodes.forEach((deviceNode) => { + const entity = this.hass.states[deviceNode.id]; + const { area, floor } = getEntityContext(entity, this.hass); + if (area) { + 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 (floor) { + if (floor.floor_id in floors) { + floors[floor.floor_id].value += deviceNode.value; + if (!floors[floor.floor_id].areas.includes(area.area_id)) { + floors[floor.floor_id].areas.push(area.area_id); + } + } else { + floors[floor.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); + } + }); + return { areas, floors }; + } + static styles = css` :host { display: block; diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 0d02968c8a..fe506de3e6 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -201,6 +201,8 @@ export interface EnergySankeyCardConfig extends EnergyCardBaseConfig { type: "energy-sankey"; title?: string; layout?: "vertical" | "horizontal"; + group_by_floor?: boolean; + group_by_area?: boolean; } export interface EntityFilterCardConfig extends LovelaceCardConfig {