基于G6画个xmind出来

公司产品因为业务发展,出现了一个新的需求:需要去实现知识库的层级知识展示,展示效果通过树图来实现,具体的展示形式可见下图:
树形图

其中有几个需要注意点:

  1. 节点上的详情icon可以点击,点击展开关闭详情
  2. 节点后的伸缩icon在伸缩状态下需要显示当前节点的子节点个数

这个效果有点类似xmind的交互效果了,但是树的节点不论是样式还是点击事件都被高度定制了,在这种情况下基于配置的Echarts们就无用武之地了,我们只能利用更加底层的G6图表引擎去实现。

具体如何安装G6可以参见G6的文档,下面仅仅是选用文档中的第二种安装方式快速引入,写个demo出来验证可行。

首先我们需要完成G6的初始化等前置准备工作

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>树图</title>
    <style>
        ::-webkit-scrollbar {
            display: none;
        }
 
        html, body {
            background-color: #f0f2f5;
            overflow: hidden;
            margin: 0;
        }
    </style>
</head>
<body>
<div id="mountNode"></div>
<script src="https://gw.alipayobjects.com/os/antv/pkg/_antv.g6-3.1.0/build/g6.js"></script>
<script src="https://gw.alipayobjects.com/os/antv/pkg/_antv.hierarchy-0.5.0/build/hierarchy.js"></script>
<script src="https://gw.alipayobjects.com/os/antv/assets/lib/jquery-3.2.1.min.js"></script>
<script>
    const CANVAS_WIDTH = window.innerWidth;
    const CANVAS_HEIGHT = window.innerHeight;
 
    // 使用G6的TreeGraph
    graph = new G6.TreeGraph({
        container: "mountNode",
 
         CANVAS_WIDTH,
        height: CANVAS_HEIGHT,
        defaultNode: {
            shape: "rect",
        },
        defaultEdge: {
            shape: "cubic-horizontal",
            style: {
                stroke: "rgba(0,0,0,0.25)"
            }
        },
        layout: (data) => {
            return Hierarchy.compactBox(data, {
                direction: "LR",
                getId: function getId(d) {
                    return d.id;
                },
                getWidth: function getWidth() {
                    return 243;
                },
                getVGap: function getVGap() {
                    return 24;
                },
                getHGap: function getHGap() {
                    return 50;
                }
            });
        }
    });
 
    function formatData(data) {
        const recursiveTraverse = function recursiveTraverse(node, level) {
            const targetNode = {
                id: node.itemId + '',
                level: level,
                type: node.value,
                name: node.name,
                value: node.content,
                collapsed: level > 0,
                showDetail: false,
                origin: node,
            };
            if (node.children) {
                targetNode.children = [];
                node.children.forEach(function (item) {
                    targetNode.children.push(recursiveTraverse(item, level + 1));
                });
            }
            return targetNode;
        };
        return recursiveTraverse(data, 0);
    }
 
    // 获取数据,渲染图表
    $.getJSON('https://eliteapp.fanruan.com/certification/data.json', function (data) {
        data = formatData(data);
        graph.data(data);
        graph.render();
    });
</script>
</body>
</html>

之后我们便开始我们的自定义节点的设置,可以参考下自定义节点的文档

const getNodeConfig = function getNodeConfig(node) {
    let config = {
        basicColor: "#722ED1",
        fontColor: "rgb(51, 51, 51)",
        bgColor: "#ffffff"
    };
    // 请无视这种中文的判断,这里获取的数据为中文,就不做额外处理,直接拿来判断了
    switch (node.type) {
        case "标签": {
            config = {
                basicColor: 'rgba(61, 77, 102, 1)',
                fontColor: "rgb(51, 51, 51)",
                bgColor: "#ffffff"
            };
            break;
        }
        case "分类": {
            config = {
                basicColor: 'rgba(159, 230, 184, 1)',
                fontColor: "rgb(51, 51, 51)",
                bgColor: "#ffffff"
            };
            break;
        }
        case "业务问题":
            config = {
                basicColor: "rgba(45, 183, 245, 1)",
                fontColor: "rgb(51, 51, 51)",
                bgColor: "#ffffff"
            };
            break;
        default:
            break;
    }
    return config;
};
 
 
const nodeBasicMethod = {
    createNodeBox: function createNodeBox(group, config, width, height, isRoot) {
        // 最外面的大矩形,作为节点元素的容器
        const container = group.addShape("rect", {
            attrs: {
                x: 0,
                y: 0,
                 width,
                height: height,
            },
            className: 'node-container',
        });
        if (!isRoot) {
            // 不是跟节点,创建左边的小圆点
            group.addShape("circle", {
                attrs: {
                    x: 3,
                    y: height / 2,
                    r: 6,
                    fill: config.basicColor
                },
                className: 'node-left-circle',
            });
        }
        // 节点标题的矩形
        group.addShape("rect", {
            attrs: {
                x: 3,
                y: 0,
                 width - 19,
                height: height,
                fill: config.bgColor,
                radius: 2,
                cursor: "pointer"
            },
            className: 'node-main-container',
        });
 
        // 节点标题左边的粗线
        group.addShape("rect", {
            attrs: {
                x: 3,
                y: 0,
                 3,
                height: height,
                fill: config.basicColor,
            },
            className: 'node-left-line',
        });
        return container;
    },
    createDetailIcon: function createDetailIcon(group) {
        // icon外面的矩形,用来计算icon的宽度
        const iconRect = group.addShape("rect", {
            attrs: {
                fill: "#FFF",
                radius: 2,
                cursor: "pointer"
            }
        });
        iconRect.attr({
            x: 154,
            y: 6,
             24,
            height: 24
        });
        // 设置icon的图片
        group.addShape("image", {
            attrs: {
                x: 154,
                y: 6,
                height: 24,
                 24,
                img: "https://eliteapp.fanruan.com/web-static/media/close.svg",
                cursor: "pointer",
                opacity: 1
            },
            className: "node-detail-icon"
        });
        // 放一个透明的矩形在 icon 区域上,方便监听点击
        group.addShape("rect", {
            attrs: {
                x: 160,
                y: 12,
                 12,
                height: 12,
                fill: "#FFF",
                cursor: "pointer",
                opacity: 0
            },
            className: "node-detail-box",
        });
        return iconRect.getBBox().width;
    },
    createNodeName: (group, config) => {
        group.addShape("text", {
            attrs: {
                // 根据 icon 的宽度计算出剩下的留给 name 的长度
                text: "node title",
                x: 18,
                y: 18,
                fontSize: 13,
                fontWeight: 400,
                textAlign: "left",
                textBaseline: "middle",
                fill: config.fontColor,
                cursor: "pointer"
            },
            className: 'node-name-text',
        });
    },
    createNodeDetail: function createNodeDetail(group, config) {
        // 节点的类别说明,即 # 业务问题
        group.addShape('text', {
            attrs: {
                text: '',
                x: 18,
                y: 45,
                fontSize: 10,
                lineHeight: 16,
                textAlign: "left",
                textBaseline: "middle",
                fill: config.basicColor,
                cursor: "pointer",
            },
            className: 'node-detail-info'
        });
        // 节点的详情
        group.addShape("text", {
            attrs: {
                text: '',
                x: 18,
                y: 45,
                fontSize: 11,
                lineHeight: 16,
                textAlign: "left",
                textBaseline: "middle",
                fill: 'rgb(51, 51, 51)',
                cursor: "pointer",
            },
            className: "node-detail-text",
        });
        // 节点的 查看详情 按钮
        group.addShape('text', {
            attrs: {
                text: '',
                x: 18,
                y: 61,
                fontSize: 11,
                lineHeight: 16,
                textAlign: "left",
                textBaseline: "middle",
                fill: config.basicColor,
                cursor: "pointer",
            },
            className: "node-detail-link",
        });
        // 节点的 反馈问题 按钮
        group.addShape('text', {
            attrs: {
                text: '',
                x: 99,
                y: 61,
                fontSize: 11,
                lineHeight: 16,
                textAlign: "left",
                textBaseline: "middle",
                fill: config.basicColor,
                cursor: "pointer",
            },
            className: "node-detail-feedback",
        });
    },
    createNodeMarker: function createNodeMarker(group, collapsed, x, y, childrenNum) {
        // 伸缩按钮的圆形背景
        group.addShape("circle", {
            attrs: {
                x: x,
                y: y,
                r: 13,
                fill: "rgba(47, 84, 235, 0.05)",
                opacity: 0,
                zIndex: -2
            },
            className: "collapse-icon-bg"
        });
        // 伸缩按钮的 节点数量 文字
        group.addShape("text", {
            attrs: {
                x: x,
                y: y + (7 / 2),
                text: collapsed ? childrenNum : '-',
                textAlign: "center",
                fontSize: 10,
                lineHeight: 7,
                stroke: "rgba(0,0,0,0.25)",
                fill: "rgba(0,0,0,0)",
                opacity: 1,
                cursor: "pointer"
            },
            className: "collapse-icon-num"
        });
        // 伸缩按钮的圆形边框
        group.addShape("circle", {
            attrs: {
                x: x,
                y: y,
                r: 7,
                stroke: "rgba(0,0,0,0.25)",
                fill: "rgba(0,0,0,0)",
                opacity: 1,
                cursor: "pointer"
            },
            className: "collapse-icon"
        });
    },
};
 
const TREE_NODE = "tree-node";
G6.registerNode(TREE_NODE, {
    drawShape: function drawShape(cfg, group) {
        // 获取节点的颜色配置
        const config = getNodeConfig(cfg);
        const isRoot = cfg.type === "标签";
        // 最外面的大矩形
        // 这里的宽度为写死的宽度,全部节点的宽度统一,高度为data在处理时赋予的高度
        const container = nodeBasicMethod.createNodeBox(group, config, NODE_WIDTH, cfg.nodeHeight, isRoot);
        // 创建节点详情展开关闭的icon
        nodeBasicMethod.createDetailIcon(group);
        // 创建节点标题
        nodeBasicMethod.createNodeName(group, config);
        // 创建节点详情
        nodeBasicMethod.createNodeDetail(group, config);
 
        const childrenNum = (cfg.children || []).length;
        if (childrenNum > 0) {
            // 创建节点的伸缩icon
            nodeBasicMethod.createNodeMarker(group, cfg.collapsed, 191, 18, childrenNum);
        }
 
        return container;
    },
}, "single-shape");

defaultNode: {
    // 在G6的初始化中将节点改为使用自定义的节点
    shape: TREE_NODE,
},

此时,我们便可得到如图的示例:
MV0LE8.png

但是这里的跟节点的连线位置是四分五裂的,我们的交互图是统一到右侧中间伸缩icon右侧和节点左侧中间的,所以接下来我们需要对节点连线的控制点进行适配,节点的连接控制点可以参见G6的文档

defaultNode: {
    shape: TREE_NODE,
    // 全局设置节点的锚点控制点,分别在左侧中间和右侧中间
    anchorPoints: [[0, 0.5], [1, 0.5]]
},

此时的树形图便如下:
MVBZ8J.png

到这里之后,节点的效果图已经出来了,但是节点的详情交互还未实现,接下来开始实现详情的交互。

节点的交互主要为展开关闭节点详情、展开伸缩子树。

展开关闭节点详情由用户点击下拉icon触发,所以我们就需要监听节点的点击事件再具体一点就是监听节点icon的点击事件。

// 由于节点的文本不会换行,根据节点的宽度切分节点详情文本到数组中,然后进行换行
const fittingStringLine = function fittingStringLine(str, maxWidth, fontSize) {
    str = str.replace(/
/gi, '');
    const fontWidth = fontSize * 1.3; //字号+边距
 
    const actualLen = Math.floor(maxWidth / fontWidth);
    let width = strLen(str) * fontWidth;
    let lineStr = [];
    while (width > 0) {
        const substr = str.substring(0, actualLen);
        lineStr.push(substr);
 
        str = str.substring(actualLen);
        width = strLen(str) * fontWidth;
    }
    return lineStr;
};
 
const strLen = function strLen(str) {
    let len = 0;
    if(!str) {
        return len;
    }
 
    for (let i = 0; i < str.length; i++) {
        if (str.charCodeAt(i) > 0 && str.charCodeAt(i) < 128) {
            len++;
        } else {
            len += 2;
        }
    }
    return len;
};
 
 
const nodeBasicMethod = {
 
 
    afterDraw: function afterDraw(cfg, group) {
        // 伸缩icon的背景色交互
        const collapseIcon = group.findByClassName("collapse-icon");
        if (collapseIcon) {
            const bg = group.findByClassName("collapse-icon-bg");
            // 监听事件
            collapseIcon.on("mouseenter", function () {
                bg.attr("opacity", 1);
                graph.get("canvas").draw();
            });
            collapseIcon.on("mouseleave", function () {
                bg.attr("opacity", 0);
                graph.get("canvas").draw();
            });
        }
 
        // 下拉展示与隐藏节点详情
        const nodeDetailBox = group.findByClassName("node-detail-box");
        nodeDetailBox.on("click", function () {
            nodeBasicMethod.handleDetail(cfg, group);
        });
    },
    handleDetail: function handleDetail(cfg, group) {
        const circle = group.findByClassName('node-left-circle');
        const mainContainer = group.findByClassName('node-main-container');
        const nodeLeftLine = group.findByClassName('node-left-line');
        const rightCircleBg = group.findByClassName('collapse-icon-bg');
        const rightCircleIconNum = group.findByClassName('collapse-icon-num');
        const rightCircleIcon = group.findByClassName('collapse-icon');
 
        const nodeDetailText = group.findByClassName('node-detail-text');
        const nodeDetailInfo = group.findByClassName('node-detail-info');
        const nodeDetailLink = group.findByClassName('node-detail-link');
        const nodeDetailFeedback = group.findByClassName('node-detail-feedback');
 
        // 查找节点在树上的下方节点
        const node = graph.findById(cfg.id);
        const nodes = graph.findAll('node', item => {
            const model = item.getModel();
            return model.level === node.getModel().level;
        });
        const leftNodes = nodes.slice(nodes.indexOf(node) + 1);
 
        let nodeHeight;
        if (cfg.showDetail) {
            // 详情已经展开,开始关闭详情
            nodeHeight = NODE_HEIGHT;
 
            // 关闭详情
            nodeDetailText.attr('text', '');
            nodeDetailInfo.attr('text', '');
            nodeDetailLink.attr('text', '');
            nodeDetailFeedback.attr('text', '');
 
            // 下方节点上移
            leftNodes.forEach((leftNode) => {
                leftNode.getModel().y = leftNode.getBBox().y - 80;
                graph.updateItem(leftNode, {
                    y: leftNode.getBBox().y - cfg.nodeHeight + NODE_HEIGHT,
                });
            });
 
            cfg.showDetail = false;
        } else {
            // 详情未展开,开始展开详情
 
            // 展示详情
            const detailText = fittingStringLine(cfg.value, 198, 12);
            nodeDetailText.attr('text', detailText.join('
'));
            nodeDetailText.attr('y', 45 + 16 + (detailText.length) * 8);
 
            nodeDetailInfo.attr('text', `# ${cfg.type}`);
            nodeDetailLink.attr('text', '查看详情');
            nodeDetailLink.attr('y', 45 + 16 + (detailText.length) * 16 + 16);
            nodeDetailFeedback.attr('text', '反馈问题');
            nodeDetailFeedback.attr('y', 45 + 16 + (detailText.length) * 16 + 16);
 
            nodeHeight = 45 + 16 + (detailText.length + 1) * 16 + 16;
 
            // 下方的节点下移
            leftNodes.forEach((leftNode) => {
                leftNode.getModel().y = leftNode.getBBox().y + 80;
                graph.updateItem(leftNode, {
                    y: leftNode.getBBox().y + nodeHeight - cfg.nodeHeight,
                });
            });
 
            cfg.showDetail = true;
        }
        cfg.nodeHeight = nodeHeight;
 
        // 调节节点元素高度
        circle.attr('y', nodeHeight / 2);
        mainContainer.attr('height', nodeHeight);
        nodeLeftLine.attr('height', nodeHeight);
        if (rightCircleBg && rightCircleIconNum && rightCircleIcon) {
            rightCircleBg.attr('y', nodeHeight / 2);
            // 计算伸缩icon的位置,G6在这里有个坑,canvas模式下的文本位置会产生偏差
            rightCircleIconNum.attr('y', nodeHeight / 2 + 5 + (nodeHeight - NODE_HEIGHT) * 0.1);
            rightCircleIcon.attr('y', nodeHeight / 2);
        }
 
        // 更新当前节点的高度
        graph.updateItem(node, Object.assign(cfg, {
            style: {
                height: nodeHeight,
            },
        }));
        graph.get('canvas').draw();
    },
};
G6.registerNode(TREE_NODE, {
    drawShape: function drawShape(cfg, group) {},
    // 设置监听
    afterDraw: nodeBasicMethod.afterDraw,
}, "single-shape");

此时,展开关闭详情的交互就已经实现了,如图:
MVDmFS.png

对于伸缩的交互,G6提供的树图自带了专用的伸缩Behavior,可以直接拿过来进行定制使用。

graph = new G6.TreeGraph({
    container: "mountNode",
 
     CANVAS_WIDTH,
    height: CANVAS_HEIGHT,
    defaultNode: {},
    defaultEdge: {},
    modes: {
        default: [{
            type: "collapse-expand",
            // 判断是否开始伸缩
            shouldBegin: function shouldBegin(e) {
                console.log('shouldBegin', e.target.get("className") === "collapse-icon");
                // 点击 node 禁止展开收缩,只有在点击到的是伸缩icon的时候才允许伸缩
                return e.target.get("className") === "collapse-icon";
            },
            // 伸缩状态发生改变
            onChange: function onChange(item, collapsed) {
                const icon = item.get("group").findByClassName("collapse-icon-num");
                icon.attr("text", collapsed ? item.getModel().children.length : '-');
 
                // 关闭全部的详情
                const detailNodeList = graph.findAll('node', node => {
                    return node.getModel().showDetail;
                });
                detailNodeList.forEach(detailNode => {
                    const group = detailNode.get('group');
                    const cfg = detailNode.getModel();
 
                    nodeBasicMethod.handleDetail(cfg, group);
                });
            },
        }]
    },
    layout: (data) => {}
});

我们的树图便可以正常的伸缩啦,如图:
MVDJoT.png

完整代码可暂时从这儿下载:https://files.cnblogs.com/files/tingyugetc/g6-tree.zip

原文地址:https://www.cnblogs.com/tingyugetc/p/11820008.html