diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 2b4fb39394..6f6f8a40aa 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -47,6 +47,8 @@ export class HaChartBase extends LitElement { @property({ attribute: "expand-legend", type: Boolean }) public expandLegend?: boolean; + @property({ attribute: false }) public extraComponents?: any[]; + @state() @consume({ context: themesContext, subscribe: true }) _themes!: Themes; @@ -271,6 +273,10 @@ export class HaChartBase extends LitElement { } const echarts = (await import("../../resources/echarts")).default; + if (this.extraComponents?.length) { + echarts.use(this.extraComponents); + } + echarts.registerTheme("custom", this._createTheme()); this.chart = echarts.init(container, "custom"); @@ -298,11 +304,13 @@ export class HaChartBase extends LitElement { } private _getDataZoomConfig(): DataZoomComponentOption | undefined { - const xAxis = (this.options?.xAxis?.[0] ?? - this.options?.xAxis) as XAXisOption; - const yAxis = (this.options?.yAxis?.[0] ?? - this.options?.yAxis) as YAXisOption; - if (xAxis.type === "value" && yAxis.type === "category") { + const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as + | XAXisOption + | undefined; + const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as + | YAXisOption + | undefined; + if (xAxis?.type === "value" && yAxis?.type === "category") { // vertical data zoom doesn't work well in this case and horizontal is pointless return undefined; } diff --git a/src/components/chart/ha-sankey-chart.ts b/src/components/chart/ha-sankey-chart.ts index 375c7bc5c1..00d0a96d6c 100644 --- a/src/components/chart/ha-sankey-chart.ts +++ b/src/components/chart/ha-sankey-chart.ts @@ -1,9 +1,17 @@ -import { customElement, property } from "lit/decorators"; -import { LitElement, html, css, svg, nothing } from "lit"; -import { ResizeController } from "@lit-labs/observers/resize-controller"; +import { customElement, property, state } from "lit/decorators"; +import { LitElement, html, css } from "lit"; +import type { EChartsType } from "echarts/core"; +import type { CallbackDataParams } from "echarts/types/dist/shared"; +import type { SankeySeriesOption } from "echarts/types/dist/echarts"; +import { SankeyChart } from "echarts/charts"; import memoizeOne from "memoize-one"; +import { ResizeController } from "@lit-labs/observers/resize-controller"; import type { HomeAssistant } from "../../types"; +import type { ECOption } from "../../resources/echarts"; import { measureTextWidth } from "../../util/text"; +import "./ha-chart-base"; +import { NODE_SIZE } from "../trace/hat-graph-const"; +import "../ha-alert"; export interface Node { id: string; @@ -25,34 +33,14 @@ export interface SankeyChartData { links: Link[]; } -type ProcessedNode = Node & { - x: number; - y: number; - size: number; -}; - type ProcessedLink = Link & { value: number; - offset: { - source: number; - target: number; - }; - passThroughNodeIds: string[]; }; -interface 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 OVERFLOW_MARGIN = 5; const FONT_SIZE = 12; -const MIN_DISTANCE = FONT_SIZE / 2; +const NODE_GAP = 8; +const LABEL_DISTANCE = 5; @customElement("ha-sankey-chart") export class HaSankeyChart extends LitElement { @@ -65,141 +53,144 @@ export class HaSankeyChart extends LitElement { @property({ type: Boolean }) public vertical = false; - @property({ attribute: false }) public loadingText?: string; + @property({ type: String, attribute: false }) public valueFormatter?: ( + value: number + ) => string; - private _statePerPixel = 0; + public chart?: EChartsType; - private _sizeController = new ResizeController(this, { + @state() private _sizeController = new ResizeController(this, { callback: (entries) => entries[0]?.contentRect, }); - disconnectedCallback() { - super.disconnectedCallback(); - } - - willUpdate() { - this._statePerPixel = 0; - } - render() { - if (!this._sizeController.value) { - return this.loadingText ?? nothing; - } + const options = { + grid: { + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + tooltip: { + trigger: "item", + formatter: this._renderTooltip, + appendTo: document.body, + }, + } as ECOption; - 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} - `; + return html``; } - 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 _renderTooltip = (params: CallbackDataParams) => { + const data = params.data as Record; + const value = this.valueFormatter + ? this.valueFormatter(data.value) + : data.value; + if (data.id) { + const node = this.data.nodes.find((n) => n.id === data.id); + return `${params.marker} ${node?.label ?? data.id}
${value}`; } - ); + if (data.source && data.target) { + const source = this.data.nodes.find((n) => n.id === data.source); + const target = this.data.nodes.find((n) => n.id === data.target); + return `${source?.label ?? data.source} → ${target?.label ?? data.target}
${value}`; + } + return null; + }; - private _processLinks(nodes: Node[], indexes: number[], rawLinks: Link[]) { + 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 links = this._processLinks(filteredNodes, data.links); + const sectionWidth = width / indexes.length; + const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE; + + return { + id: "sankey", + type: "sankey", + nodes: filteredNodes.map((node) => ({ + id: node.id, + value: node.value, + itemStyle: { + color: node.color, + }, + depth: node.index, + })), + links, + draggable: false, + orient: this.vertical ? "vertical" : "horizontal", + nodeWidth: 15, + nodeGap: NODE_GAP, + lineStyle: { + color: "gradient", + opacity: 0.4, + }, + layoutIterations: 0, + label: { + formatter: (params) => + data.nodes.find((node) => node.id === (params.data as Node).id) + ?.label ?? (params.data as Node).id, + position: this.vertical ? "bottom" : "right", + distance: LABEL_DISTANCE, + minMargin: 5, + overflow: "break", + }, + labelLayout: (params) => { + if (this.vertical) { + // reduce the label font size so the longest word fits on one line + const longestWord = params.text + .split(" ") + .reduce( + (longest, current) => + longest.length > current.length ? longest : current, + "" + ); + const wordWidth = measureTextWidth(longestWord, FONT_SIZE); + const fontSize = Math.min( + FONT_SIZE, + (params.rect.width / wordWidth) * FONT_SIZE + ); + return { + fontSize: fontSize > 1 ? fontSize : 0, + width: params.rect.width, + align: "center", + }; + } + + // estimate the number of lines after the label is wrapped + // this is a very rough estimate, but it works for now + const lineCount = Math.ceil(params.labelRect.width / labelSpace); + // `overflow: "break"` allows the label to overflow outside its height, so we need to account for that + const fontSize = Math.min( + (params.rect.height / lineCount) * FONT_SIZE, + FONT_SIZE + ); + return { + fontSize, + lineHeight: fontSize, + width: labelSpace, + height: params.rect.height, + }; + }, + top: this.vertical ? 0 : OVERFLOW_MARGIN, + bottom: this.vertical ? 25 : OVERFLOW_MARGIN, + left: this.vertical ? OVERFLOW_MARGIN : 0, + right: this.vertical ? OVERFLOW_MARGIN : labelSpace + LABEL_DISTANCE, + emphasis: { + focus: "adjacency", + }, + } as SankeySeriesOption; + }); + + private _processLinks(nodes: Node[], 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); @@ -222,307 +213,25 @@ export class HaSankeyChart extends LitElement { 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 between 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 ? measureTextWidth(node.label, FONT_SIZE) : 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 _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 = measureTextWidth(longestWord, FONT_SIZE); - return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE); + return links; } static styles = css` :host { display: block; flex: 1; - background: var(--ha-card-background, var(--card-background-color, #000)); - overflow: hidden; - position: relative; + background: var(--ha-card-background, var(--card-background-color)); } - 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; + ha-chart-base { + width: 100%; + height: 100%; } `; } 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 d5630916ef..0314dd1110 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts @@ -81,6 +81,8 @@ class HuiEnergySankeyCard const prefs = this._data.prefs; const types = energySourcesByType(prefs); + const computedStyle = getComputedStyle(this); + const nodes: Node[] = []; const links: Link[] = []; @@ -90,7 +92,7 @@ class HuiEnergySankeyCard "ui.panel.lovelace.cards.energy.energy_distribution.home" ), value: 0, - color: "var(--primary-color)", + color: computedStyle.getPropertyValue("--primary-color"), index: 1, }; nodes.push(homeNode); @@ -109,7 +111,9 @@ class HuiEnergySankeyCard ), value: totalFromGrid, tooltip: `${formatNumber(totalFromGrid, this.hass.locale)} kWh`, - color: "var(--energy-grid-consumption-color)", + color: computedStyle.getPropertyValue( + "--energy-grid-consumption-color" + ), index: 0, }); @@ -134,7 +138,7 @@ class HuiEnergySankeyCard ), value: totalBatteryOut, tooltip: `${formatNumber(totalBatteryOut, this.hass.locale)} kWh`, - color: "var(--energy-battery-out-color)", + color: computedStyle.getPropertyValue("--energy-battery-out-color"), index: 0, }); links.push({ @@ -158,7 +162,7 @@ class HuiEnergySankeyCard ), value: totalSolarProduction, tooltip: `${formatNumber(totalSolarProduction, this.hass.locale)} kWh`, - color: "var(--energy-solar-color)", + color: computedStyle.getPropertyValue("--energy-solar-color"), index: 0, }); @@ -188,7 +192,7 @@ class HuiEnergySankeyCard ), value: totalBatteryIn, tooltip: `${formatNumber(totalBatteryIn, this.hass.locale)} kWh`, - color: "var(--energy-battery-in-color)", + color: computedStyle.getPropertyValue("--energy-battery-in-color"), index: 1, }); nodes.forEach((node) => { @@ -219,7 +223,7 @@ class HuiEnergySankeyCard ), value: totalToGrid, tooltip: `${formatNumber(totalToGrid, this.hass.locale)} kWh`, - color: "var(--energy-grid-return-color)", + color: computedStyle.getPropertyValue("--energy-grid-return-color"), index: 1, }); nodes.forEach((node) => { @@ -249,7 +253,6 @@ class HuiEnergySankeyCard }, }; let untrackedConsumption = homeNode.value; - const computedStyle = getComputedStyle(this); prefs.device_consumption.forEach((device, idx) => { const entity = this.hass.entities[device.stat_consumption]; const value = @@ -258,7 +261,7 @@ class HuiEnergySankeyCard this._data!.stats[device.stat_consumption] ) || 0 : 0; - if (value <= 0) { + if (value < 0.01) { return; } untrackedConsumption -= value; @@ -335,6 +338,7 @@ class HuiEnergySankeyCard value: floors[floorId].value, tooltip: `${formatNumber(floors[floorId].value, this.hass.locale)} kWh`, index: 2, + color: computedStyle.getPropertyValue("--primary-color"), }); links.push({ source: "home", @@ -353,6 +357,7 @@ class HuiEnergySankeyCard value: areas[areaId].value, tooltip: `${formatNumber(areas[areaId].value, this.hass.locale)} kWh`, index: 3, + color: computedStyle.getPropertyValue("--primary-color"), }); links.push({ source: floorNodeId, @@ -379,7 +384,7 @@ class HuiEnergySankeyCard ), value: untrackedConsumption, tooltip: `${formatNumber(untrackedConsumption, this.hass.locale)} kWh`, - color: "var(--state-unavailable-color)", + color: computedStyle.getPropertyValue("--state-unavailable-color"), index: 4, }); links.push({ @@ -402,9 +407,7 @@ class HuiEnergySankeyCard ? html`` : html`${this.hass.localize( "ui.panel.lovelace.cards.energy.no_data_period" @@ -414,6 +417,9 @@ class HuiEnergySankeyCard `; } + private _valueFormatter = (value: number) => + `${formatNumber(value, this.hass.locale)} kWh`; + static styles = css` :host { display: block; diff --git a/src/resources/echarts.ts b/src/resources/echarts.ts index df393a92fe..77a328722f 100644 --- a/src/resources/echarts.ts +++ b/src/resources/echarts.ts @@ -27,6 +27,7 @@ import type { BarSeriesOption, LineSeriesOption, CustomSeriesOption, + SankeySeriesOption, } from "echarts/charts"; import type { // The component option types are defined with the ComponentOption suffix @@ -50,6 +51,7 @@ export type ECOption = ComposeOption< | GridComponentOption | DataZoomComponentOption | VisualMapComponentOption + | SankeySeriesOption >; // Register the required components