树形结构的布局几何 / 吸引性地布置节点

7

我有一个类似树形结构的数据结构,希望在SVG画布上绘制它(使用jQuery SVG)。我希望以一种有吸引力的间距方式从上到下渲染节点。

理想情况下,我需要一个类或函数,可以向其中传递树的表示,并获得每个节点的X和Y坐标。不幸的是,我的Google搜索被成千上万的列表树GUI小部件所挫败,这不是我想要的。

理想情况下,这将是一些Javascript代码,但我也可以使用一些Python代码(用于服务器端)。我看了一下Github上的这个 Python代码,但我真的无法理解它的用法(或使其运行)。我还简要地查看了ete2a1,但它未能正确安装,文档也未完成,而且它似乎更适合实际呈现树形结构为图像,而不是进行几何计算。

如果这是一个模糊的问题,我很抱歉,请让我知道如何使它更清晰。基本上,我有这个:

Tree("Root",
    Tree("leaf 1",
        Tree("leaf 2",
            Tree("leaf 4"),
                Tree("leaf 5")), Tree("leaf 3",
                                    Tree("leaf 6"))))

这将呈现为:

          Root
            |
          leaf 1
            |
            /\
           /  \
      leaf 2  leaf 3
        /\      \
       /  \      \
  leaf 4  leaf 5  leaf 6

唔,可能不太准确,但你大致明白了。

非常感谢任何建议!


3
你考虑过使用Dot吗?(这里有它的Python绑定库:http://code.google.com/p/pydot/) - Oliver Charlesworth
@Oli Charlesworth - 谢谢,我会看一下。我希望能找到一个相对轻量级的替代方案来代替GraphViz,但这个看起来确实是一个潜在的解决方案。 - Mikesname
这是我希望从Python中完成的事情,希望其他人也有一些相关经验。 - shuttle87
@shuttle87:正如Oli Charlesworth建议的那样,我最终使用了带有pydot绑定的Dot。然而,这从来都不是一个特别方便的解决方案,因为(至少在两年前)pydot的文档并不完善,并且似乎仍然需要编写Dot文件并再次读取它们以获取节点位置属性。如果有更好的解决方案,我将保持这个问题开放。 - Mikesname
3个回答

5

强烈推荐使用d3.js。这个可折叠树状图非常适合处理大型数据集。还有一个普通的树状图。非常容易使用,特别是对于有jQuery背景的人。


3

我之前写过这个,找出我以前用javascript编写的实现。

假设所有节点都具有相同的宽度,但是它将很好地推广到每个节点都含有自定义宽度的情况。只需查找 node_size 引用并相应地推广即可。

以下是包含嵌入式javascript的完整HTML文件。 刷新浏览器会生成一个新的随机树并绘制它。但是您可以忽略我的绘图例程,它使用旧的CSS技巧来呈现框和线。坐标存储在树节点中。

这里是一个示例输出。 它显示了唯一的显着弱点。 它是“左贪心”的。 就像以60为根的子树可以左右滑动一样,它将始终尽可能向左。 专用的hack解决了这个问题,例如父节点非叶子之间的叶节点73。 通用解决方案更加棘手。 Example random tree drawing from HTML code

<html>
<head>
  <style>
    .node {
      position: absolute;
      background-color: #0000cc;
      color: #ffffff;
      font-size: 12px;
      font-family: sans-serif;
      text-align: center;
      vertical-align: middle;
      border: 1px solid #000088;
    }

    .dot {
      position: absolute;
      background-color: black;
      width: 1px;
      height: 1px;
      overflow:hidden;
    }
  </style>
  <script>

    var node_size = 18
    var horizontal_gap = 16
    var vertical_gap = 32

    // Draw a graph node.
    function node(lbl, x, y, sz) {
      if (!sz) sz = node_size
      var h = sz / 2
      document.write('<div class="node" style="left:' + (x - h) + 'px;top:' + (y - h) +
          'px;width:' + sz + 'px;height:' + sz + 'px;line-height:' + sz +
          'px;">' + lbl + '</div>')
    }

    // Draw a 1-pixel black dot.
    function dot(x, y) {
      document.write('<div class="dot" style="left:' + x + 'px;top:' + y + 'px;"></div>')
    }

    // Draw a line between two points.  Slow but sure...
    function arc(x0, y0, x1, y1) {
      var dx = x1 - x0
      var dy = y1 - y0
      var x = x0
      var y = y0
      if (abs(dx) > abs(dy)) {
        var yinc = dy / dx
        if (dx < 0)
          while (x >= x1) { dot(x, y); --x; y -= yinc }
        else
          while (x <= x1) { dot(x, y); ++x; y += yinc }
      }
      else {
        var xinc = dx / dy
        if (dy < 0)
          while (y >= y1) { dot(x, y); --y; x -= xinc }
        else
          while (y <= y1) { dot(x, y); ++y; x += xinc }
      }
    }

    // Tree node.
    function Tree(lbl, children) {
      this.lbl = lbl
      this.children = children ? children : []
      // This will be filled with the x-offset of this node wrt its parent.
      this.offset = 0
      // Optional coordinates that can be written by place(x, y)
      this.x = 0
      this.y = 0
    }

    Tree.prototype.is_leaf = function() { return this.children.length == 0 }

    // Label the tree with given root (x,y) coordinates using the offset
    // information created by extent().
    Tree.prototype.place = function(x, y) {
      var n_children = this.children.length
      var y_children = y + vertical_gap + node_size
      for (var i = 0; i < n_children; i++) {
        var child = this.children[i]
        child.place(x + child.offset, y_children)
      }
      this.x = x
      this.y = y
    }

    // Draw the tree after it has been labeled w ith extent() and place().
    Tree.prototype.draw = function () {
      var n_children = this.children.length
      for (var i = 0; i < n_children; i++) {
        var child = this.children[i]
        arc(this.x, this.y + 0.5 * node_size + 2, child.x, child.y - 0.5 * node_size)
        child.draw()
      }
      node(this.lbl, this.x, this.y)
    }

    // Recursively assign offsets to subtrees and return an extent
    // that gives the shape of this tree.
    //
    // An extent is an array of left-right x-coordinate ranges,
    // one element per tree level.  The root of the tree is at
    // the origin of its coordinate system.
    //
    // We merge successive extents by finding the minimum shift
    // to the right that will cause the extent being merged to
    // not overlap any of the previous ones.
    Tree.prototype.extent = function() {
      var n_children = this.children.length

      // Get the extents of the children
      var child_extents = []
      for (var i = 0; i < n_children; i++)
        child_extents.push(this.children[i].extent())

      // Compute a minimum non-overlapping x-offset for each extent
      var rightmost = []
      var offset = 0
      for (i = 0; i < n_children; i++) {
        var ext = child_extents[i]
        // Find the necessary offset.
        offset = 0
        for (var j = 0; j < min(ext.length, rightmost.length); j++)
          offset = max(offset, rightmost[j] - ext[j][0] + horizontal_gap)
        // Update rightmost
        for (var j = 0; j < ext.length; j++)
          if (j < rightmost.length)
            rightmost[j] = offset + ext[j][1]
          else
            rightmost.push(offset + ext[j][1])
        this.children[i].offset = offset
      }
      rightmost = null  // Gc, come get it.

      // Center leaves between non-leaf siblings with a tiny state machine.
      // This is optional, but eliminates a minor leftward skew in appearance.
      var state = 0
      var i0 = 0
      for (i = 0; i < n_children; i++) {
        if (state == 0) {
          state = this.children[i].is_leaf() ? 3 : 1
        } else if (state == 1) {
          if (this.children[i].is_leaf()) {
            state = 2
            i0 = i - 1 // Found leaf after non-leaf. Remember the non-leaf.
          }
        } else if (state == 2) {
          if (!this.children[i].is_leaf()) {
            state = 1  // Found matching non-leaf. Reposition the leaves between.
            var dofs = (this.children[i].offset - this.children[i0].offset) / (i - i0)
            offset = this.children[i0].offset
            for (j = i0 + 1; j < i; j++)
              this.children[j].offset = (offset += dofs)
          }
        } else {
          if (!this.children[i].is_leaf()) state = 1
        }
      }

      // Adjust to center the root on its children
      for (i = 0; i < n_children; i++)
        this.children[i].offset -= 0.5 * offset

      // Merge the offset extents of the children into one for this tree
      var rtn = [ [-0.5 * node_size, 0.5 * node_size] ]
      // Keep track of subtrees currently on left and right edges.
      var lft = 0
      var rgt = n_children - 1
      i = 0
      for (i = 0; lft <= rgt; i++) {
        while (lft <= rgt && i >= child_extents[lft].length) ++lft
        while (lft <= rgt && i >= child_extents[rgt].length) --rgt
        if (lft > rgt) break
        var x0 = child_extents[lft][i][0] + this.children[lft].offset
        var x1 = child_extents[rgt][i][1] + this.children[rgt].offset
        rtn.push([x0, x1])
      }
      return rtn
    }

    // Return what the bounding box for the tree would be if it were drawn at (0,0).
    // To place it at the upper left corner of a div, draw at (-bb[0], -bb[1])
    // The box is given as [x_left, y_top, width, height]
    function bounding_box(extent) {
      var x0 = extent[0][0]
      var x1 = extent[0][1]
      for (var i = 1; i < extent.length; i++) {
        x0 = min(x0, extent[i][0])
        x1 = max(x1, extent[i][1])
      }
      return [x0, -0.5 * node_size, x1 - x0, (node_size + vertical_gap) * extent.length - vertical_gap ]
    }

    function min(x, y) { return x < y ? x : y }
    function max(x, y) { return x > y ? x : y }
    function abs(x) { return x < 0 ? -x : x }

    // Generate a random tree with given depth and minimum number of children of the root.
    // The min_children field is optional.  Use e.g. 2 to avoid trivial trees.
    var node_label = 0
    function random_tree(depth, min_children) {
      var n_children = depth <= 1 || Math.random() < 0.5 ? 0 : Math.round(Math.random() * 4)
      if (min_children) n_children = max(n_children, min_children)
      var children = []
      for (var i = 0; i < n_children; i++)
        children.push(random_tree(depth - 1, min_children - 1))
      return new Tree('' + node_label++, children)
    }
  </script>
</head>
<body>
<div style="width:1000px;height:800px">
  <script>
    // Generate a random tree.
    tree = random_tree(10, 2)

    // Label it with node offsets and get its extent.
    e = tree.extent()

    // Retrieve a bounding box [x,y,width,height] from the extent.
    bb = bounding_box(e)

    // Label each node with its (x,y) coordinate placing root at given location.
    tree.place(-bb[0] + horizontal_gap, -bb[1] + horizontal_gap)

    // Draw using the labels.
    tree.draw()
  </script>
</div>
</body>
</html>

2

我将在此提供一个基于我两年前编写的Python脚本的答案,虽然它远非最佳。如Oli Charlesworth所建议的那样,我使用了PyDot通过Graphviz。完整的脚本已经发布在下面,但首先需要进行设置才能使其正常工作(假设您使用的是virtualenv):

首先,请确保已安装Graphviz。假设您使用的是apt等软件包管理器:

$> sudo apt-get install graphviz

使用virtualenv wrapper创建一个新的虚拟环境:

$> mkvirtualenv pydot

在新的虚拟环境中,将pyparsing版本固定在2.0之前(dot_parser需要使用此版本才能正常工作):
(pydot)$> pip install pyparsing==1.5.7

安装pydot:

(pydot)$> pip install pydot

现在我使用的Python(警告:它不是很好):

import os
import re
import sys
import tempfile
import pydot

TEST_GRAPH = {
    "Root" : {
        "inputs" : [],
    },
    "Leaf1" : {
        "inputs" : ["Root"],
    },
    "Leaf2" : {
        "inputs" : ["Leaf1"],
    },
    "Leaf3" : {
        "inputs" : ["Leaf1"],
    },
    "Leaf4" : {
        "inputs" : ["Leaf2"],
    },
    "Leaf5" : {
        "inputs" : ["Leaf2"],
    },
    "Leaf6" : {
        "inputs" : ["Leaf3"],
    },
}

class DotError(StandardError):
    """Dot problems."""

# http://www.graphviz.org/doc/info/lang.html
RAW_NAME_RE = r"(^[A-Za-z_][a-zA-Z0-9_]*$)|(^-?([.[0-9]+|[0-9]+(.[0-9]*)?)$)"

def conditional_quote(name):
    """Dot quotes names if they match the regex above, otherwise not"""
    if re.match(RAW_NAME_RE, name) is None:
        return "\"%s\"" % name
    return name


def get_node_positions(nodedict, aspect=None):
    """Build the pydot graph, outputting a dictionary of name -> [x,y]"""

    # Tweak positioning parameters as required: 
    g = pydot.Dot(margin="0.1", ranksep="0.7", nodesep="1.5")
    if aspect is not None:
        g.set_aspect(round(aspect))
    for name, node in nodedict.items():
        n = pydot.Node(name, width="0.5", fixedsize="0.5")
        g.add_node(n)

    for name, node in nodedict.items():
        for i in node["inputs"]:
            try:
                src = g.get_node(conditional_quote(i))
                if isinstance(src, list):
                    src = src[0]
                dst = g.get_node(conditional_quote(name))
                if isinstance(dst, list):
                    dst = dst[0]
                g.add_edge(pydot.Edge(src, dst))
            except IndexError:
                print "Input %s not found" % i

    # dot doesn't seem to work on < 4 nodes... prevent it from
    # by just throwing an error
    if len(nodedict) < 4:
        raise DotError("Dot breaks with less than 4 nodes.")

    # Now the particularly horrible bit. Write the script as a dot file,
    # then read it in again and extract the positionings.
    # WARNING: Currently doesn't clean up the temp file.
    with tempfile.NamedTemporaryFile(delete=False, suffix=".dot") as t:
        t.close()
        g.write_dot(t.name)
    g = pydot.graph_from_dot_file(t.name)

    out = {}
    for name, node in nodedict.items():
        gn = g.get_node(conditional_quote(name))
        if isinstance(gn, list):
            gn = gn[0]
        out[name] = [int(d) \
                for d in gn.get_pos().replace('"', "").split(",")]
    return out

if __name__ == "__main__":
    print(get_node_positions(TEST_GRAPH))

尽管这看起来相当丑陋,但它基本上满足了我的需求。如果有人能找到更好的解决方案,我会很感兴趣。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接