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

语言: CN / TW / HK

theme: channing-cyan

前言

作为一个重度拖延症患者和究极懒狗,鸽了这么多天都没有写过新的文章,真是羞愧。这段时间领导布置了一个任务,希望我可以做一个股权穿透图。我本着不重复造轮子的理念,就去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、数据处理

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

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

js let data = { id: "abc1005", // 根节点名称 name: "山东吠舍科技有限责任公司", // 子节点列表 children: [ { id:"abc1006", name: "山东第一首陀罗科技服务有限公司", percent: '100%', }, { id:"abc1007", name: "山东第二首陀罗程技术有限公司", percent: '100%', }, { id:"abc1008", name: "山东第三首陀罗光伏材料有限公司", percent: '100%', }, { id:"abc1009", name: "山东第四首陀罗科技发展有限公司", percent: '100%', children: [ { id:"abc1010", name: "山东第一达利特瑞利分析仪器有限公司", percent: '100%', children:[ { id:"abc1011", name: "山东瑞利的子公司一", percent: '80%', }, { id:"abc1012", name: "山东瑞利的子公司二", percent: '90%', }, { id:"abc1013", name: "山东瑞利的子公司三", percent: '100%', }, ] } ] }, { id:"abc1014", name: "山东第五首陀罗电工科技有限公司", percent: '100%', children: [ { id:"abc1015", name: "山东第二达利特低自动化设备有限公司", percent: '100%', children:[ { id:"abc1016", name: "山东敬业的子公司一", percent: '100%', }, { id:"abc1017", name: "山东敬业的子公司二", percent: '90%', } ] } ] }, { id: "abc1020", name: "山东第六首陀罗分析仪器(集团)有限责任公司", percent: '100%', children: [ { id:"abc1021", name: "山东第三达利特分气体工业有限公司", } ] }, ], // 父节点列表 parents: [ { id: "abc2001", name: "山东刹帝利集团有限责任公司", percent: '60%', parents: [ { id: "abc2000", name: "山东婆罗门集团有限公司", percent: '100%', }, ] }, { id: "abc2002", name: "吴小远", percent: '40%', } ], }

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

js let root = d3.hierarchy(data);

root打印出来查看其结构:

hierarchy处理过后的数据(2).jpg

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

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

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

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

tree处理后的数据(2).jpg

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

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

2、绘制SVG元素

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

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

```html

```

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

```js // 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元素。

```js // 将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"); ```

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

```js // 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元素了

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

```js 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 ? "-" : "+" }); ```

接下来绘制连接线:

js // 绘制直角连接线的方法 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)

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

雏形 (2).jpg

关于箭头是如何绘制的,详情请参照底部的源码

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

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

动画演示2.gif

代码如下:

```js // 将非层级不为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;
});

} ```

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

js // 还是在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都是什么意思、具体是如何使用的,可以前去官方文档了解。

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

完整版代码可见https://gitee.com/wushengyuan/equity-penetration-chart.git