基於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",
 
        width: 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: 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: width - 19,
                height: height,
                fill: config.bgColor,
                radius: 2,
                cursor: "pointer"
            },
            className: 'node-main-container',
        });
 
        // 節點標題左邊的粗線
        group.addShape("rect", {
            attrs: {
                x: 3,
                y: 0,
                width: 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,
            width: 24,
            height: 24
        });
        // 設置icon的圖片
        group.addShape("image", {
            attrs: {
                x: 154,
                y: 6,
                height: 24,
                width: 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,
                width: 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(/\n/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('\n'));
            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",
 
    width: 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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM