/* * 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(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; }); }); }