1
0
mirror of https://github.com/home-assistant/frontend.git synced 2025-12-20 02:38:53 +00:00

Improved Sankey layout (#26787)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Petar Petrov
2025-10-14 14:53:11 +03:00
committed by GitHub
parent fd7f0d3841
commit d1093b187f
21 changed files with 1094 additions and 19 deletions

View File

@@ -22,7 +22,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
@@ -346,7 +346,7 @@ export class HaChartBase extends LitElement {
if (this.chart) {
this.chart.dispose();
}
const echarts = (await import("../../resources/echarts")).default;
const echarts = (await import("../../resources/echarts/echarts")).default;
if (this.extraComponents?.length) {
echarts.use(this.extraComponents);

View File

@@ -6,7 +6,7 @@ import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";

View File

@@ -1,13 +1,13 @@
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 type { CallbackDataParams } from "echarts/types/src/util/types";
import memoizeOne from "memoize-one";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
@@ -39,7 +39,7 @@ type ProcessedLink = Link & {
const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 8;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
@customElement("ha-sankey-chart")
@@ -164,6 +164,7 @@ export class HaSankeyChart extends LitElement {
lineStyle: {
color: "gradient",
opacity: 0.4,
curveness: 0.5,
},
layoutIterations: 0,
label: {

View File

@@ -11,7 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,

View File

@@ -15,8 +15,8 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts";
import echarts from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";

View File

@@ -29,7 +29,7 @@ import {
getStatisticMetadata,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";

View File

@@ -31,7 +31,7 @@ import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { ECOption } from "../../../resources/echarts";
import type { ECOption } from "../../../resources/echarts/echarts";
import { haStyle } from "../../../resources/styles";
import { DefaultPrimaryColor } from "../../../resources/theme/color/color.globals";
import type { HomeAssistant } from "../../../types";

View File

@@ -26,7 +26,7 @@ import {
formatDateVeryShort,
} from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
export function getSuggestedMax(dayDifference: number, end: Date): number {

View File

@@ -36,7 +36,7 @@ import {
getCompareTransform,
} from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage";
import type { ECOption } from "../../../../resources/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";

View File

@@ -28,7 +28,7 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import type { ECOption } from "../../../../resources/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";

View File

@@ -28,7 +28,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";

View File

@@ -32,9 +32,9 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
import type { ECOption } from "../../../../resources/echarts";
@customElement("hui-energy-solar-graph-card")
export class HuiEnergySolarGraphCard

View File

@@ -37,7 +37,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",

View File

@@ -27,7 +27,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";

View File

@@ -0,0 +1,10 @@
import { SankeyChart } from "echarts/charts";
import type { EChartsExtensionInstallRegisters } from "echarts/types/src/extension";
import sankeyLayout from "./sankey-layout";
import SankeyView from "./sankey-view";
export default function install(registers: EChartsExtensionInstallRegisters) {
SankeyChart(registers as any);
registers.registerLayout(sankeyLayout);
registers.registerChartView(SankeyView as any);
}

View File

@@ -0,0 +1,586 @@
import type GlobalModel from "echarts/types/src/model/Global";
import type SankeySeriesModel from "echarts/types/src/chart/sankey/SankeySeries";
import type {
SankeyEdgeItemOption,
SankeyNodeItemOption,
} from "echarts/types/src/chart/sankey/SankeySeries";
import type { GraphNode, GraphEdge } from "echarts/types/src/data/Graph";
import type ExtensionAPI from "echarts/types/src/core/ExtensionAPI";
import { createBoxLayoutReference } from "echarts/lib/util/layout";
import type { SankeyPathShape } from "./sankey-path";
interface PassThroughNode {
passThrough: boolean;
id: string;
value: number;
depth: number;
}
interface GraphLink extends GraphEdge {
passThroughNodeIds: string[];
}
type Node = GraphNode | PassThroughNode;
interface SectionNode {
node: Node;
id: string;
value: number;
x: number;
y: number;
dx: number;
dy: number;
size: number;
}
export function isPassThroughNode(node: Node): node is PassThroughNode {
return "passThrough" in node;
}
const MIN_SIZE = 1;
interface CoordinateSystem {
breadth: "x" | "y";
depth: "x" | "y";
breadthSize: "dx" | "dy";
depthSize: "dx" | "dy";
}
export function getCoordinateSystem(
orient: "vertical" | "horizontal"
): CoordinateSystem {
return orient === "vertical"
? { breadth: "x", depth: "y", breadthSize: "dx", depthSize: "dy" }
: { breadth: "y", depth: "x", breadthSize: "dy", depthSize: "dx" };
}
export default function sankeyLayout(ecModel: GlobalModel, _api: ExtensionAPI) {
ecModel.eachSeriesByType("sankey", ((seriesModel: SankeySeriesModel) => {
if (seriesModel.get("nodeAlign") !== "justify") {
// Only handle justify nodes for now
return;
}
const nodeWidth = seriesModel.get("nodeWidth")!;
const nodeGap = seriesModel.get("nodeGap")!;
const refContainer = createBoxLayoutReference(
seriesModel,
_api
).refContainer;
const { width, height } = refContainer;
const graph = seriesModel.getGraph();
const nodes = graph.nodes;
const edges = graph.edges;
const orient = seriesModel.get("orient")!;
layoutSankey(nodes, edges, nodeWidth, nodeGap, width, height, orient);
}) as any);
}
function layoutSankey(
nodes: GraphNode[],
edges: GraphEdge[],
nodeWidth: number,
nodeGap: number,
width: number,
height: number,
orient: "vertical" | "horizontal"
) {
const filteredNodes = nodes.filter((node) => node.getLayout().value > 0);
const depths = [
...new Set(
filteredNodes.map(
(n) =>
(n.hostGraph.data.getRawDataItem(n.dataIndex) as SankeyNodeItemOption)
.depth || 0
)
),
].sort();
const passThroughNodes = generatePassThroughNodes(depths, edges);
const processedNodes = processNodes(
filteredNodes,
passThroughNodes,
depths,
width,
height,
orient,
nodeGap
);
applyLayout(processedNodes, nodeWidth, orient);
}
export function getNodeDepthInfo(
node: GraphNode,
depths: number[]
): { depth: number; depthIndex: number } {
const nodeItem = node.hostGraph.data.getRawDataItem(
node.dataIndex
) as SankeyNodeItemOption;
const depth = nodeItem.depth || 0;
const depthIndex = depths.findIndex((i) => i === depth);
return { depth, depthIndex };
}
export function getEdgeValue(edge: GraphEdge): number {
const edgeItem = edge.hostGraph.edgeData.getRawDataItem(
edge.dataIndex
) as SankeyEdgeItemOption;
return edgeItem.value as number;
}
export function getPassThroughSections(
sourceDepthIndex: number,
targetDepthIndex: number,
depths: number[]
): number[] {
return depths.slice(sourceDepthIndex + 1, targetDepthIndex);
}
export function createPassThroughNode(
sourceId: string,
targetId: string,
depth: number,
value: number
): PassThroughNode {
return {
passThrough: true,
id: `${sourceId}-${targetId}-${depth}`,
value,
depth,
};
}
function processEdgeForPassThrough(
edge: GraphEdge,
depths: number[],
passThroughNodes: PassThroughNode[]
): string[] {
if (edge.getLayout().value === 0) {
return [];
}
const sourceInfo = getNodeDepthInfo(edge.node1, depths);
const targetInfo = getNodeDepthInfo(edge.node2, depths);
const edgeValue = getEdgeValue(edge);
const passThroughSections = getPassThroughSections(
sourceInfo.depthIndex,
targetInfo.depthIndex,
depths
);
const sourceNode = edge.node1.hostGraph.data.getRawDataItem(
edge.node1.dataIndex
) as SankeyNodeItemOption;
const targetNode = edge.node2.hostGraph.data.getRawDataItem(
edge.node2.dataIndex
) as SankeyNodeItemOption;
const passThroughNodeIds = passThroughSections.map((depth) => {
const node = createPassThroughNode(
sourceNode.id as string,
targetNode.id as string,
depth,
edgeValue
);
passThroughNodes.push(node);
return node.id;
});
return passThroughNodeIds;
}
function generatePassThroughNodes(depths: number[], edges: GraphEdge[]) {
const passThroughNodes: PassThroughNode[] = [];
edges.forEach((edge) => {
const passThroughNodeIds = processEdgeForPassThrough(
edge,
depths,
passThroughNodes
);
const link = edge as GraphLink;
link.passThroughNodeIds = passThroughNodeIds;
});
return passThroughNodes;
}
export function groupNodesBySection(
nodes: GraphNode[],
passThroughNodes: PassThroughNode[]
): Record<number, Node[]> {
const nodesPerSection: Record<number, Node[]> = {};
nodes.forEach((node) => {
const depth = node.getLayout().depth;
if (!nodesPerSection[depth]) {
nodesPerSection[depth] = [node];
} else {
nodesPerSection[depth].push(node);
}
});
passThroughNodes.forEach((node) => {
if (!nodesPerSection[node.depth]) {
nodesPerSection[node.depth] = [node];
} else {
nodesPerSection[node.depth].push(node);
}
});
return nodesPerSection;
}
export function createSectionNodes(nodes: Node[]): SectionNode[] {
return nodes.map(
(node: Node): SectionNode => ({
node,
id: node.id,
value: isPassThroughNode(node) ? node.value : node.getLayout().value,
x: 0,
y: 0,
dx: 0,
dy: 0,
size: 0,
})
);
}
export function calculateSectionDimensions(
orient: "vertical" | "horizontal",
width: number,
height: number,
depths: number[],
nodeGap: number
) {
const sectionSize = (orient === "vertical" ? width : height) - nodeGap * 2;
const sectionDepthSize =
orient === "vertical" ? height / depths.length : width / depths.length;
return { sectionSize, sectionDepthSize };
}
/**
* Basically does `align-items: space-around`
* @param {{ nodes: SectionNode[]; depth: number; totalValue: number; valueToSizeRatio: number; }} section - The section to position nodes in
* @param {number} index - The index of the section
* @param {number} sectionSize - The size of the section
* @param {number} sectionDepthSize - The depth size of the section
* @param {number} globalValueToSizeRatio - The global value to size ratio
* @param {"vertical" | "horizontal"} orient - The orientation of the section (vertical or horizontal)
* @returns {void}
*/
function positionNodesInSection(
section: {
nodes: SectionNode[];
depth: number;
totalValue: number;
valueToSizeRatio: number;
},
index: number,
sectionSize: number,
sectionDepthSize: number,
globalValueToSizeRatio: number,
orient: "vertical" | "horizontal"
) {
let totalSize = 0;
if (section.valueToSizeRatio !== globalValueToSizeRatio) {
section.nodes.forEach((node) => {
const size = Math.max(
MIN_SIZE,
Math.floor(node.value / globalValueToSizeRatio)
);
totalSize += size;
node.size = size;
});
} else {
totalSize = section.nodes.reduce((sum, node) => sum + node.size, 0);
}
const emptySpace = sectionSize - totalSize;
let offset = emptySpace / (section.nodes.length + 1);
section.nodes.forEach((node) => {
if (orient === "vertical") {
node.x = offset;
node.y = index * sectionDepthSize;
} else {
node.x = index * sectionDepthSize;
node.y = offset;
}
offset += node.size + emptySpace / (section.nodes.length + 1);
});
}
function processNodes(
nodes: GraphNode[],
passThroughNodes: PassThroughNode[],
depths: number[],
width: number,
height: number,
orient: "vertical" | "horizontal",
nodeGap: number
) {
const { sectionSize, sectionDepthSize } = calculateSectionDimensions(
orient,
width,
height,
depths,
nodeGap
);
const nodesPerSection = groupNodesBySection(nodes, passThroughNodes);
let globalValueToSizeRatio = 0;
const sections = depths.map((depth) => {
const sectionNodes = createSectionNodes(nodesPerSection[depth] || []);
const availableSpace = sectionSize - (sectionNodes.length + 1) * nodeGap;
const totalValue = sectionNodes.reduce(
(acc: number, node: SectionNode) => acc + node.value,
0
);
const { nodes: sizedNodes, valueToSizeRatio: sectionValueToSizeRatio } =
setNodeSizes(
sectionNodes,
availableSpace,
totalValue,
globalValueToSizeRatio
);
if (sectionValueToSizeRatio > globalValueToSizeRatio) {
globalValueToSizeRatio = sectionValueToSizeRatio;
}
return {
nodes: sizedNodes,
depth,
totalValue,
valueToSizeRatio: sectionValueToSizeRatio,
};
});
sections.forEach((section, index) => {
positionNodesInSection(
section,
index,
sectionSize,
sectionDepthSize,
globalValueToSizeRatio,
orient
);
});
return sections.flatMap((section) => section.nodes);
}
export function setNodeSizes(
nodes: SectionNode[],
availableSpace: number,
totalValue: number,
prevValueToSizeRatio = 0
): { nodes: SectionNode[]; valueToSizeRatio: number } {
let valueToSizeRatio = totalValue / availableSpace;
if (valueToSizeRatio < prevValueToSizeRatio) {
valueToSizeRatio = prevValueToSizeRatio;
}
let deficitHeight = 0;
const result = nodes.map((node) => {
if (node.size === MIN_SIZE) {
return node;
}
let size = Math.floor(node.value / valueToSizeRatio);
if (size < MIN_SIZE) {
deficitHeight += MIN_SIZE - size;
size = MIN_SIZE;
}
return {
...node,
size,
};
});
if (deficitHeight > 0) {
return setNodeSizes(
result,
availableSpace - deficitHeight,
totalValue,
valueToSizeRatio
);
}
return { nodes: result, valueToSizeRatio };
}
function applyNodeDimensions(
nodes: SectionNode[],
nodeWidth: number,
coords: CoordinateSystem
) {
nodes.forEach((node) => {
node[coords.breadthSize] = node.size;
node[coords.depthSize] = nodeWidth;
if (isPassThroughNode(node.node)) {
return;
}
node.node.setLayout(
{ x: node.x, y: node.y, dx: node.dx, dy: node.dy },
true
);
});
}
function applyEdgeSizes(nodes: SectionNode[], coords: CoordinateSystem) {
nodes.forEach((node) => {
if (isPassThroughNode(node.node)) {
return;
}
node.node.outEdges.forEach((edge) => {
const edgeItem = edge.hostGraph.edgeData.getRawDataItem(
edge.dataIndex
) as SankeyEdgeItemOption;
const edgeSize = ((edgeItem.value as number) / node.value) * node.size;
edge.setLayout(
{ [coords.breadthSize]: edgeSize, [coords.depthSize]: 0 },
true
);
});
});
}
function sortEdgesByTargetPosition(
nodes: SectionNode[],
coords: CoordinateSystem
) {
nodes.forEach((node) => {
if (isPassThroughNode(node.node)) {
return;
}
node.node.outEdges.sort(
(a, b) =>
a.node2.getLayout()[coords.breadth] -
b.node2.getLayout()[coords.breadth]
);
node.node.inEdges.sort(
(a, b) =>
a.node1.getLayout()[coords.breadth] -
b.node1.getLayout()[coords.breadth]
);
});
}
function generatePassThroughPoints(
edge: GraphLink,
nodes: SectionNode[],
orient: "vertical" | "horizontal",
curveType: "curveVertical" | "curveHorizontal"
): SankeyPathShape["targets"] {
const passthroughPoints: SankeyPathShape["targets"] = [];
edge.passThroughNodeIds.forEach((nodeId) => {
const passthroughNode = nodes.find((n) => n.id === nodeId)!;
passthroughPoints.push({
x: passthroughNode.x,
y: passthroughNode.y,
type: curveType,
});
if (orient === "vertical") {
passthroughPoints.push({
x: passthroughNode.x,
y: passthroughNode.y + passthroughNode.dy,
type: "line",
});
} else {
passthroughPoints.push({
x: passthroughNode.x + passthroughNode.dx,
y: passthroughNode.y,
type: "line",
});
}
});
return passthroughPoints;
}
function positionOutEdges(
node: SectionNode,
orient: "vertical" | "horizontal",
coords: CoordinateSystem
) {
if (isPassThroughNode(node.node)) {
return;
}
let offset = 0;
node.node.outEdges.forEach((edge) => {
edge.setLayout(
{
x: orient === "vertical" ? node.x + offset : node.x + node.dx,
y: orient === "vertical" ? node.y + node.dy : node.y + offset,
},
true
);
offset += edge.getLayout()[coords.breadthSize];
});
}
function positionInEdges(
node: SectionNode,
nodes: SectionNode[],
orient: "vertical" | "horizontal",
coords: CoordinateSystem,
curveType: "curveVertical" | "curveHorizontal"
) {
if (isPassThroughNode(node.node)) {
return;
}
let offset = 0;
node.node.inEdges.forEach((edge) => {
const passthroughPoints = generatePassThroughPoints(
edge as GraphLink,
nodes,
orient,
curveType
);
edge.setLayout(
{
targets: [
...passthroughPoints,
{
x: orient === "vertical" ? node.x + offset : node.x,
y: orient === "vertical" ? node.y : node.y + offset,
type: curveType,
},
] as SankeyPathShape["targets"],
},
true
);
offset += edge.getLayout()[coords.breadthSize];
});
}
function applyLayout(
nodes: SectionNode[],
nodeWidth: number,
orient: "vertical" | "horizontal"
) {
const coords = getCoordinateSystem(orient);
const curveType = orient === "vertical" ? "curveVertical" : "curveHorizontal";
applyNodeDimensions(nodes, nodeWidth, coords);
applyEdgeSizes(nodes, coords);
sortEdgesByTargetPosition(nodes, coords);
nodes.forEach((node) => {
if (isPassThroughNode(node.node)) {
return;
}
positionOutEdges(node, orient, coords);
positionInEdges(node, nodes, orient, coords, curveType);
});
}

View File

@@ -0,0 +1,88 @@
export interface SankeyPathShape {
x: number;
y: number;
dx: number;
dy: number;
targets: {
x: number;
y: number;
type: "curveHorizontal" | "curveVertical" | "line";
}[];
}
export function buildPath(
ctx: CanvasRenderingContext2D,
shape: SankeyPathShape,
curveness: number
) {
if (!shape.targets?.length) {
return;
}
ctx.moveTo(shape.x, shape.y);
const lastPoint = shape.targets[shape.targets.length - 1];
const points = [
{ x: shape.x, y: shape.y, type: lastPoint.type },
...shape.targets,
];
for (let i = 1; i < points.length; i++) {
const point = points[i];
const prevPoint = points[i - 1];
if (point.type === "curveHorizontal") {
ctx.bezierCurveTo(
prevPoint.x * (1 - curveness) + point.x * curveness,
prevPoint.y,
point.x * (1 - curveness) + prevPoint.x * curveness,
point.y,
point.x,
point.y
);
} else if (point.type === "curveVertical") {
ctx.bezierCurveTo(
prevPoint.x,
prevPoint.y * (1 - curveness) + point.y * curveness,
point.x,
point.y * (1 - curveness) + prevPoint.y * curveness,
point.x,
point.y
);
} else {
ctx.lineTo(point.x, point.y);
}
}
ctx.lineTo(lastPoint.x + shape.dx, lastPoint.y + shape.dy);
for (let i = points.length - 2; i >= 0; i--) {
const prevPoint = {
x: points[i + 1].x + shape.dx,
y: points[i + 1].y + shape.dy,
type: points[i + 1].type,
};
const point = {
x: points[i].x + shape.dx,
y: points[i].y + shape.dy,
type: prevPoint.type,
};
if (point.type === "curveHorizontal") {
ctx.bezierCurveTo(
prevPoint.x * (1 - curveness) + point.x * curveness,
prevPoint.y,
point.x * (1 - curveness) + prevPoint.x * curveness,
point.y,
point.x,
point.y
);
} else if (point.type === "curveVertical") {
ctx.bezierCurveTo(
prevPoint.x,
prevPoint.y * (1 - curveness) + point.y * curveness,
point.x,
point.y * (1 - curveness) + prevPoint.y * curveness,
point.x,
point.y
);
} else {
ctx.lineTo(point.x, point.y);
}
}
ctx.closePath();
}

View File

@@ -0,0 +1,48 @@
import EchartsSankeyView from "echarts/lib/chart/sankey/SankeyView";
import type GlobalModel from "echarts/types/src/model/Global";
import type SankeySeriesModel from "echarts/types/src/chart/sankey/SankeySeries";
import type ExtensionAPI from "echarts/types/src/core/ExtensionAPI";
import type { SankeyEdgeItemOption } from "echarts/types/src/chart/sankey/SankeySeries";
import type { Path } from "echarts/types/src/util/graphic";
import { buildPath } from "./sankey-path";
class SankeyView extends EchartsSankeyView {
render(
seriesModel: SankeySeriesModel,
ecModel: GlobalModel,
api: ExtensionAPI
) {
super.render(seriesModel, ecModel, api);
const edgeData = seriesModel.getData("edge");
const graph = seriesModel.getGraph();
graph.eachEdge((edge) => {
const edgeLayout = edge.getLayout();
const edgeModel = edge.getModel<SankeyEdgeItemOption>();
const lineStyleModel = edgeModel.getModel("lineStyle");
const curveness = lineStyleModel.get("curveness" as any);
const echartsCurve = edgeData.getItemGraphicEl(edge.dataIndex) as Path;
/**
* Monkey patching warning:
* This code overrides the `buildPath` method of the ECharts internal Path object for Sankey edges.
*
* Compatibility: Tested with ECharts v6 (update this if you upgrade ECharts).
*
* Reason: ECharts does not currently provide a public API for customizing Sankey edge paths.
* To customize the edge shape, we must override the internal method on each edge instance.
*
* Risks: This may break if ECharts changes the internal structure of Sankey edges or the Path class.
* Future ECharts updates may render this patch ineffective or cause runtime errors.
*
* Migration path: If ECharts adds a public API for custom edge paths, migrate to that and remove this patch.
* Track ECharts issues: https://github.com/apache/echarts/issues?q=sankey+edge+custom
*/
echartsCurve.buildPath = (ctx: CanvasRenderingContext2D) =>
buildPath(ctx, edgeLayout, curveness);
echartsCurve.dirtyShape();
});
}
}
export default SankeyView;

View File

@@ -1,3 +1,16 @@
declare module "echarts/lib/chart/graph/install" {
export const install: EChartsExtensionInstaller;
}
declare module "echarts/lib/util/graphic" {
export * from "echarts/types/src/util/graphic";
}
declare module "echarts/lib/util/states" {
export * from "echarts/types/src/util/states";
}
declare module "echarts/lib/chart/sankey/SankeyView" {
// eslint-disable-next-line no-restricted-exports
export { default } from "echarts/types/src/chart/sankey/SankeyView";
}

View File

@@ -0,0 +1,329 @@
import { describe, it, expect } from "vitest";
import type { GraphEdge, GraphNode } from "echarts/types/src/data/Graph";
import {
getCoordinateSystem,
isPassThroughNode,
calculateSectionDimensions,
groupNodesBySection,
createSectionNodes,
setNodeSizes,
getNodeDepthInfo,
getEdgeValue,
getPassThroughSections,
createPassThroughNode,
} from "../../../../../src/resources/echarts/components/sankey/sankey-layout";
// Mock types for testing
interface MockGraphNode {
id: string;
hostGraph: {
data: {
getRawDataItem: (index: number) => { depth?: number; id?: string };
};
};
dataIndex: number;
getLayout: () => { depth?: number; value: number };
}
interface MockGraphEdge {
getLayout: () => { value: number };
hostGraph: {
edgeData: {
getRawDataItem: (index: number) => { value?: number };
};
};
dataIndex: number;
node1: MockGraphNode;
node2: MockGraphNode;
}
describe("Sankey Layout Functions", () => {
describe("getCoordinateSystem", () => {
it("should return vertical coordinate system for vertical orientation", () => {
const coords = getCoordinateSystem("vertical");
expect(coords).toEqual({
breadth: "x",
depth: "y",
breadthSize: "dx",
depthSize: "dy",
});
});
it("should return horizontal coordinate system for horizontal orientation", () => {
const coords = getCoordinateSystem("horizontal");
expect(coords).toEqual({
breadth: "y",
depth: "x",
breadthSize: "dy",
depthSize: "dx",
});
});
});
describe("isPassThroughNode", () => {
it("should return true for pass-through nodes", () => {
const passThroughNode = {
passThrough: true,
id: "test",
value: 10,
depth: 1,
};
expect(isPassThroughNode(passThroughNode)).toBe(true);
});
it("should return false for regular nodes", () => {
const regularNode = {
id: "test",
getLayout: () => ({ value: 10, depth: 1 }),
};
expect(isPassThroughNode(regularNode as GraphNode)).toBe(false);
});
});
describe("calculateSectionDimensions", () => {
it("should calculate dimensions for vertical orientation", () => {
const result = calculateSectionDimensions(
"vertical",
800,
600,
[0, 1, 2],
10
);
expect(result.sectionSize).toBe(780); // 800 - 10 * 2
expect(result.sectionDepthSize).toBe(200); // 600 / 3
});
it("should calculate dimensions for horizontal orientation", () => {
const result = calculateSectionDimensions(
"horizontal",
800,
600,
[0, 1, 2],
10
);
expect(result.sectionSize).toBe(580); // 600 - 10 * 2
expect(result.sectionDepthSize).toBe(266.6666666666667); // 800 / 3
});
});
describe("groupNodesBySection", () => {
it("should group nodes by their depth", () => {
const mockNodes: MockGraphNode[] = [
{
id: "node1",
dataIndex: 0,
hostGraph: {
data: {
getRawDataItem: () => ({ depth: 0 }),
},
},
getLayout: () => ({ depth: 0, value: 10 }),
},
{
id: "node2",
dataIndex: 1,
hostGraph: {
data: {
getRawDataItem: () => ({ depth: 1 }),
},
},
getLayout: () => ({ depth: 1, value: 20 }),
},
{
id: "node3",
dataIndex: 2,
hostGraph: {
data: {
getRawDataItem: () => ({ depth: 0 }),
},
},
getLayout: () => ({ depth: 0, value: 15 }),
},
];
const passThroughNodes = [
{ id: "pt1", depth: 1, passThrough: true, value: 5 },
];
const result = groupNodesBySection(
mockNodes as GraphNode[],
passThroughNodes
);
expect(result[0]).toHaveLength(2);
expect(result[0][0].id).toBe("node1");
expect(result[0][1].id).toBe("node3");
expect(result[1]).toHaveLength(2);
expect(result[1][0].id).toBe("node2");
expect(result[1][1].id).toBe("pt1");
});
});
describe("createSectionNodes", () => {
it("should create section nodes from graph nodes", () => {
const mockNodes: MockGraphNode[] = [
{
id: "node1",
dataIndex: 0,
hostGraph: {
data: {
getRawDataItem: () => ({}),
},
},
getLayout: () => ({ value: 10 }),
},
];
const result = createSectionNodes(mockNodes as GraphNode[]);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
node: mockNodes[0],
id: "node1",
value: 10,
x: 0,
y: 0,
dx: 0,
dy: 0,
size: 0,
});
});
it("should handle pass-through nodes", () => {
const passThroughNode = {
id: "pt1",
passThrough: true,
value: 5,
depth: 1,
};
const result = createSectionNodes([passThroughNode]);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(5);
});
});
describe("setNodeSizes", () => {
it("should calculate node sizes correctly", () => {
const nodes = [
{ value: 10, size: 0 } as any,
{ value: 20, size: 0 } as any,
{ value: 30, size: 0 } as any,
];
const result = setNodeSizes(nodes, 50, 60);
expect(result.nodes[0].size).toBe(8); // floor(10 / (60/50)) = floor(10 / 1.2) = 8
expect(result.nodes[1].size).toBe(16); // floor(20 / 1.2) = 16.67 -> 16
expect(result.nodes[2].size).toBe(25); // floor(30 / 1.2) = 25
expect(result.valueToSizeRatio).toBe(1.2);
});
it("should enforce minimum size", () => {
const nodes = [{ value: 0.1, size: 0 } as any];
const result = setNodeSizes(nodes, 50, 5);
expect(result.nodes[0].size).toBe(1); // Minimum size
});
it("should handle deficit adjustment", () => {
const nodes = [
{ value: 1, size: 0 } as any,
{ value: 1, size: 0 } as any,
];
const result = setNodeSizes(nodes, 5, 2);
expect(result.nodes[0].size).toBe(2); // floor(1 / (2/5)) = floor(1 / 0.4) = 2
expect(result.nodes[1].size).toBe(2); // floor(1 / 0.4) = 2
});
});
describe("getNodeDepthInfo", () => {
it("should extract depth information from graph node", () => {
const mockNode: MockGraphNode = {
id: "test",
dataIndex: 0,
hostGraph: {
data: {
getRawDataItem: () => ({ depth: 2 }),
},
},
getLayout: () => ({ depth: 2, value: 10 }),
};
const result = getNodeDepthInfo(mockNode as GraphNode, [0, 1, 2]);
expect(result.depth).toBe(2);
expect(result.depthIndex).toBe(2);
});
it("should default to depth 0 when not specified", () => {
const mockNode: MockGraphNode = {
id: "test",
dataIndex: 0,
hostGraph: {
data: {
getRawDataItem: () => ({}),
},
},
getLayout: () => ({ depth: 0, value: 10 }),
};
const result = getNodeDepthInfo(mockNode as GraphNode, [0, 1, 2]);
expect(result.depth).toBe(0);
expect(result.depthIndex).toBe(0);
});
});
describe("getEdgeValue", () => {
it("should extract value from edge", () => {
const mockEdge: MockGraphEdge = {
getLayout: () => ({ value: 15 }),
hostGraph: {
edgeData: {
getRawDataItem: () => ({ value: 25 }),
},
},
dataIndex: 0,
node1: {} as any,
node2: {} as any,
};
const result = getEdgeValue(mockEdge as GraphEdge);
expect(result).toBe(25);
});
});
describe("getPassThroughSections", () => {
it("should return sections between source and target depths", () => {
const depths = [0, 1, 2, 3, 4];
const result = getPassThroughSections(1, 3, depths);
expect(result).toEqual([2]);
});
it("should return empty array when no sections needed", () => {
const depths = [0, 1, 2];
const result = getPassThroughSections(0, 1, depths);
expect(result).toEqual([]);
});
});
describe("createPassThroughNode", () => {
it("should create a pass-through node", () => {
const result = createPassThroughNode("source-target", "section1", 2, 15);
expect(result).toEqual({
passThrough: true,
id: "source-target-section1-2",
value: 15,
depth: 2,
});
});
});
});