力导向图布局相关
d3提供了很多布局模型,我们把数据传递给布局模型后,d3会给我们的每一条数据添加上计算过后的位置坐标,有了每个节点的坐标,我们就可以使用svg或者canvas进行绘制了。
API文档地址:github.com/d3/d3-force…
d3.forceSimulation是d3的一种布局,它会创建一个力模拟,参数是一个nodes数组(d3会向这个数组中的每一项添加x、y坐标,以及vx、vy速度),可以使用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需要是一个数组,数组中每一项要有source和target属性,source和target是链接指向,可以是数字或字符串或者是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>
目标层级结构
创建一个container根容器,用于存放要画的图形
const container = d3
.select("svg")
.append("g")
.attr("class", "container");
画圆圈和圆圈上的名字文本
- 绘制
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 });
- 调用nodeSelection的each方法,循环遍历,然后根据idx绘制
circle和text
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; });
}
})
}
画箭头
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结构如下:
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吧。