Migrate sankey chart to echarts (#24185)

* Migrate sankey chart to echarts

* don't memoize options

* Dynamically load echart sankey component

* load Sankey component with the card

* load the sankey component in ha-chart-base
This commit is contained in:
Petar Petrov 2025-02-25 16:02:41 +02:00 committed by GitHub
parent a7b1c45c00
commit 4caca19e32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 176 additions and 451 deletions

View File

@ -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;
}

View File

@ -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`
<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}
`;
return html`<ha-chart-base
.data=${this._createData(this.data, this._sizeController.value?.width)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
></ha-chart-base>`;
}
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<string, any>;
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}<br>${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}<br>${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<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);
@ -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<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 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%;
}
`;
}

View File

@ -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`<ha-sankey-chart
.data=${{ nodes, links }}
.vertical=${this._config.layout === "vertical"}
.loadingText=${this.hass.localize(
"ui.panel.lovelace.cards.energy.loading"
)}
.valueFormatter=${this._valueFormatter}
></ha-sankey-chart>`
: 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;

View File

@ -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