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`
-
- ${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