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
}

最终效果

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐