手把手教你使用d3.js画一个股权穿透图

前言

作为一个重度拖延症患者和究极懒狗,鸽了这么多天都没有写过新的文章,真是羞愧。这段时间领导布置了一个任务,希望我可以做一个股权穿透图。我本着不重复造轮子的理念,就去github上面去搜了一下,发现还真有一个:

d3.js-tree-2direction.jpg

代码拷贝下来之后,运行了一下,效果图是这个样子的:

d3.js-tree-2direction-master效果图.jpg

只能说一眼丁真:鉴定为丑,于是我运用我之前学过的svg相关知识,将其改造了一番(顺便更换了数据):

股权穿透图v3.jpg

嗯,这样子看起来就好看了不少嘛。

不过,我也只是改变了一些样式,但对其是怎么实现的还并不理解。

为了搞明白是怎么实现的,我看了一些关于d3.js相关的文章,对其有了一个初步的了解。在我看来,d3.js在使用方式上非常类似于jquery。而它之所以可以用于开发可视化图表,是因为其内置了非常多与图形相关的预处理模块。比如path(在canvas或者svg中绘制路径)polygon(提供了基础的二维多边形几个基本的操作)。不过以上这两个模块我也都还没有用过,暂且按下不表。

在这个股权穿透图中,主要用到的模块有selection(选择器)hierarchy(层级化)zoom(缩放或者放大)transition(过渡)

令人有些不爽的是,d3.js-tree-2direction使用的d3.js版本是比较老的v3版本,并没有完善的中文文档。这就导致我查阅相关的api文档的时候,要么翻译不全,要么没有相关的代码用例。读起来相当的吃力。而国内的d3.js中文网已经很久没有更新了,版本停留在了5.x.x

于是后来我注意到d3.js官方推出了一个官方画廊—— Observable,里面有很多作者本人写的示例。在这其中,我找到了最符合我需求的示例——collapsible-tree(可折叠树)。不过,这个示例中,d3的版本已经升级为了v6。所幸,功夫不负有心人,在我投入了大量的精力去阅读这个官方示例代码和api文档之后,我终于搞懂了作者本人的代码编写思路,然后基于d3的v7版本,自己写了一套股权穿透图。接下来,我将详细阐述一下自己的代码逻辑,请诸位请我娓娓道来:

代码逻辑

1、数据处理

(以下示例使用的数据是精简版的数据)

首先,我们将从后台请求过来的数据整理成如下的嵌套结构:

let data = {
    // 根节点名称
    id: "982",
    // 根节点名称
    name: "北京京仪科技有限责任公司",
    // 子节点列表
    children: [
        {
            id:"988",
            name: "北京京仪伍玖科技发展有限公司",
            percent: '100%',
            children: [
                {
                    id:"989",
                    name: "北京瑞利分析仪器有限公司",
                    percent: '100%',
                    children:[
                        {
                            id:"1045",
                            name: "北京瑞利的子公司一",
                            percent: '80%',
                        },
                        {
                            id:"1055",
                            name: "北京瑞利的子公司二",
                            percent: '90%',
                        }
                    ]
                }
            ]
        },
    ]
}
复制代码

然后,使用d3.hierarchy将嵌套结构数据中的每一个节点处理成相同的格式:

let root = d3.hierarchy(data);
复制代码

root打印出来查看其结构:

hierarchy处理过后的数据.jpg

如上图所示,无论是根节点还是子节点,d3.hierarchy都会将其数据本体放入属性data中,并且还会加上depth以表示层级。并且子节点放入children中,父节点放入parent中。height的话表示节点高度,叶节点(即没有子节点的节点)height为0。

接下来,定义一个设置好节点之间距离的tree方法,然后将层级化后的数据root传入进去

// 200是指节点之间的横坐标的距离,170指的父子节点之间的纵坐标之间的距离
let tree = d3.tree().nodeSize([200,170])
tree(root);
复制代码

再将root打印出来查看一下其结构:

tree处理后的数据.jpg

这个时候我们发现tree方法将原来的层级化的数据添加上了两个属性xy,即一个节点的坐标点。而根节点的坐标点为(0,0)

就这样,我们得知了每个节点所在的坐标点,这样的话我们只需要在对应的坐标点上面绘制我们所需的图形即可。

2、绘制SVG元素

此时,数据已经准备就绪,但是作为数据载体的svg却连根毛都没有。这时就需要使用到d3.js操作dom的能力了。

首先,准备一个宿主元素。

<style>
    #app {
      width: 1600px;
      height: 800px;
      background-color: #ffffff;
      border-radius: 8px;
    }
</style>
<div id="app"></div>
复制代码

然后,创建一个svg标签,置于app中。

// d3为目标元素设置属性的api——attr和jquery的非常相似,几乎没有什么上手门槛。
// svg不设置宽高的话,默认撑满其父元素。
// 关于viewBox就说来话长了,建议系统学习一下svg相关的标签和属性。
const svg = d3.create('svg').attr("viewBox",[-1600/2,-800/2,1600,800]).style("user-select","none");

const app = d3.select("#app");

app.append(()=>{
    // node()方法可以得到svg的d3对象对应的DOM对象;
    return svg.node();
})
复制代码

接下来,将页面元素拆解一下。将其分为node(节点)link(连接线)。然后分别创建两个group(组),基于这两个组,在其下面创建各种各样的svg元素。

// 将node和link放入同一个父节点中,组成一个整体。便于之后添加拖动和放大缩小的功能。
const gAll = svg.append("g").attr("id","all");

// 之所以gNodes要在gLink之后,是因为在svg中没有html中的z-index的概念,后面的元素就是会覆盖掉前面的元素。为了保证节点框能覆盖掉多余的连接线,这是必须的措施
// 连接线集合
const gLink = gAll.append("g").attr("id","linkGroup");
// 节点集合
const gNodes = gAll.append("g").attr("id", "nodeGroup");
复制代码

然后,将数据和元素进行绑定;

// descendants方法返回所有的后代节点,自身为第一个节点。
const nodes = root.descendants();
const links = root.links();

// 关于绑定数据的data方法,就是将数据绑定到对应的元素上面。第二个参数可以指定一个标识,使元素上的已绑定数据和nodes中的数据一一对应。
const nodeGroup = gNodes.selectAll("g").data(nodes,(d)=>{
    return d.data.id;
})
const linksPath = gLink.selectAll("path").data(links,(d)=>{
    return d.target.data.id;
})
复制代码

绑定好数据之后,我们就可以使用enter方法来添加各种各样的svg元素了

先添加矩形框中的各种元素

const nodeGroupEnter = nodeGroup.enter().append("g");

// 以下为了精简代码,都是使用的固定数字。而且对根节点不同样式的处理也暂时去掉了

// 矩形外边框
nodeGroupEnter
    .append("rect")
    .attr("width", (d) => {
    	// 设置矩形宽170个单位
        return 170;
    })
    .attr("height", (d) => {
    	// 设置矩形高70
        return 70;
    })
    .attr("x", (d) => {
    	// 使矩形框向左移动一半距离,向上移动一半距离。使得矩形框可以居中
        return -85;
    })
    .attr("y", (d) => {
        return -35;
    })
	// 设置圆角
    .attr("rx", 5)
	// 描边宽度1,相当于border-width
    .attr("stroke-width", 1)
	// 描边颜色
    .attr("stroke", (d) => {
        return "#7A9EFF";
    })
	// 填充色,相当于background-color
    .attr("fill", (d) => {
        return "#FFFFFF";
    })
	// 为其添加的点击事件
    .on("click", (e, d) => {
        alert(d.data.name);
    });
// 公司名称第一行
nodeGroupEnter
    .append("text")
    .attr("class", "main-title")
    .attr("x", 0)
    .attr("y", (d) => {
        return -14;
    })
	// 文本锚点,设置为居中
    .attr("text-anchor", (d) => {
        return "middle";
    })
    .text((d) => {
        if (d.depth === 0) {
            return d.data.name;
        } else {
            return d.data.name.length > 11
                ? d.data.name.substring(0, 11)
            : d.data.name;
        }
    })
    .attr("fill", (d) => {
        return "#000000";
    })
    .style("font-size", (d) => 14)
    .style('font-family','黑体')
    .style("font-weight", "bold");

// 公司名称第二行
(略)
// 控股比例
(略)
// 添加“收缩展开按钮组”
const expandBtnGroup = nodeGroupEnter.append("g").attr("class", "expandBtn");
// 添加圆形按钮
expandBtnGroup.append("circle").attr("r", 8).attr("fill", "#7A9EFF").attr("cy", 8);
// 添加加号或者减号
expandBtnGroup.append("text")
          .attr("text-anchor", "middle")
          .attr("fill", "#ffffff")
          .attr("y", 13)
          .style('font-size', 16)
          .style('font-family','微软雅黑')
          .text((d)=>{
            return d.children ? "-" : "+"
          });
复制代码

接下来绘制连接线:

// 绘制直角连接线的方法
drawLink({ source, target }) {
    const halfDistance = (target.y - source.y) / 2;
    const halfY = source.y + halfDistance;
    return `M${source.x},${source.y} L${source.x},${halfY} ${target.x},${halfY} ${target.x},${target.y}`;
}
// 绘制连接线
linksPath.enter().append("path").attr("d",drawLink).attr("fill", "none").attr("stroke", "#7A9EFF").attr("stroke-width", 1)
复制代码

当我们做完这些之后,穿透图的雏形也已经形成了:

雏形.jpg

3、逻辑封装和添加过渡动画

接下来。我们先将上面的绘制元素的逻辑封装成一个方法,使其可以复用。然后增加动效,使各个节点和连接线从渐渐地原点伸展出来。实现如下效果(下图动画特地放慢了):

动画演示.gif

代码如下:

// 将非层级不为0的节点都隐藏起来
root.descendants().forEach((node) => {
    node._children = node.children || null;
    if (node.depth) {
        node.children = null;
    }
});

update();

// 更新节点
function update(source){
    (...将上方绘制svg元素的逻辑放入update方法中...)
    // 生成一个可重复使用的transition实例
    const myTransition = this.svg.transition().duration(500);
    
    // 如果source不存在,则创建一个
    if (!source) {
        source = {
            x0: 0,
            y0: 0,
        };
        // 设置根节点所在的位置(原点)
        root.x0 = 0;
        root.y0 = 0;
    }
    
    // 上面增加节点的功能修改一下,使其刚生成节点的时候,位置处在x0,y0上,并且填充透明度和描边的透明度都设置成0,先使其不显示出来
    const nodeGroupEnter = nodeGroup.enter().append("g")
        .attr("transform", (d) => {
            return `translate(${source.x0},${source.y0})`;
        })
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0)
        .style("cursor", "pointer");
    
    (...省略绘制节点部分的代码...)
    
    // 绘制连接线也是同理,先使其起点和终点都设置成source的坐标点。然后再更改其坐标至正确的位置上
    
    linksPath.enter()
        .append("path")
        .attr("d", (d) => {
            let o = {
                source: {
                    x: source.x0,
                    y: source.y0,
                },
                target: {
                    x: source.x0,
                    y: source.y0,
                },
            };
            return this.drawLink(o);
        })
    
    (...省略绘制连接线部分的代码...)
    
    // 有元素更新和元素新增的时候,将节点显示出来,并且将其位置从原点(源坐标)移动至新的位置
    nodeGroup
        .merge(node1Enter)
        .transition(myTransition)
        .attr("transform", (d) => {
            return `translate(${d.x},${d.y})`;
        })
        .attr("fill-opacity", 1)
        .attr("stroke-opacity", 1);

    // 有元素消失时,将对应的节点隐藏起来,并且将其位置移回原点(源坐标)
    nodeGroup
        .exit()
        .transition(myTransition)
        .remove()
        .attr("transform", (d) => {
            return `translate(${source.x0},${source.y0})`;
        })
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0);
    // 连接线部分的逻辑和上面的节点的逻辑相同,也是将其坐标移动到新的位置上
    // 有元素更新和元素新增时...
    linksPath.merge(link1Enter).transition(myTransition).attr("d", this.drawLink);
	// 有元素消失时...
    linksPath
        .exit()
        .transition(myTransition)
        .remove()
        .attr("d", (d) => {
            let o = {
                source: {
                    x: source.x,
                    y: source.y,
                },
                target: {
                    x: source.x,
                    y: source.y,
                },
            };
            return this.drawLink(o);
        });
    
   	// node数据改变的时候更改一下加减号
    // 这个expandBtn是上面添加的“收缩展开按钮组”
    const expandButtonsSelection = d3.selectAll('g.expandBtn')

    expandButtonsSelection.select('text').transition().text((d) =>{
        return d.children ? "-" : "+";
    })
    
    // 在最后,将所有节点的当前位置记录下来,存至x0和y0上
    rootOfDown.eachBefore((d) => {
        d.x0 = d.x;
        d.y0 = d.y;
    });
}
复制代码

到这儿,我们已经给节点的显示和隐藏添加上过渡动画了。接下来,我们还需要给收缩展开按钮增加点击事件:

// 还是在update方法内
// 对上方添加“收缩展开按钮组”的逻辑修改一下
const expandBtnGroup = nodeGroupEnter.append("g").attr("class", "expandBtn")
	.attr("transform", (d) => {
        return `translate(0, 35)`;
    })
    .style("display", (d) => {
        // 如果没有子节点,则不显示
        if (!d._children) {
            return "none";
        }
    })
    .on("click", (e, d) => {
        // 当children中有值,则直接赋值给_children。
        if (d.children) {
            d._children = d.children;
            d.children = null;
        } else {
            // 如果children没有值,则将_children中的值赋给它
            d.children = d._children;
        }
        // 然后再调用update方法,将本节点传入进入,作为之后收缩展开的起点。
        this.update(d);
    });
复制代码

结语

至此。所有的核心逻辑都已经梳理完毕。但是这里面用到的d3相关的api碍于篇幅关系就没办法细讲了,如果想要了解这些API都是什么意思、具体是如何使用的,可以前去官方文档了解。

在完整版代码中,为连接线增加上了箭头,也增加了向上部分查看股东的逻辑。另外,还添加了展开全部收缩全部的功能。

完整版代码可见gitee.com/wushengyuan…

猜你喜欢

转载自juejin.im/post/7107121205110390821