前言
网上关于d3的教程比较少,在此通过一个树形图的示例让大家加深对d3的理解;并可以此为基础开发企业图谱这种业务功能
一、实现效果
二、d3 API
1. d3.hierarchy
层级布局,为每个节点指定深度等属性
var root = d3.hierarchy(data);
返回的root会多些层级信息,如depth,height,parent等属性,其中data属性指向源数据
2. d3.tree
创建一个树状图生成器
var tree = d3.tree()(root)
对指定的 root hierarchy 进行布局,为节点添加x,y坐标
3. tree.nodeSize
设置节点尺寸
tree.nodeSize([30,100])
当指定了nodeSize时,根节点的初始位置总是返回(0,0)
4. tree.separation
设置两个相邻的节点之间的距离
tree.separation((a, b) => {
return (a.parent == b.parent ? 1 : 2) / a.depth;
});
返回的数字越大,间距越大。间距也与上面设置的nodeSize有关
5. node.descendants
返回后代节点数组,第一个节点为自身,然后依次为所有子节点的拓扑排序
root.descendants()
6. node.links
root.links()
7. d3.zoom
创建一个缩放交互
var zoom = d3.zoom()
8. zoom.scaleExtent
设置可缩放系数大小
zoom.scaleExtent([0.8,2])
9. zoom.on
监听缩放事件
zoom.on("zoom", () => {
//监听到缩放事件后,执行的回调函数
svg.select("g").attr("transform", d3.event.transform);
});
10. zoom.translateBy
对指定的选中元素进行平移变换,用于初始化平移时位置
//如果svg初始化时translate不是从(0,0)开始的,缩放时也要设置初始位置
svg.call(zoom).call(zoom.translateBy,200,100)
11. zoom.scaleBy
对选中的元素进行缩放(在当前缩放基础上叠加缩放)
zoom.scaleBy(svg,1.1)
二、坐标系
1. svg坐标系
以屏幕左上角为原点(0,0),水平方向为X轴,垂直方向为Y轴
2. 水平树状图坐标系
d3.tree()生成的x,y坐标默认是生成垂直方向的树,如果要画水平方向的树,需要把坐标反过来
三、画图
1. 数据
示例数据
var dataset = {
name: "中国",
children: [
{
name: "浙江",
children: [
{
name: "杭州",
},
{
name: "宁波",
},
{
name: "温州",
},
{
name: "绍兴",
},
],
},
],
};
2. 初始化
添加dom元素
<div style="height: 100vh" id="tree"></div>
-
通过d3选择器获取到id=tree节点,并添加svg画布;
-
svg画布下添加g节点,并通过translate属性改变原点坐标
-
创建并调用d3缩放器,使图能够缩放、平移
/* g:容器,root:d3转换dataset后的数据,zoomHandler:缩放器 */
var g = null,
root,
zoomHandler,
svg,
width = document.body.clientWidth,
height = document.body.clientHeight;
//过渡时长
var duration = 750;
/* 初始化 */
function init() {
svg = d3
.select("#tree")
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("font-size", "16px");
g = svg
.append("g")
.attr(
"transform",
`translate(${width / 2 - 200},${height / 2}) scale(1)`
);
//缩放器
zoomHandler = d3
.zoom()
.scaleExtent([0.8, 2]) // 缩放范围
.on("zoom", () => {
svg.select("g").attr("transform", d3.event.transform);
});
//调用缩放器
svg
.call(zoomHandler)
.call(zoomHandler.translateBy, width / 2 - 200, height / 2) //初始化平移位置
.on("dblclick.zoom", null); //禁用双击缩放事件
dealData();
}
3. 处理数据
-
将原数据通过d3转为层级数据
-
通过遍历,为所有节点添加 _children 属性(用来展开、缩放)
-
通过遍历,为所有节点添加唯一 id(添加、删除节点时,d3能够找到对应节点)
/* 处理数据 */
function dealData() {
//转换成层级数据
root = d3.hierarchy(dataset);
/* 添加_children属性并绑定唯一标识用于实现点击收缩及展开功能 */
root.descendants().forEach((d) => {
d._children = d.children;
d.id = uuid();
});
update(root);
}
4. 绘制
-
通过d3.tree为层级数据添加空间坐标
-
通过nodeSize,separation分别设置节点大小和节点间距
-
获取页面节点并与数据中的节点进行比较
-
如果有需要添加的节点,通过 .enter().append('g')添加
-
如果有需要删除的节点,通过 .exit().remove()删除
-
对线条的处理方式与节点一致
-
遍历所以节点,通过添加x0,y0属性用于记录上一次的位置
/* 绘制 */
function update(source) {
/* 每次更新需重新进行布局,为了调整展开收缩时节点之间的间距 */
d3
.tree()
.nodeSize([30, 100])
.separation((a, b) => {
//调整节点之间的间隙
let result =
a.parent === b.parent && !a.children && !b.children ? 1 : 2;
if (result > 1) {
let length = 0;
length = a.children ? length + a.children.length : length;
length = b.children ? length + b.children.length : length;
result = length / 2 + 0.5;
}
return result;
})(root);
/* 获取页面节点并与数据中的节点进行比较 */
const node = g
.selectAll("g.gNode")
.data(root.descendants(), (d) => d.id);
/* 需要增加的节点 */
const nodeEnter = node
.enter()
.append("g")
.attr("id", (d) => `g${d.id}`)
.attr("class", "gNode")
.attr(
"transform",
() => `translate(${source.y0 || 0},${source.x0 || 0})`
)
.attr("fill-opacity", 0)
.attr("stroke-opacity", 0)
.on("click", (d) => {
clickNode(d);
});
//在节点中增加元素(圆圈和描述文字)
nodeEnter.each((d) => {
drawText(d.id);
//有子节点
if (d._children) {
drawCircle(d.id);
}
});
/* 去除多余的节点 */
node
.exit()
.transition()
.duration(duration)
.remove()
.attr("transform", () => `translate(${source.y},${source.x})`)
.attr("fill-opacity", 0)
.attr("stroke-opacity", 0);
/* 更新节点 */
node
.merge(nodeEnter)
.transition()
.duration(duration)
.attr("transform", (d) => `translate(${d.y - 16},${d.x})`)
.attr("fill-opacity", 1)
.attr("stroke-opacity", 1);
/* 获取页面线条并与数据中的线条进行比较 */
const link = g
.selectAll("path.gNode")
.data(root.links(), (d) => d.target.id);
/* insert是在g标签前面插入,防止连接线挡住G节点内容 */
const linkEnter = link
.enter()
.insert("path", "g")
.attr("class", "gNode")
.attr("d", () => {
const o = { x: source.x0 || 0, y: source.y0 || 0 };
return diagonal({ source: o, target: o });
})
.attr("fill", "none")
.attr("stroke-width", 1)
.attr("stroke", "#dddddd");
/* 去除多余的线条 */
link
.exit()
.transition()
.duration(duration)
.remove()
.attr("d", () => {
const o = { x: source.x, y: source.y };
return diagonal({ source: o, target: o });
});
/* 更新线条 */
link
.merge(linkEnter)
.transition()
.duration(duration)
.attr("d", diagonal);
/* 前序遍历处理数据,增加x0,y0用于记录同一节点上一次位置 */
root.eachBefore((d) => {
d.x0 = d.x;
d.y0 = d.y;
});
console.log(root);
}
5. 节点点击事件
- 通过children赋值为空,删除子节点;通过 _children赋值给children,添加子节点
//点击节点
function clickNode(d) {
//无子节点
if (!d.children && !d._children) return;
d.children = d.children ? null : d._children;
//改变圆圈内竖线属性,使其呈现出来
d3.select(`#g${d.id} .node-circle .node-circle-vertical`)
.transition()
.duration(duration)
.attr("stroke-width", d.children ? 0 : 1);
update(d);
}
6. 放大、缩小、重置
-
通过zoom.scaleBy对图形进行放大缩小
-
通过删除svg属性,并重绘达到重置的效果
//放大
function onMagnifySVG() {
zoomHandler.scaleBy(svg, 1.1);
}
//缩小
function onShrinkSVG() {
zoomHandler.scaleBy(svg, 0.9);
}
//重置
function onResetSVG() {
d3.select("svg").remove();
init();
}
7. 完整代码
<!DOCTYPE html>
<html>
<head>
<title>testD3_chp15_1.html</title>
<script type="text/javascript" src="http://d3js.org/d3.v5.min.js"></script>
<meta name="keywords" content="keyword1,keyword2,keyword3" />
<meta name="description" content="this is my page" />
<meta name="content-type" content="text/html; charset=GBK" />
<style>
body {
margin: 0;
}
</style>
<!--<link rel="stylesheet" type="text/css" href="./styles.css">-->
</head>
<body>
<div style="height: 100vh" id="tree"></div>
<ul style="position: absolute; top: 0; left: 0; cursor: pointer">
<li onclick="onMagnifySVG()">放大</li>
<li onclick="onShrinkSVG()">缩小</li>
<li onclick="onResetSVG()">重置</li>
</ul>
<script>
//数据
var dataset = {
name: "中国",
children: [
{
name: "浙江",
children: [
{
name: "杭州",
},
{
name: "宁波",
},
{
name: "温州",
},
{
name: "绍兴",
},
],
},
{
name: "广西",
children: [
{
name: "桂林",
children: [
{
name: "秀峰区",
},
{
name: "叠彩区",
},
{
name: "象山区",
},
{
name: "七星区",
},
],
},
{
name: "南宁",
},
{
name: "柳州",
},
{
name: "防城港",
},
],
},
{
name: "黑龙江",
children: [
{
name: "哈尔滨",
},
{
name: "齐齐哈尔",
},
{
name: "牡丹江",
},
{
name: "大庆",
},
],
},
{
name: "新疆",
children: [
{
name: "乌鲁木齐",
},
{
name: "克拉玛依",
},
{
name: "吐鲁番",
},
{
name: "哈密",
},
],
},
],
};
//随机数,用于绑定id
function uuid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return (
s4() +
s4() +
"-" +
s4() +
"-" +
s4() +
"-" +
s4() +
"-" +
s4() +
s4() +
s4()
);
}
/* g:容器,root:d3转换dataset后的数据,zoomHandler:缩放器 */
var g = null,
root,
zoomHandler,
svg,
width = document.body.clientWidth,
height = document.body.clientHeight;
//过渡时长
var duration = 750;
/* 初始化 */
function init() {
svg = d3
.select("#tree")
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("font-size", "16px");
g = svg
.append("g")
.attr(
"transform",
`translate(${width / 2 - 200},${height / 2}) scale(1)`
);
//缩放器
zoomHandler = d3
.zoom()
.scaleExtent([0.8, 2]) // 缩放范围
.on("zoom", () => {
svg.select("g").attr("transform", d3.event.transform);
});
//调用缩放器
svg
.call(zoomHandler)
.call(zoomHandler.translateBy, width / 2 - 200, height / 2) //初始化平移位置
.on("dblclick.zoom", null); //禁用双击缩放事件
dealData();
}
/* 处理数据 */
function dealData() {
//转换成层级数据
root = d3.hierarchy(dataset);
/* 添加_children属性并绑定唯一标识用于实现点击收缩及展开功能 */
root.descendants().forEach((d) => {
d._children = d.children;
d.id = uuid();
});
update(root);
}
/* 绘制 */
function update(source) {
/* 每次更新需重新进行布局,为了调整展开收缩时节点之间的间距 */
d3
.tree()
.nodeSize([30, 100])
.separation((a, b) => {
//调整节点之间的间隙
let result =
a.parent === b.parent && !a.children && !b.children ? 1 : 2;
if (result > 1) {
let length = 0;
length = a.children ? length + a.children.length : length;
length = b.children ? length + b.children.length : length;
result = length / 2 + 0.5;
}
return result;
})(root);
/* 获取页面节点并与数据中的节点进行比较 */
const node = g
.selectAll("g.gNode")
.data(root.descendants(), (d) => d.id);
/* 需要增加的节点 */
const nodeEnter = node
.enter()
.append("g")
.attr("id", (d) => `g${d.id}`)
.attr("class", "gNode")
.attr(
"transform",
() => `translate(${source.y0 || 0},${source.x0 || 0})`
)
.attr("fill-opacity", 0)
.attr("stroke-opacity", 0)
.on("click", (d) => {
clickNode(d);
});
//在节点中增加元素(圆圈和描述文字)
nodeEnter.each((d) => {
drawText(d.id);
//有子节点
if (d._children) {
drawCircle(d.id);
}
});
/* 去除多余的节点 */
node
.exit()
.transition()
.duration(duration)
.remove()
.attr("transform", () => `translate(${source.y},${source.x})`)
.attr("fill-opacity", 0)
.attr("stroke-opacity", 0);
/* 更新节点 */
node
.merge(nodeEnter)
.transition()
.duration(duration)
.attr("transform", (d) => `translate(${d.y - 16},${d.x})`)
.attr("fill-opacity", 1)
.attr("stroke-opacity", 1);
/* 获取页面线条并与数据中的线条进行比较 */
const link = g
.selectAll("path.gNode")
.data(root.links(), (d) => d.target.id);
/* insert是在g标签前面插入,防止连接线挡住G节点内容 */
const linkEnter = link
.enter()
.insert("path", "g")
.attr("class", "gNode")
.attr("d", () => {
const o = { x: source.x0 || 0, y: source.y0 || 0 };
return diagonal({ source: o, target: o });
})
.attr("fill", "none")
.attr("stroke-width", 1)
.attr("stroke", "#dddddd");
/* 去除多余的线条 */
link
.exit()
.transition()
.duration(duration)
.remove()
.attr("d", () => {
const o = { x: source.x, y: source.y };
return diagonal({ source: o, target: o });
});
/* 更新线条 */
link
.merge(linkEnter)
.transition()
.duration(duration)
.attr("d", diagonal);
/* 前序遍历处理数据,增加x0,y0用于记录同一节点上一次位置 */
root.eachBefore((d) => {
d.x0 = d.x;
d.y0 = d.y;
});
console.log(root);
}
//点击节点
function clickNode(d) {
//无子节点
if (!d.children && !d._children) return;
d.children = d.children ? null : d._children;
//改变圆圈内竖线属性,使其呈现出来
d3.select(`#g${d.id} .node-circle .node-circle-vertical`)
.transition()
.duration(duration)
.attr("stroke-width", d.children ? 0 : 1);
update(d);
}
//画文本
function drawText(id) {
d3.select(`#g${id}`)
.append("text")
.attr("x", (d) => (d.children ? -(8 + d.data.name.length * 16) : 8))
.attr("y", 5)
.text((d) => d.data.name);
}
//画圆圈
function drawCircle(id) {
let gMark = d3
.select(`#g${id}`)
.append("g")
.attr("class", "node-circle")
.attr("stroke", "red")
.attr("stroke-width", 1);
//画圆
gMark
.append("circle")
.attr("fill", "none")
.attr("r", 6)
.attr("fill", "#ffffff");
const padding = 4;
//横线
gMark.append("path").attr("d", `m -${padding} 0 l ${2 * padding} 0`);
//竖线
gMark
.append("path")
.attr("d", `m 0 -${padding} l 0 ${2 * padding}`)
.attr("stroke-width", 0)
.attr("class", "node-circle-vertical");
}
//画连接线
function diagonal({ source, target }) {
let s = source,
d = target;
return `M ${s.y} ${s.x}
L ${(s.y + d.y) / 2} ${s.x},
L ${(s.y + d.y) / 2} ${d.x},
${d.y} ${d.x}`;
}
//放大
function onMagnifySVG() {
zoomHandler.scaleBy(svg, 1.1);
}
//缩小
function onShrinkSVG() {
zoomHandler.scaleBy(svg, 0.9);
}
//重置
function onResetSVG() {
d3.select("svg").remove();
init();
}
init();
</script>
</body>
</html>
四、结语
如果本篇比较受欢迎的话,后期再更一篇,实现企业图谱大致功能;
你的点赞,是我创作的动力;码字不易,点赞支持!!!