d3-v5绘制一个关系图谱(二)绘制图谱

3,832 阅读9分钟

力导向图布局相关

d3提供了很多布局模型,我们把数据传递给布局模型后,d3会给我们的每一条数据添加上计算过后的位置坐标,有了每个节点的坐标,我们就可以使用svg或者canvas进行绘制了。

API文档地址:github.com/d3/d3-force…

d3.forceSimulation是d3的一种布局,它会创建一个力模拟,参数是一个nodes数组(d3会向这个数组中的每一项添加xy坐标,以及vxvy速度),可以使用simulation.on监听力的事件,tick事件会在力运动过程中自动触发,我们可以在这个函数中根据每个节点的新坐标绘制新的图形位置。

下面的代码会创建一个力导图,并会给nodes的每一项添加坐标以及速度属性

this.simulation = d3
    .forceSimulation(nodes)
    .force("link", d3.forceLink(this.links).distance(200))
    .force("charge", d3.forceManyBody().strength(-200).distanceMax(100))
    .force( "center", d3.forceCenter(this.global.width / 2, this.global.height / 2) ) 
    .on("tick", tick);

simulation.restart 重新启动力模拟,可以与simulation.alphaTarget或者simulation.alpha联合使用,这些方法可以设置力衰减系数

tick事件的回调函数会在力效果运动的时候不断触发,每次迭代,慢慢进行力的衰减,最终按照一定速度到达节点位置,我们可以在这个函数中

Force

simulation.force('力名称', 力函数)是一个用于添加力的函数,可以添加经典的物理力,比如电荷力、重力,并且它可以对力进行约束,比如控制力保持节点在某个指定大小的容器中,保持节点连接线之间的距离。

自定义一个力函数myForce,这个力可以移动节点到原始位置

function myForce(alpha) {
    for (let i = 0, n = nodes.length, node, k = alpha * 0.1; i < n; ++i) {
        node = nodes[i]; 
        node.vx -= node.x * k;
        node.vy -= node.y * k;
    }
}

使用simulation.force添加这个新增的力,第一个参数为力的名称,没有固定命名,这个名字用于取消添加的力

const simulation = d3.forceSimulation(nodes);
// 添加一个名为myForce的力
simulation.force("myForce", myForce) 
// 取消名为myForce的力 
simulation.force("myForce", null);

力模拟通常需要组合多种力,d3中提供了几个力模块

Centering 向心力

d3.forceCenter(x, y) 用于创建一个向心力,指定中心的x、y坐标,如果没有指定,坐标默认为<0,0>

Collision 碰撞力

 用于创建一个碰撞力,碰撞力将作用对象视为具有给定半径的圆。
d3.forceCollide(radius);
用于设置力的强度,值范围[0,1],默认0.7
collide.strength(strength);
用于设置迭代次数,默认为1
collide.iterations(iterations);

例如:

const collide = d3.forceCollide(30); collide.strength(1).iterations(1)

Links 链接力

根据需要将链接的节点推到一起或分开,力的强度与链接节点的距离与目标距离之间的差异成正比,类似于弹簧力 d3.forceLink(links) 创建一个链接力

这里的links需要是一个数组,数组中每一项要有sourcetarget属性,sourcetarget是链接指向,可以是数字或字符串或者是nodes的引用

设置节点距离 link.distance(distance)

设置力的强度 link.strength(strength)

simulation.force("link", d3.forceLink(links).distance(200))

Many-Body

// 创建一个ManyBody
const manyBody = d3.forceManyBody()

manyBody.strength(num)正值互相吸引类似重力,负值互相排斥类似电荷力。

manyBody.distanceMin(distance)设置作用该力的节点之间的最小距离。如果未指定距离,则返回当前最小距离,默认为1。最小距离确定两个附近节点之间的力强度上限,避免不稳定。特别是,如果两个节点完全重合,它可以避免无限强的力;在这种情况下,力的方向是随机的。

manyBody*.distanceMin(distance) 如果指定了距离,则设置此力作用节点之间的最大距离。如果未指定距离,则返回当前最大距离,默认为无穷大(最好指定一个有限的距离)。

绘制关系图谱

创建力模拟

this.simulation = d3
    .forceSimulation(nodes)
    .force("link", d3.forceLink(this.links).distance(200))
    .force("charge", d3.forceManyBody().strength(-200).distanceMax(100))
    .force( "center", d3.forceCenter(屏幕宽度 / 2, 屏幕高度 / 2) ) 

数据结构

nodes数组是所有的节点,links数组是节点之间的关联信息

    const data = {
        nodes: [
            {
                 // 节点id
                 id: 115804450,
                 // 节点名称
                 name: "北京兆协食品有限公司上海销售分部", 
                 // 节点类型 公司:company,人:man
                 type: "company",
                 ...
            }
        ],
        links: [
           {
                    // 源节点
                    src: 130790264,
                    // 目标节点
                    dst: 67932906,
                    // 关系
                    type: "投资",
                    ...
           },
        ]
    }

数据格式化,formatLink方法给links中每个元素添加source和target,指向nodes中的节点,创建和nodes的关联。formatNode方法给nodes的每一项添加一个idx,用于之后的指定节点操作。因为uuid方法返回值前面可能是数字,不能作为dom节点的id,所以在前面随便加了个字符串。

 function formatNode(nodes) {
        const name = '从某个企业详情页跳转过来的企业名称';
        nodes.forEach((node) => {
            if (name === node.name) {
                node.isSelf = true;
            }
            node.idx = "zf" + uuid();
        });
  }
  function formatLink(links, nodes) {
        links.forEach((link, i) => {
            link.index = i;
            const src = nodes.find((node) => node.id == link.src);
            const dst = nodes.find((node) => node.id == link.dst);
            link["source"] = src;
            link["target"] = dst;
        });
  }

基础dom结构

添加一个svg标签

 <svg 
      xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
      id="svgId" height="1000" width="100%">
</svg>

目标层级结构 image.png 创建一个container根容器,用于存放要画的图形

        const container = d3
            .select("svg")
            .append("g")
            .attr("class", "container");

画圆圈和圆圈上的名字文本

  1. 绘制g.nodegroup(整个圆圈和文本的g),在绘制g.nodegroup g.idx(每一个圆圈和文本的g,此时我们把之前格式化出来的idx作为节点的id设置到上面了),并将nodes绑定到上面,然后就可以用nodeG进行接下来的绘制了,并且nodeG接下来的属性设置是可以获取nodes中的坐标的。

idx作为dom节点的id,是为了方便之后将对应的圆圈circle和文本text放到同一个g.id下面绘制,这样方便以后的事件监听,比如对这个圈和文本注册点击事件,要把事件注册到这个g上面,否则如果在同级的并且只监听circle的话,点击text就不会触发事件

        let nodeSelection = container
            .append("g")
            .attr("class", "nodegroup")
            .selectAll(".node")
            .data(nodes)
            .enter()
            .append('g')
            .attr("id", d => { return d.idx });
  1. 调用nodeSelection的each方法,循环遍历,然后根据idx绘制circletext
   nodeSelection.each(d => {
            drawCircle(d.idx)
            drawText(d.idx);
        })

这个绘制圆圈的方法中,先不用设置坐标,之后在tick函数中更新时设置就可以了。

  function drawCircle(id) {
            const circleG = d3
                .select(`#${id}`)
                .attr('class', 'circle-g')
            circleG
                .append("circle")
                .attr("class", "real-circle")
                .attr("fill", d => {
                    // 某个企业详情页点击进来的,给个这个企业特殊样式
                    if (d.isSelf) {
                        return '#fe9d3b';
                    }
                    if (d.type === 'man') {
                        return '#fe5557';
                    }
                    return '#52a3eb'
                })
                .attr('stroke', function (d) {
                    if (d.isSelf) {
                        return '#ec8a2f';
                    }
                    else if (d.type === 'company') {
                        return '#0082e5';
                    }
                    return 'none';
                })
                // 设置圆圈半径
                .attr("r", d => {
                    if (d.type === 'man') {
                        return 23;
                    }
                    return 35;
                })
        }

绘制文本,之间做了一些换行处理

  function drawText(id) {
            d3.select(`#${id}`)
                .insert("text")
                .attr('class', 'circle-text')
                .attr("dy", ".35em")
                .attr("text-anchor", "middle")
                .attr('font-size', 12)
                .style('fill', function (node) {
                    return '#fff';
                })
                .attr('y', d => { return d.x })
                .attr('x', function (d) {
                    let re_en = /[a-zA-Z]+/g;
                    //如果是全英文,不换行
                    if (d.name.match(re_en)) {
                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 2)
                            .text(function () { return d.name; });
                    }
                    //如果小于四个字符,不换行
                    else if (d.name.length <= 4) {
                        d3.select(this).append('tspan')
                            .attr('x', -2)
                            .attr('y', 2)
                            .text(function () { return d.name; });
                    } else if (d.name.length > 4 && d.name.length <= 8) {//大于4  这两行
                        let top = d.name.substring(0, 4);
                        let bot = d.name.substring(4, d.name.length);

                        d3.select(this).text(function () { return ''; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', -7)
                            .text(function () { return top; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 10)
                            .text(function () { return bot; });
                    }
                    // 文字长度大于8 折三行
                    else if (d.name.length > 8 && d.name.length <= 12) {
                        let top = d.name.substring(0, 4);
                        let bot = d.name.substring(4, 8);
                        let bot1 = d.name.substring(8, d.name.length);

                        d3.select(this).text(function () { return ''; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', -15)
                            .text(function () { return top; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 2)
                            .text(function () { return bot; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 16)
                            .text(function () { return bot1; });

                    }
                    //文字长度大于12 折四行
                    else if (d.name.length > 12 && d.name.length <= 16) {

                        let top = d.name.substring(0, 4);
                        let bot = d.name.substring(4, 8);
                        let bot1 = d.name.substring(8, 12);
                        let bot2 = d.name.substring(12, d.name.length);

                        d3.select(this).text(function () { return ''; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', -20)
                            .text(function () { return top; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', -3)
                            .text(function () { return bot; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 10)
                            .text(function () { return bot1; });
                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 23)
                            .text(function () { return bot2; });


                    } else if (d.name.length > 16) {//文字长度大于16  方案
                        let top = d.name.substring(0, 4);
                        let bot = d.name.substring(4, 8);
                        let bot1 = d.name.substring(8, 12);
                        let bot2 = d.name.substring(12, 14);

                        bot2 += '...'
                        d3.select(this).text(function () { return ''; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', -22)
                            .text(function () { return top; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', -7)
                            .text(function () { return bot; });

                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 10)
                            .text(function () { return bot1; });
                        d3.select(this).append('tspan')
                            .attr('x', 0)
                            .attr('y', 25)
                            .text(function () { return bot2; });
                    }
                })
        }

画箭头

image.png defs标签里面的内容默认不会展示,但可以被引用,比如marker标签的内容,在下面的连接线绘制中,可以设置marker-end属性值为某个marker的id,那么连接线就会以这个marker结尾,下面的代码中在marker中用path绘制一个箭头,通过refX属性调整箭头在连接线的位置,因为连接线的末尾是指向圆心的,所以需要调整箭头位置,让箭头在圆边上。

下面绘制了两个颜色的圆,可以在连线中通过id指定使用对应的颜色。

  function drawMarker() {
        d3.select('svg')
            .append("svg:defs")
            .append("svg:marker")
            .attr("id", "blueMarker")
            .attr("viewBox", "0 -5 10 10")
            .attr("refX", 70)
            .attr("refY", 0)
            .attr('markerUnits', 'userSpaceOnUse')
            .attr("markerWidth", 6)
            .attr("markerHeight", 6)
            .attr("orient", "auto")
            .append("svg:path")
            .attr("d", "M0,-5L10,0L0,5").attr('fill', '#4099ea')
        d3.select('svg')
            .select("defs")
            .append("svg:marker")
            .attr("id", "redMarker")
            .attr("viewBox", "0 -5 10 10")
            .attr("refX", 70)
            .attr("refY", 0)
            .attr('markerUnits', 'userSpaceOnUse')
            .attr("markerWidth", 6)
            .attr("markerHeight", 6)
            .attr("orient", "auto")
            .append("svg:path")
            .attr("d", "M0,-5L10,0L0,5").attr('fill', '#fd6568')
    }

画连接线

传入容器对象和links,将links绑定在g.line-path上,

 // 添加连接路径path
 function drawLinkPath(container, links) {
        container
            .append("g")
            .attr("class", "line-path")
            .selectAll(".path")
            .data(links)
            .enter()
            .append("path")
            .attr("class", "path")
            .attr("fill", "none")
            .attr("stroke-width", 1)
            .attr("stroke", "#D8D8D8")
            .attr("marker-end", function (d) {
                let id = "blueMarker";
                if (d.type === "投资") {
                    id = "redMarker";
                }
                return `url(#${id})`;
            })
            .attr('cursor', 'pointer');
}
       

画关系文本

绘制连接线中间的文字(人物实体之间的关系),dom结构如下: image.png text标签放的就是文本,而rect中放的是一个半透明的白框,可以在重叠的时候有更好的效果。

 function drawRelationText(container, links) {
            
            let edges_text =
                container
                    .append('g')
                    .attr('class', 'relation-text')
                    .selectAll('.linetext')
                    .data(links)
                    .enter()
                    .append("svg:g")
                    .attr("class", "linetext")
                    .attr("fill-opacity", 1)


            edges_text.append("svg:text")
                .attr("font-size", 10)
                .attr("fill", "#8e8e8e")
                .attr("y", ".31em")
                .attr('text-anchor', "middle")
                .text(function (d) {
                    return d.type;
                });

            edges_text.insert('rect', 'text')
                .attr('width', function (d, i, e) {
                    const { width } = e[i].parentNode.getBoundingClientRect()
                    return width;
                })
                .attr('height', function (d) {
                    return 14;
                })
                .attr("y", "-.5em")
                .attr('x', function (d, i, e) {
                    const { width } = e[i].nextSibling.getBoundingClientRect()
                    return -width / 2;
                })
                .attr('fill', 'rgba(255,255,255,.5)');
        }

tick方法

tick方法是d3力导向图在运动的时候自动触发的,我们可以在里面对每个图元的位置进行更新。

   function tick() {
        updateLinkLine();
        updateCircleAndText();
        updateLinkRelationText();
   }
   // 更新节点圆圈和圆圈上的文字
   function updateCircleAndText() {
        d3.selectAll("circle")
            .attr("cx", (d) => {
                return d.x
            })
            .attr("cy", (d) => d.y);
        d3.selectAll(".circle-text").attr("transform", (d) => {
            return `translate(${d.x},${d.y})`;
        });
    }
    // 更新连接线坐标
   function updateLinkLine() {
        const edges_line = d3.selectAll(".path");
        edges_line.attr("d", function (d) {
            return "M" + d.source.x + " " + d.source.y + " L " + d.target.x + " " + d.target.y
        });
   }
   // 更新连接线中间的文本的坐标
   function updateLinkRelationText() {
        const svg = d3.select("svg");
        let edges_text = svg.selectAll(".linetext");

        //更新连接线上文字的位置
        edges_text.attr("transform", function (d) {
            let translateX = (d.source.x + d.target.x) / 2;
            let translateY = (d.source.y + d.target.y) / 2;
            return `translate(${translateX},${translateY}) rotate(0)`;
        });
    }

拖拽处理

    // 拖拽处理dragFunc是一个函数,参数传递拖拽的目标节点
    const dragFunc = d3
        .drag()
        .on("start", dragStarted)
        .on("drag", dragged)
        .on("end", dragend);

      dragFunc(nodeSelection);
    
      // 拖拽开始
      function dragStarted(d) {
            // 如果不是运动状态,调用restart方法
            if (!d3.event.active) simulation.alphaTarget(0.01).restart();
            // 固定拖拽节点的坐标不受力影响
            d.fx = d.x;
            d.fy = d.y;
       }
       // 拖拽中
       function dragged(d) {
            // d3.event中的坐标状态随着拖拽而变化
            d.fx = d3.event.x;
            d.fy = d3.event.y;
       }
       // 拖拽结束
       function dragend(d) {
            if (!d3.event.active) simulation.alphaTarget(0);
            d.fx = null;
            d.fy = null;
       }

缩放行为 触摸板双指上拉下拉放大,缩小

调用下面的函数,就会初始化处理一个放大、缩小功能scaleExtent属性指定缩放范围,比如下面的0.2,5代表最小0.2最大5。

     function initZoom() {
           zoom = d3
                .zoom()
                .scaleExtent([0.2, 5])
                .on("zoom", () => {
                    // transform中存放偏移坐标x、y,以及缩放系数k
                    let transform = d3.event.transform;
                    return container.attr(
                        "transform",
                        `translate(${transform.x},${transform.y})scale(${transform.k})`
                    );
                });

            //动画持续时间
            container
                .transition()
                .duration(300)
                .call(zoom.transform, d3.zoomIdentity);
            d3.select("svg")
                .call(zoom)
                // 取消默认的双击放大事件
                .on("dblclick.zoom", null);
        }

有了zoom对象可以设置放大缩小按钮了,通过设置factor就可以设置缩放倍数了,比如想要放大到1.2倍,将factor设置为1.2然后调用zoom.scaleTo方法。想要缩放到0.8倍也是同理,设置factor为1.8,然后调用这个函数。

 // 声明一个缩放系数变量
 let factor = 1;
 zoom.scaleTo(
    d3.select("svg").transition().duration(300),factor
 );

尾声

d3.js是一个灵活强大的底层可视化工具,在国内的系统教程比较少,并且版本更新速度比较快,目前最新已经到了v7版本,其中v3和v4版本变化比较大,推荐清华大学的可视化视频教程入门,这个可能是国内d3唯一比较系统的视频教程了。文档的话就看GitHub上的官方Api吧。