工作中遇到了要可视化一个树结构数据的需求,自己瞎鼓捣一通倒是也勉强实现了这样的需求,但是并不美观,随后在网上冲浪的时候发现了一个叫Reingold-Tilford的树结构可视化算法,便研究了一下,随便记录一下自己的理解。
这个算法上网搜一下是可以搜到作者写的论文的,不过硬看纯英文论文对我来说还是有亿点难度的,所以研究它的论文还是算了
找资料的时候看到两篇很好的文章解释这个算法,对我的理解有很大启发
rachel53461.wordpress.com/2014/04/20/...
第一篇是中文文章,写的也挺清楚的,不过可能是我太菜了所以我感觉读起来还是有点抽象的,不太跟得上,不过它推荐了一个英文的博客,也就是上面的第二篇,写得更通俗易懂。
以下是我个人对这个算法的理解,参考了以上两篇文章,主要是第二篇
1. 设定
TR算法对最终画出来的树结构给了一些规范限制
- 所有同层节点必须被放置在同一水平高度
- 父节点需相对于它所有的子节点居中
- 相邻的且都拥有子树的节点需在保证所有子树画出来不会重叠的前提下尽量紧挨
以及一些其他的规范,具体还有哪些我忘了,那篇中文的文章里有很详细的描述
2. 步骤
具体到代码实现的步骤,如下所示
-
后序遍历这棵树(确保先处理子节点再处理父节点,因为父节点有些东西是要根据子节点的情况来调整的)
-
给每个节点的x坐标赋值,如果它是一个最左节点,它的x值为0,否则为它的左兄弟节点的x的值加一(或者加预先设定的相邻节点间隔值)
-
对每一个父节点,我们需要将它置于相对于它所有子节点水平居中的位置(也就是最左子节点的X坐标与最右子节点X坐标的中间值,相加除二就行)
如果父节点本身没有左兄弟节点 ,那就直接改父节点的X坐标。如果父节点是有左兄弟节点的,那就不是要修改父节点的x坐标以使它居中,而是要修改它的所有子节点的X坐标,让它们偏移到保证父节点是相对于它们水平居中的位置。
具体做法是:如果这个父节点没有左兄弟节点,就将它的x值改成根据子节点的X坐标值算出来的居中的值。否则(它有左兄弟节点),那它需要增加一个"mod "属性,这个属性是一个数字,代表它的所有子节点应该往右偏移多少,才能保证它们是均匀分布在父节点两边(也就是保证父节点是相对于它们水平居中)
根据第一步赋值X坐标,各个点的X坐标将会是这个样子
此时对于b来说,它的X坐标应该修改为0.5
对于c来说,它的坐标不改变,而是计算它的子节点的X坐标应该怎么偏移,也就是f和g的,f的X坐标应该改成0.5,g的X坐标应该改成1.5,那么这个偏移量也就是0.5,这个0.5目前是要记录在c节点中的(也就是"mod"属性),在最后画之前要再遍历一遍整棵树,每个节点就能根据它的父节点的这个偏移量来调整它们各自的X坐标,得到最终正确的结果
这个mod值的计算方法是这样:拿c做例子,先根据它的子节点坐标算出来它如果要居中的话,它的X坐标应该是0.5,那么它的mod值就是 c.x - 0.5
-
处理非叶子节点的互相之间子树重叠的问题,对于每个非叶子节点,检查它每一层子树的轮廓(contour,即这一层最左/小或者最右/大的X坐标)与旁边的子树的同层轮廓是否有重叠,根据重叠情况调整根节点的X坐标以及mod的值
具体做法是:
对于每一个非叶子节点(且它有左兄弟节点),先从它自己开始往下遍历,记下每层的左轮廓,然后从它的最左兄弟节点开始,逐个向右遍历直到它自己,对于每一个左边的兄弟节点,也是往下遍历并记下每一层的右轮廓,然后就拿当前节点的左轮廓与每一个左边的兄弟节点的右轮廓比较,如果有重叠,记下最大的重叠距离,然后当前节点就要往右偏移【最大重叠距离 + 预先设定的相邻节点间隔】距离(记得它的mod值也要加上这个距离)
在处理这一步的时候记得要考虑上mod值,每个节点真正的X坐标是它的X坐标加上它父节点的mod值的和
-
第四步完成后,有可能会出现一种情况:
两边两棵很宽的子树是撑开到合理的样子了,但是中间两个节点堆积在左侧,并不美观
如果将它们均匀分布在两棵宽子树之间,就会好看很多,也就是实现这样的效果
要实现这样其实也很简单,就是在第四步每次偏移完节点后,检查两个节点之间是否还有节点,有的话,就先算出两个节点之间的X坐标距离,然后除【中间夹着的节点个数 +1】,就得到要让中间的节点均匀分布,它们互相之间应该间隔的距离
-
最后再对整棵树进行一次先序遍历,根据mod值算出每个节点的真正的X坐标
3. 实现
以下是参考资料后自己的代码实现,用的typescript,有很多自己的理解和个人代码风格,可能不一定完全健壮且正确,有不对的地方再改吧
typescript
// 树节点结构
type TreeNode = {
name?: string;
x?: number;
y?: number;
mod?: number;
leftSibling?: TreeNode;
root?: TreeNode;
index?: number;
children?: Array<TreeNode>;
}
然后是第一轮遍历
typescript
// 父节点与子节点间垂直间隔
const MIN_VERTICAL_GAP = 40
// 相邻节点间隔
const MIN_SIBLING_GAP = 30
function isEmpty(arr: Array<any>): boolean {
return !Array.isArray(arr) || arr.length == 0
}
function setInitialX(node: TreeNode, depth: number = 0, index: number = 0) {
node.x = 0
node.y = depth * MIN_VERTICAL_GAP
node.index = index
node.mod = 0
if (!isEmpty(node.children)) {
let leftSibling: TreeNode
depth++
// 后序遍历,先遍历子节点
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (leftSibling) child.leftSibling = leftSibling
child.root = node
leftSibling = child
setInitialX(child, depth, i)
}
}
if (node.leftSibling) node.x = node.leftSibling.x + MIN_SIBLING_GAP
if (!isEmpty(node.children)) {
// 所有子节点的x坐标的中间值
const midX = (node.children[0].x + node.children[node.children.length - 1].x) / 2
if (node.leftSibling) {
node.mod = node.x - midX
// todo 检查与左边的兄弟子树是否有重叠,处理重叠
checkForConflicts(node)
}
else {
node.x = midX
}
}
}
检查重叠
typescript
function checkForConflicts(node: TreeNode) {
const leftContoursOfCurrentNode = [] // 当前节点的每层左轮廓
getLeftContours(node, leftContoursOfCurrentNode) // 填充轮廓数组
let totalShift = 0 // 当前节点整棵子树向右偏移的距离
// 从最左兄弟节点开始向右遍历
for (let i = 0; i < node.index; i++) {
const left = node.root.children[i]
if (isEmpty(left.children)) continue
const rightContoursOfLeftSibling = [] // 当前左兄弟节点的每层右轮廓
getRightContours(left, rightContoursOfLeftSibling) // 填充轮廓数组
// 选取两棵子树之间
const minHeight = Math.min(rightContoursOfLeftSibling.length, leftContoursOfCurrentNode.length)
let shiftVal = 0
for (let i = 1; i < minHeight; i++) {
const rightContour = rightContoursOfLeftSibling[i]
const leftContour = leftContoursOfCurrentNode[i] + totalShift
const diff = leftContour - rightContour
if (diff < MIN_SIBLING_GAP) {
shiftVal = Math.max(shiftVal, MIN_SIBLING_GAP - diff)
}
}
if (shiftVal > 0) {
node.x += shiftVal
node.mod += shiftVal // mod也要修改,因为mod是当前节点的所有子节点的偏移距离
totalShift += shiftVal
if (left.index + 1 < node.index) evenGapsBetween(left, node) // 使两个节点之间夹着的其他节点均匀分布
}
}
}
function getRightContours(node: TreeNode, contours: Array<number>, modSum: number = 0, depth: number = 0) {
contours[depth] = Math.max(contours[depth] ?? (node.x + modSum), node.x + modSum)
modSum += node.mod // 因为树是递归结构,所以往下遍历时mod要累加,对于每个节点来说它的偏移是它所有父节点的mod的累加
depth++
if (!isEmpty(node.children)) {
for (const child of node.children) {
getRightContours(child, contours, modSum, depth)
}
}
}
function getLeftContours(node: TreeNode, contours: Array<number>, modSum: number = 0, depth: number = 0) {
contours[depth] = Math.min(contours[depth] ?? (node.x + modSum), node.x + modSum)
modSum += node.mod
depth++
if (!isEmpty(node.children)) {
for (const child of node.children) {
getLeftContours(child, contours, modSum, depth)
}
}
}
均匀分布中间夹着的节点
typescript
function evenGapsBetween(leftNode: TreeNode, rightNode: TreeNode) {
const root = rightNode.root
const distance = rightNode.x - leftNode.x
const gap = distance / (rightNode.index - leftNode.index)
for (let i = leftNode.index + 1; i < rightNode.index; i++) {
const subTree = root.children[i];
const destinationX = leftNode.x + (i - leftNode.index) * gap
const diff = destinationX - subTree.x
if (diff > 0) {
subTree.x += diff
subTree.mod += diff
}
}
}
最后一次pre-order traversal计算每个节点的实际坐标
typescript
function setFinalX(node: TreeNode, modSum: number = 0) {
node.x += modSum
modSum += node.mod
if (!isEmpty(node.children)) {
for (const child of node.children) {
setFinalX(child, modSum)
}
}
}
配合canvas可以画出树的结构
typescript
const tree: TreeNode = {
name: 'o',
children: [
{
name: 'a',
children: [
{
name: 'b'
},{
name: 'c',
children: [
{
name: 'g'
},{
name: 'h'
},{
name: 'i'
}
]
}
]
},{
name: 'c',
},{
name: 'd',
children: [{
name: 'e',
},{
name: 'f',
}
]
}
]
}
window.addEventListener('load', () => {
/*
<canvas id="canvas" width="1000" height="1000"></canvas>
*/
const canvas = document.getElementById("canvas") as HTMLCanvasElement
const ctx = canvas.getContext("2d")
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
ctx.fillStyle = "green"
ctx.font = 'normal 24px serif'
ctx.translate(200, 50)
setInitialX(tree)
setFinalX(tree)
function drawLines(node: TreeNode) {
if (!isEmpty(node.children)) {
for (const child of node.children) {
ctx.moveTo(node.x, node.y)
ctx.lineTo(child.x, child.y)
drawLines(child)
}
}
}
drawLines(tree)
ctx.stroke()
function drawNodes(node: TreeNode) {
ctx.fillRect(node.x - 20, node.y - 20, 40, 40)
ctx.save()
ctx.fillStyle = 'red'
ctx.fillText(node.name, node.x, node.y)
ctx.restore()
if (!isEmpty(node.children)) {
for (const child of node.children) {
drawNodes(child)
}
}
}
drawNodes(tree)
})