D3.js(Draggable and Scalable Tree)

2016-10-27 Bruce Wang 更多博文 » 博客 » GitHub »

D3 树状图

原文链接 http://brucewar.cn/2016/10/27/D3-js-Draggable-and-Scalable-Tree/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。


因为最近手上有个小的需求,设计一个可缩放和可拖拽的树形结构,我便去研读了D3官网给的一个树形的例子。

布局(Layout)

原本我以为理解了基本的选择器、元素操作、Enter、Exit就能去看实例的代码了,后来发现我错了,所以这里需要理解一下D3中布局(Layout)的概念。布局是D3中一个十分重要的概念,从布局衍生出很多图表。例如:饼状图(pie)、力导向图(force),树状图(tree)等等,基本实现了很多开源的可视化工具提供的图表。但是它又和很多可视化工具(如Echarts)有很大的不同。

相对于其它工具来说,D3较底层一点,所以初学者可能会觉得有点困难,但是一旦理解了D3布局的思想,使用起来,会比其它工具更加得心应手。首先,我阐释下D3和大部分可视化工具数据到图表的流程:

  • 大部分可视化工具:数据 => 封装好的绘图函数 => 图表
  • D3:数据 => Layout => 绘图所需的数据 => 绘制图形 => 图表

可以看出,D3需要自己去绘制图形,但是可以通过布局函数获得绘图所需要的数据,坏处是对初学者是一个很大的考验,好处是它能帮助我们制作出更加精密的图形。

树状图

回归正题,如何设计一个树形结构,我将从D3官网提供的示例代码分析。

页面代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>tree</title>
    <style>
    body{
        margin: 0;
    }
    svg{
        background-color: #eee;
    }
    .node circle {
      cursor: pointer;
      fill: #fff;
      stroke: steelblue;
      stroke-width: 1.5px;
    }
    .node text {
      font-size: 11px;
    }
    path.link {
      fill: none;
      stroke: #ccc;
      stroke-width: 1.5px;
    }
    g.detail rect{
        fill: #000;
        fill-opacity: .6;
        rx: 5;
        ry: 5;
    }
    g.detail text{
        fill: #fff;
    }
    </style>
</head>
<body>
    <div id="treeContainer"></div>
  <script src="./dist/tree.bundle.js"></script>
</body>
</html>

因为D3示例代码是同步的形式读出整个树形数据结构,我对其进行了改造,模拟异步数据(async_city.json)。

{
    "root": {
        "name": "中国"
    },
    "中国": {
        "name": "中国",
        "children": [
            {"name": "浙江"},
            {"name": "广西"},
            {"name": "黑龙江"},
            {"name": "新疆"}
        ]
    },
    "浙江": {
        "name": "浙江",
        "children": [
            {"name": "杭州"},
            {"name": "宁波"},
            {"name":"温州" },
            {"name":"绍兴" }
        ]
    },
    "广西": {
        "name": "广西",
        "children": [
            {"name": "桂林"},
            {"name": "南宁"},
            {"name": "柳州"},
            {"name": "防城港"}
        ]
    },
    "桂林": {
        "name": "桂林",
        "children": [
            {"name":"秀峰区"},
            {"name":"叠彩区"},
            {"name":"象山区"},
            {"name":"七星区"}
        ]
    },
    "黑龙江": {
            "name":"黑龙江",
            "children":
            [
                    {"name":"哈尔滨"},
                    {"name":"齐齐哈尔"},
                    {"name":"牡丹江"},
                    {"name":"大庆"}
            ]
    },
    "新疆" : {
            "name":"新疆" ,
            "children":
            [
                    {"name":"乌鲁木齐"},
                    {"name":"克拉玛依"},
                    {"name":"吐鲁番"},
                    {"name":"哈密"}
            ]
    }
}

画布

var margin = {
    top: 20,
    left: 50,
    right: 50,
    bottom: 20
};
var width = $(document).width(),
    height = $(document).height(),
    i = 0,
    limit = 2,
    root;

    // draggable and scalable
    var zoomListener = d3.behavior.zoom().scaleExtent([0.1, 3]).on('zoom', zoom);
    function zoom(){
        d3.select('svg').select('g').attr('transform', 'translate(' + d3.event.translate + ')scale(' + d3.event.scale + ')');
    }

    var svg = d3.select("#treeContainer").append("svg")
            .attr("width", width - margin.left - margin.right)
            .attr("height", height - margin.top - margin.bottom)
            .call(zoomListener)
        .append("g")
            .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

获取异步数据

// 异步获取数据
function getData(sd, cb){
    d3.json('data/async_city.json', function(err, json){
        // 通过callback返回部分数据
        cb && cb(json[sd.name]);
    });
}

构造树

// 获取树的root
getData({name: 'root'}, function(json){
    root = json;
    root.x0 = height / 2;
    root.y0 = width / 2;

    // 初始化树根
    update(root);
});

从上面的代码可以看出构造树的核心代码就是这个update函数,下面以注释的形式深入理解树形的构造。

// 新建一个树的布局
var tree = d3.layout.tree()
        .size([height - margin.top - margin.bottom, width - margin.left - margin.right]);

// 因为默认的树布局是自上而下的,这里构建一个自左向右的树,故需要一个转换x和y坐标的函数
var diagonal = d3.svg.diagonal()
        .projection(function(d) { return [d.y, d.x]; });

function update(source) {
    var duration = d3.event && d3.event.altKey ? 5000 : 500;

    /**
    * 这里实际上是通过tree的nodes函数获得树形结构的每个节点的数据,包括位置信息和深度
    * 返回的数据结构如下:
    * [{depth: 0, name: "中国", children: [], x: 380, y: 0}]
    */
    var nodes = tree.nodes(root).reverse();

    // 为了让当前节点居中,故更具当前节点的depth来计算各节点的y坐标(即横向位置)
    var srcDepth = source.depth;
    nodes.forEach(function(d){
        d.y = height / 2 + 180 * (d.depth - srcDepth);
    });

    // Update the nodes…
    var node = svg.selectAll("g.node")
            .data(nodes, function(d) { return d.id || (d.id = ++i); });

    // Enter any new nodes at the parent's previous position.
    var nodeEnter = node.enter().append("g")
            .attr("class", "node")
            .attr("transform", function(d) { return "translate(" + source.y0 + "," + source.x0 + ")"; })
            .on("click", click)
            .on('mouseover', function(d){
                if(d.name == 'more') return;
                // 鼠标hover某个节点时,显示一个详细信息的弹层
                var detail = d3.select(this).append('g')
                        .attr('class', 'detail')
                        .attr('dx', d3.event.x)
                        .attr('dy', d3.event.y + 10);
                detail.append('rect')
                        .attr('width', 100)
                        .attr('height', 100);
                detail.append('text')
                        .attr('dx', '.35em')
                        .attr('dy', '2em')
                        .attr('text-anchor', 'start')
                        .text(function(d){
                            return 'name: ' + d.name;
                        });
            })
            .on('mousemove', function(d){
                var detail = d3.select(this).select('.detail');
                detail.attr('x', d3.event.x)
                        .attr('y', d3.event.y);
            })
            .on('mouseout', function(d){
                if(d.name == 'more') return;
                d3.select(this).select('.detail').remove();
            });

    nodeEnter.append("circle")
            .attr("r", 1e-6)
            .style("fill", function(d){ return !d.isExpand ? "lightsteelblue" : "#fff"; });

    nodeEnter.append("text")
            .attr("x", -10)
            .attr("dy", ".35em")
            .attr("text-anchor", "end")
            .text(function(d) { return d.name; })
            .style("fill-opacity", 1e-6);


    // Transition nodes to their new position.
    var nodeUpdate = node.transition()
            .duration(duration)
            .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; });

    nodeUpdate.select("circle")
            .attr("r", 10)
            .style("fill", function(d){ return !d.isExpand ? "lightsteelblue" : "#fff"; });

    nodeUpdate.select("text")
            .style("fill-opacity", 1);

    // Transition exiting nodes to the parent's new position.
    var nodeExit = node.exit().transition()
            .duration(duration)
            .attr("transform", function(d) {
                if(d.name == 'more') this.remove();
                return "translate(" + source.y + "," + source.x + ")";
            })
            .remove();

    nodeExit.select("circle")
            .attr("r", 1e-6);

    nodeExit.select("text")
            .style("fill-opacity", 1e-6);

    /** Update the links...
    * tree.links方法获取连线节点之间的映射,返回的数据结构如下:
    * [{source: {}, target: {}}]
    */
    var link = svg.selectAll("path.link")
            .data(tree.links(nodes), function(d) { return d.target.id; });

    // Enter any new links at the parent's previous position.
    link.enter().insert("path", "g")
            .attr("class", "link")
            .attr("d", function(d) {
                var o = {x: source.x0, y: source.y0};
                return diagonal({source: o, target: o});
            })
        .transition()
            .duration(duration)
            .attr("d", diagonal);

    // Transition links to their new position.
    link.transition()
            .duration(duration)
            .attr("d", diagonal);

    // Transition exiting nodes to the parent's new position.
    link.exit().transition()
            .duration(duration)
            .attr("d", function(d) {
                if(d.target.name == 'more') this.remove();
                var o = {x: source.x, y: source.y};
                return diagonal({source: o, target: o});
            })
            .remove();

    // Stash the old positions for transition.
    // 记录当前节点所在的位置,为node update提供位移动画
    nodes.forEach(function(d) {
        d.x0 = d.x;
        d.y0 = d.y;
    });
}

function collapse(d){
    delete d._children;
    delete d.isExpand;
    delete d.children;
}
function expand(d){
    getData({name: d.name}, function(json){
        if(json && json.children){
            // 获取到此节点有子节点
            d._children = json.children;
            d.children = d._children.slice(0, limit);
            if(d._children.length > d.children.length){
                d.children.push({'name': 'more'});
            }
        }
        d.isExpand = true;
        update(d);
    });
}

// 异步获取数据
function getData(sd, cb){
    d3.json('data/async_city.json', function(err, json){
        cb && cb(json[sd.name]);
    });
}

function click(d){
    if(d.name == 'more'){
        // 点击更多
        d.parent.children = d.parent._children.slice(0, (d.parent.children.length - 1) + limit);
        if(d.parent._children.length > d.parent.children.length){
            d.parent.children.push({'name': 'more'});
        }
        update(d.parent);
    }else if(d.isExpand && d.children){
        // 点击展开的节点
        collapse(d);
        update(d);
    }else{
        // 点击未展开的点
        expand(d);
    }
}

可以从https://github.com/brucewar/practice-in-D3获取示例代码