• 基于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

  • 相关阅读:
    23. Sum Root to Leaf Numbers
    22. Surrounded Regions
    21. Clone Graph
    19. Palindrome Partitioning && Palindrome Partitioning II (回文分割)
    18. Word Ladder && Word Ladder II
    14. Reverse Linked List II
    20. Candy && Gas Station
    16. Copy List with Random Pointer
    ubuntu 下建立桌面快捷方式
    java基础篇-jar打包
  • 原文地址:https://www.cnblogs.com/tingyugetc/p/11820008.html
Copyright © 2020-2023  润新知