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:
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
10
src/resources/echarts/components/sankey/install.ts
Normal file
10
src/resources/echarts/components/sankey/install.ts
Normal 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);
|
||||
}
|
||||
586
src/resources/echarts/components/sankey/sankey-layout.ts
Normal file
586
src/resources/echarts/components/sankey/sankey-layout.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
88
src/resources/echarts/components/sankey/sankey-path.ts
Normal file
88
src/resources/echarts/components/sankey/sankey-path.ts
Normal 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();
|
||||
}
|
||||
48
src/resources/echarts/components/sankey/sankey-view.ts
Normal file
48
src/resources/echarts/components/sankey/sankey-view.ts
Normal 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;
|
||||
13
src/types/echarts.d.ts
vendored
13
src/types/echarts.d.ts
vendored
@@ -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";
|
||||
}
|
||||
|
||||
329
test/resources/echarts/components/sankey/sankey-layout.test.ts
Normal file
329
test/resources/echarts/components/sankey/sankey-layout.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user