mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-26 18:56:39 +00:00
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:
parent
a7b1c45c00
commit
4caca19e32
@ -47,6 +47,8 @@ export class HaChartBase extends LitElement {
|
|||||||
@property({ attribute: "expand-legend", type: Boolean })
|
@property({ attribute: "expand-legend", type: Boolean })
|
||||||
public expandLegend?: boolean;
|
public expandLegend?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public extraComponents?: any[];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@consume({ context: themesContext, subscribe: true })
|
@consume({ context: themesContext, subscribe: true })
|
||||||
_themes!: Themes;
|
_themes!: Themes;
|
||||||
@ -271,6 +273,10 @@ export class HaChartBase extends LitElement {
|
|||||||
}
|
}
|
||||||
const echarts = (await import("../../resources/echarts")).default;
|
const echarts = (await import("../../resources/echarts")).default;
|
||||||
|
|
||||||
|
if (this.extraComponents?.length) {
|
||||||
|
echarts.use(this.extraComponents);
|
||||||
|
}
|
||||||
|
|
||||||
echarts.registerTheme("custom", this._createTheme());
|
echarts.registerTheme("custom", this._createTheme());
|
||||||
|
|
||||||
this.chart = echarts.init(container, "custom");
|
this.chart = echarts.init(container, "custom");
|
||||||
@ -298,11 +304,13 @@ export class HaChartBase extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
|
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
|
||||||
const xAxis = (this.options?.xAxis?.[0] ??
|
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||||
this.options?.xAxis) as XAXisOption;
|
| XAXisOption
|
||||||
const yAxis = (this.options?.yAxis?.[0] ??
|
| undefined;
|
||||||
this.options?.yAxis) as YAXisOption;
|
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
|
||||||
if (xAxis.type === "value" && yAxis.type === "category") {
|
| YAXisOption
|
||||||
|
| undefined;
|
||||||
|
if (xAxis?.type === "value" && yAxis?.type === "category") {
|
||||||
// vertical data zoom doesn't work well in this case and horizontal is pointless
|
// vertical data zoom doesn't work well in this case and horizontal is pointless
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,17 @@
|
|||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { LitElement, html, css, svg, nothing } from "lit";
|
import { LitElement, html, css } from "lit";
|
||||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
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 memoizeOne from "memoize-one";
|
||||||
|
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import type { ECOption } from "../../resources/echarts";
|
||||||
import { measureTextWidth } from "../../util/text";
|
import { measureTextWidth } from "../../util/text";
|
||||||
|
import "./ha-chart-base";
|
||||||
|
import { NODE_SIZE } from "../trace/hat-graph-const";
|
||||||
|
import "../ha-alert";
|
||||||
|
|
||||||
export interface Node {
|
export interface Node {
|
||||||
id: string;
|
id: string;
|
||||||
@ -25,34 +33,14 @@ export interface SankeyChartData {
|
|||||||
links: Link[];
|
links: Link[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProcessedNode = Node & {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
size: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProcessedLink = Link & {
|
type ProcessedLink = Link & {
|
||||||
value: number;
|
value: number;
|
||||||
offset: {
|
|
||||||
source: number;
|
|
||||||
target: number;
|
|
||||||
};
|
|
||||||
passThroughNodeIds: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Section {
|
const OVERFLOW_MARGIN = 5;
|
||||||
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 FONT_SIZE = 12;
|
||||||
const MIN_DISTANCE = FONT_SIZE / 2;
|
const NODE_GAP = 8;
|
||||||
|
const LABEL_DISTANCE = 5;
|
||||||
|
|
||||||
@customElement("ha-sankey-chart")
|
@customElement("ha-sankey-chart")
|
||||||
export class HaSankeyChart extends LitElement {
|
export class HaSankeyChart extends LitElement {
|
||||||
@ -65,141 +53,144 @@ export class HaSankeyChart extends LitElement {
|
|||||||
|
|
||||||
@property({ type: Boolean }) public vertical = false;
|
@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,
|
callback: (entries) => entries[0]?.contentRect,
|
||||||
});
|
});
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
willUpdate() {
|
|
||||||
this._statePerPixel = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this._sizeController.value) {
|
const options = {
|
||||||
return this.loadingText ?? nothing;
|
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;
|
return html`<ha-chart-base
|
||||||
const { nodes, paths } = this._processNodesAndPaths(
|
.data=${this._createData(this.data, this._sizeController.value?.width)}
|
||||||
this.data.nodes,
|
.options=${options}
|
||||||
this.data.links
|
height="100%"
|
||||||
);
|
.extraComponents=${[SankeyChart]}
|
||||||
|
></ha-chart-base>`;
|
||||||
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}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processNodesAndPaths = memoizeOne(
|
private _renderTooltip = (params: CallbackDataParams) => {
|
||||||
(rawNodes: Node[], rawLinks: Link[]) => {
|
const data = params.data as Record<string, any>;
|
||||||
const filteredNodes = rawNodes.filter((n) => n.value > 0);
|
const value = this.valueFormatter
|
||||||
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
|
? this.valueFormatter(data.value)
|
||||||
const { links, passThroughNodes } = this._processLinks(
|
: data.value;
|
||||||
filteredNodes,
|
if (data.id) {
|
||||||
indexes,
|
const node = this.data.nodes.find((n) => n.id === data.id);
|
||||||
rawLinks
|
return `${params.marker} ${node?.label ?? data.id}<br>${value}`;
|
||||||
);
|
|
||||||
const nodes = this._processNodes(
|
|
||||||
[...filteredNodes, ...passThroughNodes],
|
|
||||||
indexes
|
|
||||||
);
|
|
||||||
const paths = this._processPaths(nodes, links);
|
|
||||||
return { nodes, paths };
|
|
||||||
}
|
}
|
||||||
);
|
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 accountedIn = new Map<string, number>();
|
||||||
const accountedOut = new Map<string, number>();
|
const accountedOut = new Map<string, number>();
|
||||||
const links: ProcessedLink[] = [];
|
const links: ProcessedLink[] = [];
|
||||||
const passThroughNodes: Node[] = [];
|
|
||||||
rawLinks.forEach((link) => {
|
rawLinks.forEach((link) => {
|
||||||
const sourceNode = nodes.find((n) => n.id === link.source);
|
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||||
const targetNode = nodes.find((n) => n.id === link.target);
|
const targetNode = nodes.find((n) => n.id === link.target);
|
||||||
@ -222,307 +213,25 @@ export class HaSankeyChart extends LitElement {
|
|||||||
accountedIn.set(targetNode.id, targetAccounted + value);
|
accountedIn.set(targetNode.id, targetAccounted + value);
|
||||||
accountedOut.set(sourceNode.id, sourceAccounted + 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) {
|
if (value > 0) {
|
||||||
links.push({
|
links.push({
|
||||||
...link,
|
...link,
|
||||||
value,
|
value,
|
||||||
offset: {
|
|
||||||
source: sourceAccounted / (sourceNode.value || 1),
|
|
||||||
target: targetAccounted / (targetNode.value || 1),
|
|
||||||
},
|
|
||||||
passThroughNodeIds,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { links, passThroughNodes };
|
return links;
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--ha-card-background, var(--card-background-color, #000));
|
background: var(--ha-card-background, var(--card-background-color));
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
svg {
|
ha-chart-base {
|
||||||
overflow: visible;
|
width: 100%;
|
||||||
position: absolute;
|
height: 100%;
|
||||||
}
|
|
||||||
.node-label {
|
|
||||||
font-size: ${FONT_SIZE}px;
|
|
||||||
fill: var(--primary-text-color, white);
|
|
||||||
}
|
|
||||||
.node-label.vertical {
|
|
||||||
position: absolute;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -81,6 +81,8 @@ class HuiEnergySankeyCard
|
|||||||
const prefs = this._data.prefs;
|
const prefs = this._data.prefs;
|
||||||
const types = energySourcesByType(prefs);
|
const types = energySourcesByType(prefs);
|
||||||
|
|
||||||
|
const computedStyle = getComputedStyle(this);
|
||||||
|
|
||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
const links: Link[] = [];
|
const links: Link[] = [];
|
||||||
|
|
||||||
@ -90,7 +92,7 @@ class HuiEnergySankeyCard
|
|||||||
"ui.panel.lovelace.cards.energy.energy_distribution.home"
|
"ui.panel.lovelace.cards.energy.energy_distribution.home"
|
||||||
),
|
),
|
||||||
value: 0,
|
value: 0,
|
||||||
color: "var(--primary-color)",
|
color: computedStyle.getPropertyValue("--primary-color"),
|
||||||
index: 1,
|
index: 1,
|
||||||
};
|
};
|
||||||
nodes.push(homeNode);
|
nodes.push(homeNode);
|
||||||
@ -109,7 +111,9 @@ class HuiEnergySankeyCard
|
|||||||
),
|
),
|
||||||
value: totalFromGrid,
|
value: totalFromGrid,
|
||||||
tooltip: `${formatNumber(totalFromGrid, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(totalFromGrid, this.hass.locale)} kWh`,
|
||||||
color: "var(--energy-grid-consumption-color)",
|
color: computedStyle.getPropertyValue(
|
||||||
|
"--energy-grid-consumption-color"
|
||||||
|
),
|
||||||
index: 0,
|
index: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -134,7 +138,7 @@ class HuiEnergySankeyCard
|
|||||||
),
|
),
|
||||||
value: totalBatteryOut,
|
value: totalBatteryOut,
|
||||||
tooltip: `${formatNumber(totalBatteryOut, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(totalBatteryOut, this.hass.locale)} kWh`,
|
||||||
color: "var(--energy-battery-out-color)",
|
color: computedStyle.getPropertyValue("--energy-battery-out-color"),
|
||||||
index: 0,
|
index: 0,
|
||||||
});
|
});
|
||||||
links.push({
|
links.push({
|
||||||
@ -158,7 +162,7 @@ class HuiEnergySankeyCard
|
|||||||
),
|
),
|
||||||
value: totalSolarProduction,
|
value: totalSolarProduction,
|
||||||
tooltip: `${formatNumber(totalSolarProduction, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(totalSolarProduction, this.hass.locale)} kWh`,
|
||||||
color: "var(--energy-solar-color)",
|
color: computedStyle.getPropertyValue("--energy-solar-color"),
|
||||||
index: 0,
|
index: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -188,7 +192,7 @@ class HuiEnergySankeyCard
|
|||||||
),
|
),
|
||||||
value: totalBatteryIn,
|
value: totalBatteryIn,
|
||||||
tooltip: `${formatNumber(totalBatteryIn, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(totalBatteryIn, this.hass.locale)} kWh`,
|
||||||
color: "var(--energy-battery-in-color)",
|
color: computedStyle.getPropertyValue("--energy-battery-in-color"),
|
||||||
index: 1,
|
index: 1,
|
||||||
});
|
});
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
@ -219,7 +223,7 @@ class HuiEnergySankeyCard
|
|||||||
),
|
),
|
||||||
value: totalToGrid,
|
value: totalToGrid,
|
||||||
tooltip: `${formatNumber(totalToGrid, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(totalToGrid, this.hass.locale)} kWh`,
|
||||||
color: "var(--energy-grid-return-color)",
|
color: computedStyle.getPropertyValue("--energy-grid-return-color"),
|
||||||
index: 1,
|
index: 1,
|
||||||
});
|
});
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
@ -249,7 +253,6 @@ class HuiEnergySankeyCard
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let untrackedConsumption = homeNode.value;
|
let untrackedConsumption = homeNode.value;
|
||||||
const computedStyle = getComputedStyle(this);
|
|
||||||
prefs.device_consumption.forEach((device, idx) => {
|
prefs.device_consumption.forEach((device, idx) => {
|
||||||
const entity = this.hass.entities[device.stat_consumption];
|
const entity = this.hass.entities[device.stat_consumption];
|
||||||
const value =
|
const value =
|
||||||
@ -258,7 +261,7 @@ class HuiEnergySankeyCard
|
|||||||
this._data!.stats[device.stat_consumption]
|
this._data!.stats[device.stat_consumption]
|
||||||
) || 0
|
) || 0
|
||||||
: 0;
|
: 0;
|
||||||
if (value <= 0) {
|
if (value < 0.01) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
untrackedConsumption -= value;
|
untrackedConsumption -= value;
|
||||||
@ -335,6 +338,7 @@ class HuiEnergySankeyCard
|
|||||||
value: floors[floorId].value,
|
value: floors[floorId].value,
|
||||||
tooltip: `${formatNumber(floors[floorId].value, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(floors[floorId].value, this.hass.locale)} kWh`,
|
||||||
index: 2,
|
index: 2,
|
||||||
|
color: computedStyle.getPropertyValue("--primary-color"),
|
||||||
});
|
});
|
||||||
links.push({
|
links.push({
|
||||||
source: "home",
|
source: "home",
|
||||||
@ -353,6 +357,7 @@ class HuiEnergySankeyCard
|
|||||||
value: areas[areaId].value,
|
value: areas[areaId].value,
|
||||||
tooltip: `${formatNumber(areas[areaId].value, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(areas[areaId].value, this.hass.locale)} kWh`,
|
||||||
index: 3,
|
index: 3,
|
||||||
|
color: computedStyle.getPropertyValue("--primary-color"),
|
||||||
});
|
});
|
||||||
links.push({
|
links.push({
|
||||||
source: floorNodeId,
|
source: floorNodeId,
|
||||||
@ -379,7 +384,7 @@ class HuiEnergySankeyCard
|
|||||||
),
|
),
|
||||||
value: untrackedConsumption,
|
value: untrackedConsumption,
|
||||||
tooltip: `${formatNumber(untrackedConsumption, this.hass.locale)} kWh`,
|
tooltip: `${formatNumber(untrackedConsumption, this.hass.locale)} kWh`,
|
||||||
color: "var(--state-unavailable-color)",
|
color: computedStyle.getPropertyValue("--state-unavailable-color"),
|
||||||
index: 4,
|
index: 4,
|
||||||
});
|
});
|
||||||
links.push({
|
links.push({
|
||||||
@ -402,9 +407,7 @@ class HuiEnergySankeyCard
|
|||||||
? html`<ha-sankey-chart
|
? html`<ha-sankey-chart
|
||||||
.data=${{ nodes, links }}
|
.data=${{ nodes, links }}
|
||||||
.vertical=${this._config.layout === "vertical"}
|
.vertical=${this._config.layout === "vertical"}
|
||||||
.loadingText=${this.hass.localize(
|
.valueFormatter=${this._valueFormatter}
|
||||||
"ui.panel.lovelace.cards.energy.loading"
|
|
||||||
)}
|
|
||||||
></ha-sankey-chart>`
|
></ha-sankey-chart>`
|
||||||
: html`${this.hass.localize(
|
: html`${this.hass.localize(
|
||||||
"ui.panel.lovelace.cards.energy.no_data_period"
|
"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`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -27,6 +27,7 @@ import type {
|
|||||||
BarSeriesOption,
|
BarSeriesOption,
|
||||||
LineSeriesOption,
|
LineSeriesOption,
|
||||||
CustomSeriesOption,
|
CustomSeriesOption,
|
||||||
|
SankeySeriesOption,
|
||||||
} from "echarts/charts";
|
} from "echarts/charts";
|
||||||
import type {
|
import type {
|
||||||
// The component option types are defined with the ComponentOption suffix
|
// The component option types are defined with the ComponentOption suffix
|
||||||
@ -50,6 +51,7 @@ export type ECOption = ComposeOption<
|
|||||||
| GridComponentOption
|
| GridComponentOption
|
||||||
| DataZoomComponentOption
|
| DataZoomComponentOption
|
||||||
| VisualMapComponentOption
|
| VisualMapComponentOption
|
||||||
|
| SankeySeriesOption
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Register the required components
|
// Register the required components
|
||||||
|
Loading…
x
Reference in New Issue
Block a user