| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| import * as layout from '../../util/layout'; | |
| import * as zrUtil from 'zrender/src/core/util'; | |
| import {groupData} from '../../util/model'; | |
| import ExtensionAPI from '../../core/ExtensionAPI'; | |
| import SankeySeriesModel, { SankeySeriesOption, SankeyNodeItemOption } from './SankeySeries'; | |
| import { GraphNode, GraphEdge } from '../../data/Graph'; | |
| import { LayoutOrient } from '../../util/types'; | |
| import GlobalModel from '../../model/Global'; | |
| export default function sankeyLayout(ecModel: GlobalModel, api: ExtensionAPI) { | |
| ecModel.eachSeriesByType('sankey', function (seriesModel: SankeySeriesModel) { | |
| const nodeWidth = seriesModel.get('nodeWidth'); | |
| const nodeGap = seriesModel.get('nodeGap'); | |
| const layoutInfo = getViewRect(seriesModel, api); | |
| seriesModel.layoutInfo = layoutInfo; | |
| const width = layoutInfo.width; | |
| const height = layoutInfo.height; | |
| const graph = seriesModel.getGraph(); | |
| const nodes = graph.nodes; | |
| const edges = graph.edges; | |
| computeNodeValues(nodes); | |
| const filteredNodes = zrUtil.filter(nodes, function (node) { | |
| return node.getLayout().value === 0; | |
| }); | |
| const iterations = filteredNodes.length !== 0 ? 0 : seriesModel.get('layoutIterations'); | |
| const orient = seriesModel.get('orient'); | |
| const nodeAlign = seriesModel.get('nodeAlign'); | |
| layoutSankey(nodes, edges, nodeWidth, nodeGap, width, height, iterations, orient, nodeAlign); | |
| }); | |
| } | |
| /** | |
| * Get the layout position of the whole view | |
| */ | |
| function getViewRect(seriesModel: SankeySeriesModel, api: ExtensionAPI) { | |
| return layout.getLayoutRect( | |
| seriesModel.getBoxLayoutParams(), { | |
| width: api.getWidth(), | |
| height: api.getHeight() | |
| } | |
| ); | |
| } | |
| function layoutSankey( | |
| nodes: GraphNode[], | |
| edges: GraphEdge[], | |
| nodeWidth: number, | |
| nodeGap: number, | |
| width: number, | |
| height: number, | |
| iterations: number, | |
| orient: LayoutOrient, | |
| nodeAlign: SankeySeriesOption['nodeAlign'] | |
| ) { | |
| computeNodeBreadths(nodes, edges, nodeWidth, width, height, orient, nodeAlign); | |
| computeNodeDepths(nodes, edges, height, width, nodeGap, iterations, orient); | |
| computeEdgeDepths(nodes, orient); | |
| } | |
| /** | |
| * Compute the value of each node by summing the associated edge's value | |
| */ | |
| function computeNodeValues(nodes: GraphNode[]) { | |
| zrUtil.each(nodes, function (node) { | |
| const value1 = sum(node.outEdges, getEdgeValue); | |
| const value2 = sum(node.inEdges, getEdgeValue); | |
| const nodeRawValue = node.getValue() as number || 0; | |
| const value = Math.max(value1, value2, nodeRawValue); | |
| node.setLayout({value: value}, true); | |
| }); | |
| } | |
| /** | |
| * Compute the x-position for each node. | |
| * | |
| * Here we use Kahn algorithm to detect cycle when we traverse | |
| * the node to computer the initial x position. | |
| */ | |
| function computeNodeBreadths( | |
| nodes: GraphNode[], | |
| edges: GraphEdge[], | |
| nodeWidth: number, | |
| width: number, | |
| height: number, | |
| orient: LayoutOrient, | |
| nodeAlign: SankeySeriesOption['nodeAlign'] | |
| ) { | |
| // Used to mark whether the edge is deleted. if it is deleted, | |
| // the value is 0, otherwise it is 1. | |
| const remainEdges = []; | |
| // Storage each node's indegree. | |
| const indegreeArr = []; | |
| // Used to storage the node with indegree is equal to 0. | |
| let zeroIndegrees: GraphNode[] = []; | |
| let nextTargetNode: GraphNode[] = []; | |
| let x = 0; | |
| // let kx = 0; | |
| for (let i = 0; i < edges.length; i++) { | |
| remainEdges[i] = 1; | |
| } | |
| for (let i = 0; i < nodes.length; i++) { | |
| indegreeArr[i] = nodes[i].inEdges.length; | |
| if (indegreeArr[i] === 0) { | |
| zeroIndegrees.push(nodes[i]); | |
| } | |
| } | |
| let maxNodeDepth = -1; | |
| // Traversing nodes using topological sorting to calculate the | |
| // horizontal(if orient === 'horizontal') or vertical(if orient === 'vertical') | |
| // position of the nodes. | |
| while (zeroIndegrees.length) { | |
| for (let idx = 0; idx < zeroIndegrees.length; idx++) { | |
| const node = zeroIndegrees[idx]; | |
| const item = node.hostGraph.data.getRawDataItem(node.dataIndex) as SankeyNodeItemOption; | |
| const isItemDepth = item.depth != null && item.depth >= 0; | |
| if (isItemDepth && item.depth > maxNodeDepth) { | |
| maxNodeDepth = item.depth; | |
| } | |
| node.setLayout({depth: isItemDepth ? item.depth : x}, true); | |
| orient === 'vertical' | |
| ? node.setLayout({dy: nodeWidth}, true) | |
| : node.setLayout({dx: nodeWidth}, true); | |
| for (let edgeIdx = 0; edgeIdx < node.outEdges.length; edgeIdx++) { | |
| const edge = node.outEdges[edgeIdx]; | |
| const indexEdge = edges.indexOf(edge); | |
| remainEdges[indexEdge] = 0; | |
| const targetNode = edge.node2; | |
| const nodeIndex = nodes.indexOf(targetNode); | |
| if (--indegreeArr[nodeIndex] === 0 && nextTargetNode.indexOf(targetNode) < 0) { | |
| nextTargetNode.push(targetNode); | |
| } | |
| } | |
| } | |
| ++x; | |
| zeroIndegrees = nextTargetNode; | |
| nextTargetNode = []; | |
| } | |
| for (let i = 0; i < remainEdges.length; i++) { | |
| if (remainEdges[i] === 1) { | |
| throw new Error('Sankey is a DAG, the original data has cycle!'); | |
| } | |
| } | |
| const maxDepth = maxNodeDepth > x - 1 ? maxNodeDepth : x - 1; | |
| if (nodeAlign && nodeAlign !== 'left') { | |
| adjustNodeWithNodeAlign(nodes, nodeAlign, orient, maxDepth); | |
| } | |
| const kx = orient === 'vertical' | |
| ? (height - nodeWidth) / maxDepth | |
| : (width - nodeWidth) / maxDepth; | |
| scaleNodeBreadths(nodes, kx, orient); | |
| } | |
| function isNodeDepth(node: GraphNode) { | |
| const item = node.hostGraph.data.getRawDataItem(node.dataIndex) as SankeyNodeItemOption; | |
| return item.depth != null && item.depth >= 0; | |
| } | |
| function adjustNodeWithNodeAlign( | |
| nodes: GraphNode[], | |
| nodeAlign: SankeySeriesOption['nodeAlign'], | |
| orient: LayoutOrient, | |
| maxDepth: number | |
| ) { | |
| if (nodeAlign === 'right') { | |
| let nextSourceNode: GraphNode[] = []; | |
| let remainNodes = nodes; | |
| let nodeHeight = 0; | |
| while (remainNodes.length) { | |
| for (let i = 0; i < remainNodes.length; i++) { | |
| const node = remainNodes[i]; | |
| node.setLayout({skNodeHeight: nodeHeight}, true); | |
| for (let j = 0; j < node.inEdges.length; j++) { | |
| const edge = node.inEdges[j]; | |
| if (nextSourceNode.indexOf(edge.node1) < 0) { | |
| nextSourceNode.push(edge.node1); | |
| } | |
| } | |
| } | |
| remainNodes = nextSourceNode; | |
| nextSourceNode = []; | |
| ++nodeHeight; | |
| } | |
| zrUtil.each(nodes, function (node) { | |
| if (!isNodeDepth(node)) { | |
| node.setLayout({depth: Math.max(0, maxDepth - node.getLayout().skNodeHeight)}, true); | |
| } | |
| }); | |
| } | |
| else if (nodeAlign === 'justify') { | |
| moveSinksRight(nodes, maxDepth); | |
| } | |
| } | |
| /** | |
| * All the node without outEgdes are assigned maximum x-position and | |
| * be aligned in the last column. | |
| * | |
| * @param nodes. node of sankey view. | |
| * @param maxDepth. use to assign to node without outEdges as x-position. | |
| */ | |
| function moveSinksRight(nodes: GraphNode[], maxDepth: number) { | |
| zrUtil.each(nodes, function (node) { | |
| if (!isNodeDepth(node) && !node.outEdges.length) { | |
| node.setLayout({depth: maxDepth}, true); | |
| } | |
| }); | |
| } | |
| /** | |
| * Scale node x-position to the width | |
| * | |
| * @param nodes node of sankey view | |
| * @param kx multiple used to scale nodes | |
| */ | |
| function scaleNodeBreadths(nodes: GraphNode[], kx: number, orient: LayoutOrient) { | |
| zrUtil.each(nodes, function (node) { | |
| const nodeDepth = node.getLayout().depth * kx; | |
| orient === 'vertical' | |
| ? node.setLayout({y: nodeDepth}, true) | |
| : node.setLayout({x: nodeDepth}, true); | |
| }); | |
| } | |
| /** | |
| * Using Gauss-Seidel iterations method to compute the node depth(y-position) | |
| * | |
| * @param nodes node of sankey view | |
| * @param edges edge of sankey view | |
| * @param height the whole height of the area to draw the view | |
| * @param nodeGap the vertical distance between two nodes | |
| * in the same column. | |
| * @param iterations the number of iterations for the algorithm | |
| */ | |
| function computeNodeDepths( | |
| nodes: GraphNode[], | |
| edges: GraphEdge[], | |
| height: number, | |
| width: number, | |
| nodeGap: number, | |
| iterations: number, | |
| orient: LayoutOrient | |
| ) { | |
| const nodesByBreadth = prepareNodesByBreadth(nodes, orient); | |
| initializeNodeDepth(nodesByBreadth, edges, height, width, nodeGap, orient); | |
| resolveCollisions(nodesByBreadth, nodeGap, height, width, orient); | |
| for (let alpha = 1; iterations > 0; iterations--) { | |
| // 0.99 is a experience parameter, ensure that each iterations of | |
| // changes as small as possible. | |
| alpha *= 0.99; | |
| relaxRightToLeft(nodesByBreadth, alpha, orient); | |
| resolveCollisions(nodesByBreadth, nodeGap, height, width, orient); | |
| relaxLeftToRight(nodesByBreadth, alpha, orient); | |
| resolveCollisions(nodesByBreadth, nodeGap, height, width, orient); | |
| } | |
| } | |
| function prepareNodesByBreadth(nodes: GraphNode[], orient: LayoutOrient) { | |
| const nodesByBreadth: GraphNode[][] = []; | |
| const keyAttr = orient === 'vertical' ? 'y' : 'x'; | |
| const groupResult = groupData(nodes, function (node) { | |
| return node.getLayout()[keyAttr] as number; | |
| }); | |
| groupResult.keys.sort(function (a, b) { | |
| return a - b; | |
| }); | |
| zrUtil.each(groupResult.keys, function (key) { | |
| nodesByBreadth.push(groupResult.buckets.get(key)); | |
| }); | |
| return nodesByBreadth; | |
| } | |
| /** | |
| * Compute the original y-position for each node | |
| */ | |
| function initializeNodeDepth( | |
| nodesByBreadth: GraphNode[][], | |
| edges: GraphEdge[], | |
| height: number, | |
| width: number, | |
| nodeGap: number, | |
| orient: LayoutOrient | |
| ) { | |
| let minKy = Infinity; | |
| zrUtil.each(nodesByBreadth, function (nodes) { | |
| const n = nodes.length; | |
| let sum = 0; | |
| zrUtil.each(nodes, function (node) { | |
| sum += node.getLayout().value; | |
| }); | |
| const ky = orient === 'vertical' | |
| ? (width - (n - 1) * nodeGap) / sum | |
| : (height - (n - 1) * nodeGap) / sum; | |
| if (ky < minKy) { | |
| minKy = ky; | |
| } | |
| }); | |
| zrUtil.each(nodesByBreadth, function (nodes) { | |
| zrUtil.each(nodes, function (node, i) { | |
| const nodeDy = node.getLayout().value * minKy; | |
| if (orient === 'vertical') { | |
| node.setLayout({x: i}, true); | |
| node.setLayout({dx: nodeDy}, true); | |
| } | |
| else { | |
| node.setLayout({y: i}, true); | |
| node.setLayout({dy: nodeDy}, true); | |
| } | |
| }); | |
| }); | |
| zrUtil.each(edges, function (edge) { | |
| const edgeDy = +edge.getValue() * minKy; | |
| edge.setLayout({dy: edgeDy}, true); | |
| }); | |
| } | |
| /** | |
| * Resolve the collision of initialized depth (y-position) | |
| */ | |
| function resolveCollisions( | |
| nodesByBreadth: GraphNode[][], | |
| nodeGap: number, | |
| height: number, | |
| width: number, | |
| orient: LayoutOrient | |
| ) { | |
| const keyAttr = orient === 'vertical' ? 'x' : 'y'; | |
| zrUtil.each(nodesByBreadth, function (nodes) { | |
| nodes.sort(function (a, b) { | |
| return a.getLayout()[keyAttr] - b.getLayout()[keyAttr]; | |
| }); | |
| let nodeX; | |
| let node; | |
| let dy; | |
| let y0 = 0; | |
| const n = nodes.length; | |
| const nodeDyAttr = orient === 'vertical' ? 'dx' : 'dy'; | |
| for (let i = 0; i < n; i++) { | |
| node = nodes[i]; | |
| dy = y0 - node.getLayout()[keyAttr]; | |
| if (dy > 0) { | |
| nodeX = node.getLayout()[keyAttr] + dy; | |
| orient === 'vertical' | |
| ? node.setLayout({x: nodeX}, true) | |
| : node.setLayout({y: nodeX}, true); | |
| } | |
| y0 = node.getLayout()[keyAttr] + node.getLayout()[nodeDyAttr] + nodeGap; | |
| } | |
| const viewWidth = orient === 'vertical' ? width : height; | |
| // If the bottommost node goes outside the bounds, push it back up | |
| dy = y0 - nodeGap - viewWidth; | |
| if (dy > 0) { | |
| nodeX = node.getLayout()[keyAttr] - dy; | |
| orient === 'vertical' | |
| ? node.setLayout({x: nodeX}, true) | |
| : node.setLayout({y: nodeX}, true); | |
| y0 = nodeX; | |
| for (let i = n - 2; i >= 0; --i) { | |
| node = nodes[i]; | |
| dy = node.getLayout()[keyAttr] + node.getLayout()[nodeDyAttr] + nodeGap - y0; | |
| if (dy > 0) { | |
| nodeX = node.getLayout()[keyAttr] - dy; | |
| orient === 'vertical' | |
| ? node.setLayout({x: nodeX}, true) | |
| : node.setLayout({y: nodeX}, true); | |
| } | |
| y0 = node.getLayout()[keyAttr]; | |
| } | |
| } | |
| }); | |
| } | |
| /** | |
| * Change the y-position of the nodes, except most the right side nodes | |
| * @param nodesByBreadth | |
| * @param alpha parameter used to adjust the nodes y-position | |
| */ | |
| function relaxRightToLeft( | |
| nodesByBreadth: GraphNode[][], | |
| alpha: number, | |
| orient: LayoutOrient | |
| ) { | |
| zrUtil.each(nodesByBreadth.slice().reverse(), function (nodes) { | |
| zrUtil.each(nodes, function (node) { | |
| if (node.outEdges.length) { | |
| let y = sum(node.outEdges, weightedTarget, orient) | |
| / sum(node.outEdges, getEdgeValue); | |
| if (isNaN(y)) { | |
| const len = node.outEdges.length; | |
| y = len ? sum(node.outEdges, centerTarget, orient) / len : 0; | |
| } | |
| if (orient === 'vertical') { | |
| const nodeX = node.getLayout().x + (y - center(node, orient)) * alpha; | |
| node.setLayout({x: nodeX}, true); | |
| } | |
| else { | |
| const nodeY = node.getLayout().y + (y - center(node, orient)) * alpha; | |
| node.setLayout({y: nodeY}, true); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| function weightedTarget(edge: GraphEdge, orient: LayoutOrient) { | |
| return center(edge.node2, orient) * (edge.getValue() as number); | |
| } | |
| function centerTarget(edge: GraphEdge, orient: LayoutOrient) { | |
| return center(edge.node2, orient); | |
| } | |
| function weightedSource(edge: GraphEdge, orient: LayoutOrient) { | |
| return center(edge.node1, orient) * (edge.getValue() as number); | |
| } | |
| function centerSource(edge: GraphEdge, orient: LayoutOrient) { | |
| return center(edge.node1, orient); | |
| } | |
| function center(node: GraphNode, orient: LayoutOrient) { | |
| return orient === 'vertical' | |
| ? node.getLayout().x + node.getLayout().dx / 2 | |
| : node.getLayout().y + node.getLayout().dy / 2; | |
| } | |
| function getEdgeValue(edge: GraphEdge) { | |
| return edge.getValue() as number; | |
| } | |
| function sum<T>(array: T[], cb: (item: T, orient?: LayoutOrient) => number, orient?: LayoutOrient) { | |
| let sum = 0; | |
| const len = array.length; | |
| let i = -1; | |
| while (++i < len) { | |
| const value = +cb(array[i], orient); | |
| if (!isNaN(value)) { | |
| sum += value; | |
| } | |
| } | |
| return sum; | |
| } | |
| /** | |
| * Change the y-position of the nodes, except most the left side nodes | |
| */ | |
| function relaxLeftToRight(nodesByBreadth: GraphNode[][], alpha: number, orient: LayoutOrient) { | |
| zrUtil.each(nodesByBreadth, function (nodes) { | |
| zrUtil.each(nodes, function (node) { | |
| if (node.inEdges.length) { | |
| let y = sum(node.inEdges, weightedSource, orient) | |
| / sum(node.inEdges, getEdgeValue); | |
| if (isNaN(y)) { | |
| const len = node.inEdges.length; | |
| y = len ? sum(node.inEdges, centerSource, orient) / len : 0; | |
| } | |
| if (orient === 'vertical') { | |
| const nodeX = node.getLayout().x + (y - center(node, orient)) * alpha; | |
| node.setLayout({x: nodeX}, true); | |
| } | |
| else { | |
| const nodeY = node.getLayout().y + (y - center(node, orient)) * alpha; | |
| node.setLayout({y: nodeY}, true); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Compute the depth(y-position) of each edge | |
| */ | |
| function computeEdgeDepths(nodes: GraphNode[], orient: LayoutOrient) { | |
| const keyAttr = orient === 'vertical' ? 'x' : 'y'; | |
| zrUtil.each(nodes, function (node) { | |
| node.outEdges.sort(function (a, b) { | |
| return a.node2.getLayout()[keyAttr] - b.node2.getLayout()[keyAttr]; | |
| }); | |
| node.inEdges.sort(function (a, b) { | |
| return a.node1.getLayout()[keyAttr] - b.node1.getLayout()[keyAttr]; | |
| }); | |
| }); | |
| zrUtil.each(nodes, function (node) { | |
| let sy = 0; | |
| let ty = 0; | |
| zrUtil.each(node.outEdges, function (edge) { | |
| edge.setLayout({sy: sy}, true); | |
| sy += edge.getLayout().dy; | |
| }); | |
| zrUtil.each(node.inEdges, function (edge) { | |
| edge.setLayout({ty: ty}, true); | |
| ty += edge.getLayout().dy; | |
| }); | |
| }); | |
| } |