d3 树—— 折叠/展开原理
1. 点击节点前的折叠/展开按钮(此处为红色圆圈)时,
- 若节点已展开(children有值)——将节点的children数据存入新属性childrenTemp后,将children设为null
- 若节点已折叠(children无值)——将节点的childrenTemp属性值赋值为children,并将childrenTemp设为null
//绘制节点(节点前的圆圈) groups.append("circle") // 树的展开折叠 .on("click", function (event, node) { let data = node.data if (data.children) { data.childrenTemp = data.children data.children = null } else { data.children = data.childrenTemp data.childrenTemp = null } that.drawMap() }) .attr("cursor", 'pointer')
2. 数据完成修改后,需先清空画布,再重新绘制树图
第一次绘图时无数据,加载数据;点击折叠展开按钮时,已有数据,则清空画布,重新绘制
if (!this.treeData) { this.treeData = data } else { // 清空画布 d3.select('#' + this.id).selectAll("svg").remove(); }
3. 为了丰富折叠展开的效果,对红色圆圈的样式进行切换
- 若节点已折叠(childrenTemp有值)——填充红色,显示为红色实心圆
- 若节点已展开(childrenTemp无值)——填充白色,显示为红色空心圆
.attr("fill", function (d) { if (d.data.childrenTemp) { return 'red' } else { return 'white' } })
安装依赖
vue项目中,安装依d3
npm install d3
组件封装 superMindmap
<template> <div :id="id"></div> </template> <script> import * as d3 from 'd3'; export default { props: { data: Object, nodeWidth: { type: Number, default: 160 }, nodeHeight: { type: Number, default: 40 }, active: { type: String, default: '' } }, data() { return { id: 'TreeMap' + randomString(4), deep: 0, treeData: null, show: true, demoData: { "label": "中国", link: "demo", url: 'https://baike.baidu.com/item/%E4%B8%AD%E5%9B%BD/1122445?fr=aladdin', "children": [ { "label": "浙江", disabled: true, "children": [ {"label": "杭州"}, {"label": "宁波"}, {"label": "温州"}, {"label": "绍兴"} ] }, { "label": "广西", "children": [ { "label": "桂林", "children": [ {"label": "秀峰区"}, {"label": "叠彩区"}, {"label": "象山区"}, {"label": "七星区"} ] }, {"label": "南宁"}, {"label": "柳州"}, {"label": "防城港"} ] }, ] } } }, mounted() { this.$nextTick( () => { this.drawMap() } ) }, methods: { drawMap() { let that = this // 源数据 let data = {} // 判断data是否为空对象 if (this.data && JSON.stringify(this.data) !== "{}") { data = this.data } else { data = this.demoData } if (!this.treeData) { this.treeData = data } else { // 清空画布 d3.select('#' + this.id).selectAll("svg").remove(); } let leafList = [] getTreeLeaf(data, leafList) let leafNum = leafList.length let TreeDeep = getDepth(data) // 左右内边距 let mapPaddingLR = 10 // 上下内边距 let mapPaddingTB = 0 let mapWidth = this.nodeWidth * TreeDeep + mapPaddingLR * 2; let mapHeight = (this.nodeHeight - 4) * leafNum + mapPaddingTB * 2; // 定义画布—— 外边距 10px let svgMap = d3.select('#' + this.id).append('svg').attr("width", mapWidth).attr("height", mapHeight).style("margin", "0px") // 定义树状图画布 let treeMap = svgMap.append("g").attr("transform", "translate(" + mapPaddingLR + "," + (mapHeight / 2 - mapPaddingTB) + ")"); // 将源数据转换为可以生成树状图的数据(有节点 nodes 和连线 links ) let treeData = d3.tree() // 设置每个节点的尺寸 .nodeSize( // 节点包含后方的连接线 [节点高度,节点宽度] [this.nodeHeight, this.nodeWidth] ) // 设置树状图节点之间的垂直间隔 .separation(function (a, b) { // 样式一:节点间等间距 // return (a.parent == b.parent ? 1: 2) / a.depth; // 样式二:根据节点子节点的数量,动态调整节点间的间距 let rate = (a.parent == b.parent ? (b.children ? b.children.length / 2 : 1) : 2) / a.depth // 间距比例不能小于0.7,避免间距太小而重叠 if (rate < 0.7) { rate = 0.7 } return rate; })( // 创建层级布局,对源数据进行数据转换 d3.hierarchy(data).sum(function (node) { // 函数执行的次数,为树节点的总数,node为每个节点 return node.value; }) ) // 贝塞尔曲线生成器 let Bézier_curve_generator = d3.linkHorizontal() .x(function (d) { return d.y; }) .y(function (d) { return d.x; }); //绘制边 treeMap.selectAll("path") // 节点的关系 links .data(treeData.links()) .enter() .append("path") .attr("d", function (d) { // 根据name值的长度调整连线的起点 var start = { x: d.source.x, // 连线起点的x坐标 // 第1个10为与红圆圈的间距,第2个10为link内文字与边框的间距,第3个10为标签文字与连线起点的间距 y: d.source.y + 10 + (d.source.data.link ? (getPXwidth(d.source.data.link) + 10) : 0) + getPXwidth(d.source.data.label) + 10 }; var end = {x: d.target.x, y: d.target.y}; return Bézier_curve_generator({source: start, target: end}); }) .attr("fill", "none") .attr("stroke", "#c3c3c3") // 虚线 // .attr("stroke-dasharray", "8") .attr("stroke-width", 1); // 创建分组——节点+文字 let groups = treeMap.selectAll("g") // 节点 nodes .data(treeData.descendants() ) .enter() .append("g") .attr("transform", function (d) { var cx = d.x; var cy = d.y; return "translate(" + cy + "," + cx + ")"; }); //绘制节点(节点前的圆圈) groups.append("circle") // 树的展开折叠 .on("click", function (event, node) { let data = node.data if (data.children) { data.childrenTemp = data.children data.children = null } else { data.children = data.childrenTemp data.childrenTemp = null } that.drawMap() }) .attr("cursor", 'pointer') .attr("r", 4) .attr("fill", function (d) { if (d.data.childrenTemp) { return 'red' } else { return 'white' } }) .attr("stroke", "red") .attr("stroke-width", 1); //绘制标注(节点前的矩形) groups.append("rect") .attr("x", 8) .attr("y", -10) .attr("width", function (d) { return d.data.link ? (getPXwidth(d.data.link) + 10) : 0 } ) .attr("height", 22) .attr("fill", "grey") // 添加圆角 .attr("rx", 4) //绘制链接方式 groups.append("text") .attr("x", 12) .attr("y", -5) .attr("dy", 10) .attr("fill", 'white') .attr("font-size", 12) .text(function (d) { return d.data.link; }) //绘制文字 groups.append("text") .on("click", function (event, node) { let data = node.data // 被禁用的节点,点击无效 if (data.disabled) { return } // 有外链的节点,打开新窗口后恢复到思维导图页面 if (data.url) { window.open(data.url) that.$emit('activeChange', 'map') return } // 标准节点—— 传出 prop if (data.dicType) { that.$emit('dicTypeChange', data.dicType) } // 标准节点—— 传出 prop if (data.prop) { that.$emit('activeChange', data.prop) } }) .attr("x", function (d) { return 12 + (d.data.link ? (getPXwidth(d.data.link) + 10) : 0) }) .attr("fill", function (d) { if (d.data.prop === that.active) { return '#409EFF' } } ) .attr("font-weight", function (d) { if (d.data.prop === that.active) { return 'bold' } }) .attr("font-size", 14) .attr("cursor", function (d) { if (d.data.disabled) { return 'not-allowed' } else { return 'pointer' } }) .attr("y", -5) .attr("dy", 10) .attr("slot", function (d) { return d.data.prop; }) .text(function (d) { return d.data.label; }) }, }, } // 获取树的深度 function getDepth(json) { var arr = []; arr.push(json); var depth = 0; while (arr.length > 0) { var temp = []; for (var i = 0; i < arr.length; i++) { temp.push(arr[i]); } arr = []; for (var i = 0; i < temp.length; i++) { if (temp[i].children && temp[i].children.length > 0) { for (var j = 0; j < temp[i].children.length; j++) { arr.push(temp[i].children[j]); } } } if (arr.length >= 0) { depth++; } } return depth; } // 提取树的子节点,最终所有树的子节点都会存入传入的leafList数组中 function getTreeLeaf(treeData, leafList) { // 判断是否为数组 if (Array.isArray(treeData)) { treeData.forEach(item => { if (item.children && item.children.length > 0) { getTreeLeaf(item.children, leafList) } else { leafList.push(item) } }) } else { if (treeData.children && treeData.children.length > 0) { getTreeLeaf(treeData.children, leafList) } else { leafList.push(treeData) } } } // 获取包含汉字的字符串的长度 function getStringSizeLength(string) { //先把中文替换成两个字节的英文,再计算长度 return string.replace(/[\u0391-\uFFE5]/g, "aa").length; } // 生成随机的字符串 function randomString(strLength) { strLength = strLength || 32; let strLib = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz" let n = ""; for (let i = 0; i < strLength; i++) { n += strLib.charAt(Math.floor(Math.random() * strLib.length)); } return n } // 获取字符串的像素宽度 function getPXwidth(str, fontSize = "12px", fontFamily = "Microsoft YaHei") { var span = document.createElement("span"); var result = {}; result.width = span.offsetWidth; result.height = span.offsetHeight; span.style.visibility = "hidden"; span.style.fontSize = fontSize; span.style.fontFamily = fontFamily; span.style.display = "inline-block"; document.body.appendChild(span); if (typeof span.textContent != "undefined") { span.textContent = str; } else { span.innerText = str; } result.width = parseFloat(window.getComputedStyle(span).width) - result.width; // 字符串的显示高度 // result.height = parseFloat(window.getComputedStyle(span).height) - result.height; return result.width; } </script>
使用范例
<template> <superMindmap v-if="showMindMap" :active='active' :data="mapData.webMap" @activeChange="activeChange"/> </template> <script> // 导入思维导图数据 import MapData from './MapData.js' // 导入思维导图组件 import superMindmap from './superMindmap.vue' export default { components:{superMindmap}, data() { return { active: '', mapData: null, showMindMap: false } }, mounted() { // 获取到数据后,再加载思维导图 this.mapData = MapData.web this.showMindMap = true }, methods: { // 点击思维导图节点后,触发变量更新 activeChange(newLabel) { this.active = newLabel this.reloadMindMap() }, // 重载思维导图 reloadMindMap() { this.showMindMap = false this.$nextTick( () => { this.showMindMap = true } ) }, } } </script>
数据范例
const webMap = { "label": "前端", "prop": "web", "url": 'https://blog.csdn.net/weixin_41192489/category_9421858.html', "link": "博客", "children": [ { "label": "编程语言", "prop": "codeType", "disabled": true, "children": [ { "label": "HTML", "prop": "HTML", }, { "label": "CSS", "prop": "CSS", }, { "label": "Javascript", "prop": "Javascript", }, ] }, { "label": "JS框架", "prop": "jsFrame", "disabled": true, "children": [ { "label": "Vue", "prop": "Vue", }, { "label": "React", "prop": "React", }, { "label": "Angular", "prop": "Angular", dicType: 'doc' }, ] }, { "label": "UI框架", "prop": "uiFrame", "disabled": true, "url": '', "children": [ { "label": "Element UI", "prop": "element_ui", "url": 'https://element.eleme.cn/#/zh-CN/component/i18n', "link": "官网", }, { "label": "iview UI", "prop": "iview UI", "url": 'http://v1.iviewui.com/docs/introduce', "link": "官网", }, { "label": "layUI", "prop": "layUI", "url": 'https://www.layui.com/doc/', "link": "官网", }, { "label": "Ant Design", "prop": "Ant Design", "url": 'https://www.antdv.com/docs/vue/introduce-cn/', "link": "官网", }, ] }, ] } const serverMap = { "label": "后端", "prop": "server", "url": 'https://blog.csdn.net/weixin_41192489/category_11044490.html', "link": "博客", "children": [ { "label": "编程语言", "prop": "codeType", disabled:true, "children": [ { "label": "Node.js", "prop": "nodejs", dicType: 'doc' }, { "label": "Java", "prop": "java", }, ] }, { "label": "框架", "prop": "frame", disabled:true, "children": [ { "label": "Koa2", "prop": "koa2", }, ] }, { "label": "数据库", "prop": "database", disabled:true, "children": [ { "label": "Redis", "prop": "Redis", dicType: 'doc' }, { "label": "MongoDB", "prop": "MongoDB", dicType: 'doc' }, { "label": "MySQL", "prop": "MySQL", dicType: 'doc' }, ] }, ] } export default { webMap, serverMap }
最终效果