图算法在前端领域考察的较少,通常除非是要写框架或者打包工具对依赖关系处理(DAG)会用到,前端对图算法的考察通常是比较少的,而对于可视化领域而言,图又是必不可少的一种展现方式,其中对于边和节点的展现布局方案结合美学效果会有不一样的算法实现,本文旨在介绍一些常见的通用布局算法,其中的每一个小的布局方案也会有不一样的分支实现前端
简写 | 算法名称 | 分类 | 备注 |
---|---|---|---|
grid | 网格布局算法 | 几何布局 | ![]() |
circle | 环形布局算法 | 几何布局 | ![]() |
concentric | 同心圆布局算法 | 几何布局 | ![]() |
radial | 辐射状布局算法 | 几何布局 | ![]() |
avsdf | 邻接点最小度优先算法(Adjacent Vertex with Smallest Degree First) | 几何布局 | ![]() |
dagre | 有向无环图树布局算法(Directed Acyclic Graph and Trees) | 层级布局 | ![]() |
breadthfirst | 广度优先布局算法 | 层级布局 | ![]() |
elk | Eclipse布局算法(Eclipse Layout Kernel) | 层级布局 | ![]() |
klay | K层布局算法(K Lay) | 层级布局 | ![]() |
fcose | 最快复合弹簧内置布局算法(Fast Compound Spring Embedder) | 力导布局 | ![]() |
cola | 约束布局(Constraint-based Layout) | 力导布局 | ![]() |
cise | 环形弹簧内置布局算法(Circular Spring Embedder) | 力导布局 | ![]() |
elk2 | Eclipse布局算法(Eclipse Layout Kernel) | 力导布局 | ![]() |
euler | 欧拉布局算法 | 力导布局 | ![]() |
spread | 扩展布局算法 | 力导布局 | ![]() |
fruchterman | Fruchterman-Reingold布局算法 | 力导布局 | ![]() |
combo | 混合布局算法 | 力导布局 | ![]() |
mds | 高维数据降维布局算法(Multi Dimensional Scaling) | 其余布局算法 | ![]() |
random | 随机布局算法 | 其余布局 | ![]() |
Fruchterman-Reingold算法属于力导布局的一种,其本质是将以前Eades的布点算法中的基于胡克定律模型进行了改进,使用了库伦斥力而且聚焦在最近相邻节点之间的能量模型,利用模拟退火等优化策略,结合美学标准对总体进行减小线交叉及总体均匀布局,其伪码描述以下图:node
对于更加细节的关于FR算法的推到,能够参看这篇论文Graph Drawing by Force-directed Placement;接下来,咱们来看一下前端可视化领域的一些具体实现,咱们结合Antv G6中的源码看一下实现思路:git
/** * Antv的layout是专门发布了一个npm包 源码地址:https://github.com/antvis/layout * FR算法目录位置 https://github.com/antvis/layout/blob/master/src/layout/fruchterman.ts */ import { OutNode, Edge, PointTuple, IndexMap, Point, FruchtermanLayoutOptions } from "./types"; import { Base } from "./base"; import { isNumber } from "../util"; type NodeMap = { [key: string]: INode; }; type INode = OutNode & { cluster: string; }; const SPEED_DIVISOR = 800; /** * fruchterman 布局 */ export class FruchtermanLayout extends Base { /** 布局中心 */ public center: PointTuple; /** 中止迭代的最大迭代数 */ public maxIteration: number = 1000; /** 重力大小,影响图的紧凑程度 */ public gravity: number = 10; /** 速度 */ public speed: number = 1; /** 是否产生聚类力 */ public clustering: boolean = false; /** 聚类力大小 */ public clusterGravity: number = 10; public nodes: INode[] = []; public edges: Edge[] = []; public width: number = 300; public height: number = 300; public nodeMap: NodeMap = {}; public nodeIdxMap: IndexMap = {}; /** 迭代结束的回调函数 */ public onLayoutEnd: () => void = () => {}; constructor(options?: FruchtermanLayoutOptions) { super(); this.updateCfg(options); } public getDefaultCfg() { return { maxIteration: 1000, gravity: 10, speed: 1, clustering: false, clusterGravity: 10 }; } /** * 执行布局 */ public execute() { const self = this; const nodes = self.nodes; if (!nodes || nodes.length === 0) { if (self.onLayoutEnd) self.onLayoutEnd(); return; } if (!self.width && typeof window !== "undefined") { self.width = window.innerWidth; } if (!self.height && typeof window !== "undefined") { self.height = window.innerHeight; } if (!self.center) { self.center = [self.width / 2, self.height / 2]; } const center = self.center; if (nodes.length === 1) { nodes[0].x = center[0]; nodes[0].y = center[1]; if (self.onLayoutEnd) self.onLayoutEnd(); return; } const nodeMap: NodeMap = {}; const nodeIdxMap: IndexMap = {}; nodes.forEach((node, i) => { if (!isNumber(node.x)) node.x = Math.random() * this.width; if (!isNumber(node.y)) node.y = Math.random() * this.height; nodeMap[node.id] = node; nodeIdxMap[node.id] = i; }); self.nodeMap = nodeMap; self.nodeIdxMap = nodeIdxMap; // layout return self.run(); } public run() { const self = this; const nodes = self.nodes; const edges = self.edges; const maxIteration = self.maxIteration; const center = self.center; const area = self.height * self.width; const maxDisplace = Math.sqrt(area) / 10; const k2 = area / (nodes.length + 1); const k = Math.sqrt(k2); const gravity = self.gravity; const speed = self.speed; const clustering = self.clustering; const clusterMap: { [key: string]: { name: string | number; cx: number; cy: number; count: number; }; } = {}; if (clustering) { nodes.forEach(n => { if (clusterMap[n.cluster] === undefined) { const cluster = { name: n.cluster, cx: 0, cy: 0, count: 0 }; clusterMap[n.cluster] = cluster; } const c = clusterMap[n.cluster]; if (isNumber(n.x)) { c.cx += n.x; } if (isNumber(n.y)) { c.cy += n.y; } c.count++; }); for (const key in clusterMap) { clusterMap[key].cx /= clusterMap[key].count; clusterMap[key].cy /= clusterMap[key].count; } } for (let i = 0; i < maxIteration; i++) { const displacements: Point[] = []; nodes.forEach((_, j) => { displacements[j] = { x: 0, y: 0 }; }); self.applyCalculate(nodes, edges, displacements, k, k2); // gravity for clusters if (clustering) { const clusterGravity = self.clusterGravity || gravity; nodes.forEach((n, j) => { if (!isNumber(n.x) || !isNumber(n.y)) return; const c = clusterMap[n.cluster]; const distLength = Math.sqrt( (n.x - c.cx) * (n.x - c.cx) + (n.y - c.cy) * (n.y - c.cy) ); const gravityForce = k * clusterGravity; displacements[j].x -= (gravityForce * (n.x - c.cx)) / distLength; displacements[j].y -= (gravityForce * (n.y - c.cy)) / distLength; }); for (const key in clusterMap) { clusterMap[key].cx = 0; clusterMap[key].cy = 0; clusterMap[key].count = 0; } nodes.forEach(n => { const c = clusterMap[n.cluster]; if (isNumber(n.x)) { c.cx += n.x; } if (isNumber(n.y)) { c.cy += n.y; } c.count++; }); for (const key in clusterMap) { clusterMap[key].cx /= clusterMap[key].count; clusterMap[key].cy /= clusterMap[key].count; } } // gravity nodes.forEach((n, j) => { if (!isNumber(n.x) || !isNumber(n.y)) return; const gravityForce = 0.01 * k * gravity; displacements[j].x -= gravityForce * (n.x - center[0]); displacements[j].y -= gravityForce * (n.y - center[1]); }); // move nodes.forEach((n, j) => { if (!isNumber(n.x) || !isNumber(n.y)) return; const distLength = Math.sqrt( displacements[j].x * displacements[j].x + displacements[j].y * displacements[j].y ); if (distLength > 0) { // && !n.isFixed() const limitedDist = Math.min( maxDisplace * (speed / SPEED_DIVISOR), distLength ); n.x += (displacements[j].x / distLength) * limitedDist; n.y += (displacements[j].y / distLength) * limitedDist; } }); } if (self.onLayoutEnd) self.onLayoutEnd(); return { nodes, edges }; } private applyCalculate( nodes: INode[], edges: Edge[], displacements: Point[], k: number, k2: number ) { const self = this; self.calRepulsive(nodes, displacements, k2); self.calAttractive(edges, displacements, k); } // 计算斥力 private calRepulsive(nodes: INode[], displacements: Point[], k2: number) { nodes.forEach((v, i) => { displacements[i] = { x: 0, y: 0 }; nodes.forEach((u, j) => { if (i === j) { return; } if ( !isNumber(v.x) || !isNumber(u.x) || !isNumber(v.y) || !isNumber(u.y) ) return; let vecX = v.x - u.x; let vecY = v.y - u.y; let vecLengthSqr = vecX * vecX + vecY * vecY; if (vecLengthSqr === 0) { vecLengthSqr = 1; const sign = i > j ? 1 : -1; vecX = 0.01 * sign; vecY = 0.01 * sign; } // 核心计算项 C常数值 const common = k2 / vecLengthSqr; displacements[i].x += vecX * common; displacements[i].y += vecY * common; }); }); } // 计算引力 private calAttractive(edges: Edge[], displacements: Point[], k: number) { edges.forEach(e => { if (!e.source || !e.target) return; const uIndex = this.nodeIdxMap[e.source]; const vIndex = this.nodeIdxMap[e.target]; if (uIndex === vIndex) { return; } const u = this.nodeMap[e.source]; const v = this.nodeMap[e.target]; if (!isNumber(v.x) || !isNumber(u.x) || !isNumber(v.y) || !isNumber(u.y)) return; const vecX = v.x - u.x; const vecY = v.y - u.y; const vecLength = Math.sqrt(vecX * vecX + vecY * vecY); const common = (vecLength * vecLength) / k; displacements[vIndex].x -= (vecX / vecLength) * common; displacements[vIndex].y -= (vecY / vecLength) * common; displacements[uIndex].x += (vecX / vecLength) * common; displacements[uIndex].y += (vecY / vecLength) * common; }); } public getType() { return "fruchterman"; } }
在dom中布局,咱们最常想到的就是网格布局,在最先的没有div的时代,都是经过table进行布局的,这里图的布局也是最容易想到的一种布局方式,虽然简单,但咱们也能够看一下对应的实现思路,咱们看一下在Cytoscape中的实现方案:github
// grid布局目录位置 https://github.com/cytoscape/cytoscape.js/blob/unstable/src/extensions/layout/grid.js function GridLayout( options ){ this.options = util.extend( {}, defaults, options ); } GridLayout.prototype.run = function(){ let params = this.options; let options = params; let cy = params.cy; let eles = options.eles; let nodes = eles.nodes().not( ':parent' ); if( options.sort ){ nodes = nodes.sort( options.sort ); } let bb = math.makeBoundingBox( options.boundingBox ? options.boundingBox : { x1: 0, y1: 0, w: cy.width(), h: cy.height() } ); if( bb.h === 0 || bb.w === 0 ){ eles.nodes().layoutPositions( this, options, function( ele ){ return { x: bb.x1, y: bb.y1 }; } ); } else { // width/height * splits^2 = cells where splits is number of times to split width let cells = nodes.size(); let splits = Math.sqrt( cells * bb.h / bb.w ); let rows = Math.round( splits ); let cols = Math.round( bb.w / bb.h * splits ); let small = function( val ){ if( val == null ){ return Math.min( rows, cols ); } else { let min = Math.min( rows, cols ); if( min == rows ){ rows = val; } else { cols = val; } } }; let large = function( val ){ if( val == null ){ return Math.max( rows, cols ); } else { let max = Math.max( rows, cols ); if( max == rows ){ rows = val; } else { cols = val; } } }; let oRows = options.rows; let oCols = options.cols != null ? options.cols : options.columns; // if rows or columns were set in options, use those values if( oRows != null && oCols != null ){ rows = oRows; cols = oCols; } else if( oRows != null && oCols == null ){ rows = oRows; cols = Math.ceil( cells / rows ); } else if( oRows == null && oCols != null ){ cols = oCols; rows = Math.ceil( cells / cols ); } // otherwise use the automatic values and adjust accordingly // if rounding was up, see if we can reduce rows or columns else if( cols * rows > cells ){ let sm = small(); let lg = large(); // reducing the small side takes away the most cells, so try it first if( (sm - 1) * lg >= cells ){ small( sm - 1 ); } else if( (lg - 1) * sm >= cells ){ large( lg - 1 ); } } else { // if rounding was too low, add rows or columns while( cols * rows < cells ){ let sm = small(); let lg = large(); // try to add to larger side first (adds less in multiplication) if( (lg + 1) * sm >= cells ){ large( lg + 1 ); } else { small( sm + 1 ); } } } let cellWidth = bb.w / cols; let cellHeight = bb.h / rows; if( options.condense ){ cellWidth = 0; cellHeight = 0; } if( options.avoidOverlap ){ for( let i = 0; i < nodes.length; i++ ){ let node = nodes[ i ]; let pos = node._private.position; if( pos.x == null || pos.y == null ){ // for bb pos.x = 0; pos.y = 0; } let nbb = node.layoutDimensions( options ); let p = options.avoidOverlapPadding; let w = nbb.w + p; let h = nbb.h + p; cellWidth = Math.max( cellWidth, w ); cellHeight = Math.max( cellHeight, h ); } } let cellUsed = {}; // e.g. 'c-0-2' => true let used = function( row, col ){ return cellUsed[ 'c-' + row + '-' + col ] ? true : false; }; let use = function( row, col ){ cellUsed[ 'c-' + row + '-' + col ] = true; }; // to keep track of current cell position let row = 0; let col = 0; let moveToNextCell = function(){ col++; if( col >= cols ){ col = 0; row++; } }; // get a cache of all the manual positions let id2manPos = {}; for( let i = 0; i < nodes.length; i++ ){ let node = nodes[ i ]; let rcPos = options.position( node ); if( rcPos && (rcPos.row !== undefined || rcPos.col !== undefined) ){ // must have at least row or col def'd let pos = { row: rcPos.row, col: rcPos.col }; if( pos.col === undefined ){ // find unused col pos.col = 0; while( used( pos.row, pos.col ) ){ pos.col++; } } else if( pos.row === undefined ){ // find unused row pos.row = 0; while( used( pos.row, pos.col ) ){ pos.row++; } } id2manPos[ node.id() ] = pos; use( pos.row, pos.col ); } } let getPos = function( element, i ){ let x, y; if( element.locked() || element.isParent() ){ return false; } // see if we have a manual position set let rcPos = id2manPos[ element.id() ]; if( rcPos ){ x = rcPos.col * cellWidth + cellWidth / 2 + bb.x1; y = rcPos.row * cellHeight + cellHeight / 2 + bb.y1; } else { // otherwise set automatically while( used( row, col ) ){ moveToNextCell(); } x = col * cellWidth + cellWidth / 2 + bb.x1; y = row * cellHeight + cellHeight / 2 + bb.y1; use( row, col ); moveToNextCell(); } return { x: x, y: y }; }; nodes.layoutPositions( this, options, getPos ); } return this; // chaining }; export default GridLayout;
MDS是Multidimensional Scaling的简称,即为高维数据降维算法,其是一种力导算法高维数据下的稳定下布局的优化,避免数据超载而致使的总体的布局不稳定,上图中方程式通过数学推导化简后(ps:对于具体推导感兴趣的同窗能够看这篇文章图布局算法之Stress Majorization),其伪码描述以下:算法
接下来,咱们来看一下前端的具体实现,来看一下Antv G6中的实现方案:npm
/** * Antv的layout是专门发布了一个npm包 源码地址:https://github.com/antvis/layout * MDS算法目录位置 https://github.com/antvis/layout/blob/master/src/layout/mds.ts */ // ml-matrix是机器学习相关的一些矩阵操做 import { Matrix as MLMatrix, SingularValueDecomposition } from "ml-matrix"; import { PointTuple, OutNode, Edge, Matrix, MDSLayoutOptions } from "./types"; import { floydWarshall, getAdjMatrix, scaleMatrix } from "../util"; import { Base } from "./base"; /** * mds 布局 */ export class MDSLayout extends Base { /** 布局中心 */ public center: PointTuple = [0, 0]; /** 边长度 */ public linkDistance: number = 50; private scaledDistances: Matrix[]; public nodes: OutNode[] = []; public edges: Edge[] = []; /** 迭代结束的回调函数 */ public onLayoutEnd: () => void = () => {}; constructor(options?: MDSLayoutOptions) { super(); this.updateCfg(options); } public getDefaultCfg() { return { center: [0, 0], linkDistance: 50 }; } /** * 执行布局 */ public execute() { const self = this; const { nodes, edges = [] } = self; const center = self.center; if (!nodes || nodes.length === 0) { if (self.onLayoutEnd) self.onLayoutEnd(); return; } if (nodes.length === 1) { nodes[0].x = center[0]; nodes[0].y = center[1]; if (self.onLayoutEnd) self.onLayoutEnd(); return; } const linkDistance = self.linkDistance; // the graph-theoretic distance (shortest path distance) matrix const adjMatrix = getAdjMatrix({ nodes, edges }, false); const distances = floydWarshall(adjMatrix); self.handleInfinity(distances); // scale the ideal edge length acoording to linkDistance const scaledD = scaleMatrix(distances, linkDistance); self.scaledDistances = scaledD; // get positions by MDS const positions = self.runMDS(); self.positions = positions; positions.forEach((p: number[], i: number) => { nodes[i].x = p[0] + center[0]; nodes[i].y = p[1] + center[1]; }); if (self.onLayoutEnd) self.onLayoutEnd(); return { nodes, edges }; } /** * mds 算法 * @return {array} positions 计算后的节点位置数组 */ public runMDS(): PointTuple[] { const self = this; const dimension = 2; const distances = self.scaledDistances; // square distances const M = MLMatrix.mul(MLMatrix.pow(distances, 2), -0.5); // double centre the rows/columns const rowMeans = M.mean("row"); const colMeans = M.mean("column"); const totalMean = M.mean(); M.add(totalMean) .subRowVector(rowMeans) .subColumnVector(colMeans); // take the SVD of the double centred matrix, and return the // points from it const ret = new SingularValueDecomposition(M); const eigenValues = MLMatrix.sqrt(ret.diagonalMatrix).diagonal(); return ret.leftSingularVectors.toJSON().map((row: number[]) => { return MLMatrix.mul([row], [eigenValues]) .toJSON()[0] .splice(0, dimension) as PointTuple; }); } public handleInfinity(distances: Matrix[]) { let maxDistance = -999999; distances.forEach(row => { row.forEach(value => { if (value === Infinity) { return; } if (maxDistance < value) { maxDistance = value; } }); }); distances.forEach((row, i) => { row.forEach((value, j) => { if (value === Infinity) { distances[i][j] = maxDistance; } }); }); } public getType() { return "mds"; } }
可视化图布局是可视化领域一个比较精深的方向,其设计了美学理念、机器学习、数据分析等相关知识,对于智能布局预测,能够结合机器学习等人工智能方法进行处理,业界常见的好比对于OpenOrd的一些大规模图布局的边切优化等,具体感兴趣的同窗能够参看这篇文章OpenOrd-面向大规模图布局的开源算法-研读;对于前端智能化与可视化结合的方面,能够将tensorflow与可视化图布局进行拓展,具体能够看一下G6的图布局预测方案G6 智能布局预测,其大概实现思路是借助tensorflow,对于卷积层和池化层的处理以及相关操做。综上,可视化图布局领域的拓展结合了前端智能化与前端数据可视化两大前端方向,因而可知,前端的七大发展方向并不是是割裂开来的,他们之间相互影响、相互借鉴,对于交叉领域深刻研究或许能有不一样的启发!数组