視覺化—AntV G6 緊湊樹實現節點與邊動態樣式、超過X條展示更多等實用小功能

Echoyya、發表於2023-03-29

透過一段時間的使用和學習,對G6有了更一步的經驗,這篇博文主要從以下幾個小功能著手介紹,文章最後會給出完整的demo程式碼。

1. 樹圖的基本佈局和使用

樹圖的佈局,使用的模板是官網所提供的 緊湊樹模板,在此基礎上,進行一些定製化的改造,官網緊湊樹案例

屬性說明:

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    // .....
    layout: {
      type: 'compactBox',    // 佈局型別
      direction: 'LR',       // 樹圖佈局方向, 從左向右
      getHeight: function getHeight() { // 高度
        return 16
      },
      getWidth: function getWidth() {  // 寬度
        return 16
      },
      getVGap: function getVGap() {   // 節點之間 垂直間距
        return 25
      },
      getHGap: function getHGap() {  // 節點之間 水平間距
        return 150
      }
    }
})

2. 根據返回資料的屬性不同,定製不一樣的節點樣式

大部分在實際專案中,資料都是由後端返回,可能會存在多種型別的資料,需要進行不同的處理和展示,那麼此時只在 graph 初始化時,定義defaultNode顯然是不夠用的,G6支援動態改變節點樣式。需要在 graph 例項化之後:

// 以下函式均在下方有實現程式碼:
graph.node((node)=> {
  return {
    label: node.label || formatLabel(node),   
    icon: formatIcon(node),
    size: node.size || 40,
    labelCfg: { position: setLabelPos(node) },  // label 顯示位置
    style: {
      fill: getNodeColor(),
      stroke: getNodeColor()
    }
  }
})

3. 節點 label 文案顯示過長時,透過截斷的方式,顯示...

首先判斷當前節點有無子節點,後續會進行label的拼接,顯示葉子節點的數量,在進行結束,此書由於資料的原因,案例中資料labelid使用同一個欄位,後續同學們可以自己按實際情況進行調整:我擷取的長度是 15

// label 過長截斷 顯示...
const formatLabel = (node) => {
  const hasChildren = node.childrenBak?.length || node.children?.length
  const ellipsis = node.id.length > 15 ? '...' : ''
  return `${node.id.slice(0, 15)}${ellipsis}${hasChildren ? ' (' + hasChildren + ')' : ''}`
}

4. 當一個父節點包含children葉子節點時,label後顯示children的長度,格式為:node.label(children.length)

這個小功能,與上一個label 截斷,統一都是處理label的,因此放在同一個函式中返回,其主要實現程式碼為 這一行:

 return `${node.id.slice(0, 15)}${ellipsis}${hasChildren ? ' (' + hasChildren + ')' : ''}`

5. 截斷後的label ,透過滑鼠懸浮,完全顯示在 tooltip 中,定義並改寫 tooltip 樣式

這個小功能設計的改動比較多,同學們在看的時候,不要看錯了哈

第一步:首先定義一個函式,返回tooltip,官網的案例中並不是返回的函式,而是直接返回了一個物件,這個在實際使用過程中會存在問題,就是新增的資料無法使用到這個外掛,因此透過函式呼叫的方式 ,可以解決該現象:

// label 顯示... 時,顯示提示 tip
const treeTooltip = ()=> {
  return new G6.Tooltip({
    offsetX: 10,  // 滑鼠偏移量
    offsetY: 20,
    shouldBegin(e: any) {
      return e.item?.get('model')?.label?.includes('...')   // label中有...才顯示,表示被截斷
    },
    getContent(e: any) {
      let outDiv = document.createElement('div')   // 設定tip容器和樣式
      outDiv.innerHTML = `
      <p style="max-width:600px;word-wrap:break-word;border-radius:5px;font-size:15px;color:#fff;background:#333;padding:10px">
        ${e.item.getModel().id} 
      </p>`
      return outDiv
    },
    itemTypes: ['node']    // 表示觸發的元素型別
  })
}

第二步:在new 例項化的options中新增 外掛使用

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    plugins: [treeTooltip()],
    // .....
})

第三步:完整以上程式碼後,基本可以看出tip的提示框,但是由於是改寫原有的樣式,因此還需要 改一下 style, 由於畫布操作較多,因此canvas畫布,修改滑鼠樣式,全文只有這裡提到了style修改。就寫在一起了,實際並不影響 tooltip 功能

<style scoped>
#container >>> .g6-component-tooltip {
    background:#333;
    color:#fff;
    padding: 0 8px;
}

canvas {
  cursor: pointer !important;
}
</style>

6. 根據節點展開收起狀態 動態變更 label 顯示位置,展開時在上,收起時在右

此功能的設計是為了最佳化,當節點label過長並展開時,父子之間水平間距不夠時會出現文案互相重疊等問題,做了一個小最佳化,

首先判斷節點是否是展開狀態,以及是否有葉子節點,節點有children並展開時在上,其餘情況都顯示在右邊,這是初始化時的程式碼:

// 根據節點展開收起狀態 動態變更 label 顯示位置,展開時在上,收起時在右
const setLabelPos = (node) => {
  return !node.collapsed && node.children?.length ? 'top' : 'right'
}

因為樹圖可以監聽節點的展開收起狀態,因此在切換的時候,也需要進行 label定位的問題:在new 例項化的options中新增 modes

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    plugins: [treeTooltip()],
    // .....
    modes: {
       default: [
          {
            type: 'collapse-expand',
            onChange: function onChange(item: any, collapsed) {
              const data = item?.get('model')
              data.collapsed = collapsed
              const model = {
                id: data.id,
                labelCfg: { position: !collapsed ? 'top' : 'right' }
              }
              item.update(model)
              item.refresh()
              return true
            }
          },
          'drag-canvas',
          'zoom-canvas'
        ]
     }
}) 

7. 設定節點的icon樣式和背景色(隨機色可自行定製)

廢話不多話,處理節點的icon圖表,圖表可以使用圖片,也可以使用文字,此處就用文字了,擷取的label前兩個字串,並設定背景顏色(隨機色可自行定製)

// 葉子節點 圖示處理  擷取ID 前兩個字串
const formatIcon = (node) => {
  node.icon = {
    text: node.id.slice(0,2),
    fill: '#fff',
    stroke: '#fff',
    textBaseline: 'middle',
    fontSize: 20,
    width: 25,
    height: 25,
    show: true
  }
}

// 葉子節點 背景顏色隨機填充
const getNodeColor = () =>  {
  const colors = ["#8470FF",  "#A020F0", "#C0FF3E", "#FF4500", "#66d6d1"];
  return colors[Math.floor(Math.random() * colors.length)];
}

8. 葉子節點超過 (xxx)條, 摺疊葉子節點,顯示展開更多

當葉子節點很多時,並不想全部展開,而是先只展示一部分,其他節點摺疊在 展開按鈕中,實現思路,定義一個屬性,接受原有全部children, 然後進行擷取,在push一個 展開按鈕,實現:

第一步:定義一個childrenBak屬性接受 children資料

//  子還有子,因此需要遞迴
const splitChild = (node) => {
  node.childrenBak = node.children ? [...node.children] : []
  let result: any = []

  if(node.children){
    result = node.children.slice(0, 5)
    if (node.children.length > 5) {
      result.push({ id: `Expand-${node.id}`, label: ' 展開更多...' })
    }
    node.children = result
    node.children.forEach(child =>{
      splitChild(child)
    })  
  }
}

9. 點選展開更多節點,渲染被摺疊的葉子節點

第二步:(接功能小8繼續)點選展開更多時,顯示被摺疊的剩餘節點,,定義node:click事件

思路:找到展開更多 , 找到展開更多節點的 父節點, 更新父節點的 children

graph.on('node:click', (evt) => {
    const { item } = evt
    const node = item?.get('model')
    if (node.id.includes('Expand')) {  // id 中包含Expand 表示是展開更多 
      const parentNode = graph.getNeighbors(item, 'source')[0].get('model')  // 找到展開更多節點的 父節點
      graph.updateChildren(parentNode.childrenBak, parentNode.id)   //  使用上一步宣告的childrenBak 更新父節點的 children
    } 
  })

10. 定義滑鼠點選事件,聚焦當前點選節點至畫布中心點

小9 說了點選事件,那麼點選事件中,還有一個小的最佳化點,就是將當前點選的節點,移動至畫布中心,並賦予高亮選中樣式

  const animateCfg = { duration: 200, easing: 'easeCubic' }
  graph.on('node:click', (evt) => {
    const { item } = evt
    const node = item?.get('model')
    // if (node.id.includes('Expand')) {    //  功能點 9 程式碼
    //   const parentNode = graph.getNeighbors(item, 'source')[0].get('model')
    //   console.log(parentNode,parentNode.childrenBak);
    //   graph.updateChildren(parentNode.childrenBak, parentNode.id)
    // } 
    setTimeout(() => {
      if (!node.id.includes('Expand')) {
        graph.focusItem(item, true, animateCfg)
        graph.getNodes().forEach((node) => {   
          graph.clearItemStates(node)  // 先清空其他節點的 高亮樣式
        })
        graph.setItemState(item, 'selected', true)   // selected 需要在例項化處進行定義
      }
    }, 500)
  })

selected 表示的是 nodeStateStyles,也就是節點狀態樣式

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    plugins: [treeTooltip()],
    // .....
    nodeStateStyles: {
      active: {   // 這個用在了 滑鼠懸浮,可以自行定義
        fill: 'l(0) 0:#FF4500 1:#32CD32',
        stroke: 'l(0) 0:#FF4500 1:#32CD32',
        lineWidth: 5
      },
      selected: {  // 這個用在了 滑鼠選中,可以自行定義
        fill: 'l(0) 0:#FF4500 1:#32CD32',
        stroke: 'l(0) 0:#FF4500 1:#32CD32',
        lineWidth: 5
      }
    }
}) 

11. 定義滑鼠移入移出事件

  graph.on('node:mouseenter', (evt) => {
    const { item } = evt
    graph.setItemState(item, 'active', true)   // active 與 selected 都是節點狀態樣式
  })

  graph.on('node:mouseleave', (evt) => {
    const { item } = evt
    graph.setItemState(item, 'active', false)
  })

12. 根據返回資料的屬性不同,定製不一樣的 邊 樣式

關於節點的差不多介紹完了,關於連線,內容就比較少了,動態定義連線樣式,及連線上的文字樣式:

可以根據 link的不同屬性自定義 連線顏色和label顏色,因為是測試資料,因此就用一個自增長的數判斷奇偶性來進行區分,以便明白其中定製化的方法

let selfGrowthNum = 0
graph.edge((edge)=> {
    // let {source, target } = edge   // 解構連線的 起始節點
    selfGrowthNum++
    return {
      style: {
        opacity: 0.5,
        stroke: selfGrowthNum % 2 ? '#ADD8E6' : "#FFDEAD",
        lineWidth: 2
      },
      labelCfg: {
        position: 'end',
        style: {
          fontSize: 16,
          fill: selfGrowthNum % 2 ? '#ADD8E6' : "#FFDEAD",
        }
      },
      label:  selfGrowthNum % 2 ? 'even' : "odd"
    }
  })

13. 設定連線上關係文案樣式

上述程式碼基本完成了 連線的樣式和文案的樣式,但此時,線是貫穿文字的,看著比較亂,因此還需要修改連線樣式 defaultEdge

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    // .....
    defaultEdge: {
      type: 'cubic-horizontal',
      style: {    // 如果不定製化,這個就是預設樣式
        opacity: 0.5,
        stroke: '#ccc',
        lineWidth: 2
      },
      labelCfg: {
        position: 'end',   // 文字顯示線上段的哪個位置,
        refX: -15,
        style: {
          fontSize: 16,
          background: {
            fill: '#ffffff',  // 給文字新增背景色,解決文字被橫穿的問題
            padding: [2, 2, 2, 2]
          }
        }
      }
    }
})

14. 解決畫布拖拽,出現黑色殘影問題

G6 4.x 依賴的渲染引擎 @antv/g@4.x 版本支援了區域性渲染,帶了效能提升的同時,也帶來了圖形更新時可能存在渲染殘影的問題。比如拖拽節點時,節點的文字會留下軌跡。由於目前 @antv/g 正在進行大版本的升級(到 5.x),可能不考慮在 4.x 徹底修復這個問題。當我們遇到這個問題的時候,可以透過關閉區域性渲染的方法解決,但是這樣可能導致效能有所降低。

graph.get('canvas').set('localRefresh', false)。

15. Demo 動圖演示

16. 完整Demo案例

<template>
  <div id="container"></div>
</template>

<script lang="ts" setup>
import G6 from "@antv/g6";
import { onMounted } from "vue";

let graph: any = null;

// 樹圖初始資料
const treeData = {
  id: "Modeling Methods",
  color: "",
  children: [
    {
      id: "Classification",
      children: [
        { id: "Logistic regression" },
        { id: "Linear discriminant analysis" },
        { id: "Rules" },
        { id: "Decision trees" },
        { id: "Naive Bayes" },
        { id: "Knearest neighbor" },
        { id: "Probabilistic neural network" },
        { id: "Support vector machine" },
      ],
    },
    {
      id: "Methods",
      children: [
        { id: "Classifier selection" },
        { id: "Models diversity" },
        { id: "Classifier fusion" },
      ],
    },
  ],
};
onMounted(() => {
  splitChild(treeData);
  drawTreeGraph();
});

function drawTreeGraph() {
  if (graph) graph.destroy();
  const container = document.getElementById("container") as HTMLElement;

  graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth - 300,
    height: document.documentElement.clientHeight,
    fitView: false,
    fitViewPadding: [10, 50, 10, 50],
    animate: true,
    plugins: [treeTooltip()],
    defaultNode: {
      type: "circle",
      size: 40,
      collapsed: false,
      style: {
        fill: "#fff",
        lineWidth: 2,
        cursor: "pointer",
      },
      labelCfg: {
        position: "right",
        offset: 10,
        style: {
          fill: "#333",
          fontSize: 20,
          stroke: "#fff",
          background: {
            fill: "#ffffff",
            padding: [2, 2, 2, 2],
          },
        },
      },
      anchorPoints: [
        [0, 0.5],
        [1, 0.5],
      ],
      icon: {
        show: true,
        width: 25,
        height: 25,
      },
    },
    defaultEdge: {
      type: "cubic-horizontal",
      labelCfg: {
        position: "end",
        refX: -15,
        style: {
          fontSize: 16,
          background: {
            fill: "#ffffff",
            padding: [2, 2, 2, 2],
          },
        },
      },
    },
    modes: {
      default: [
        {
          type: "collapse-expand",
          onChange: function onChange(item: any, collapsed) {
            const data = item?.get("model");
            data.collapsed = collapsed;
            const model = {
              id: data.id,
              labelCfg: { position: !collapsed ? "top" : "right" },
            };
            item.update(model);
            item.refresh();
            return true;
          },
        },
        "drag-canvas",
        "zoom-canvas",
      ],
    },
    layout: {
      type: "compactBox",
      direction: "LR",
      getHeight: function getHeight() {
        return 30;
      },
      getWidth: function getWidth() {
        return 16;
      },
      getVGap: function getVGap() {
        return 30;
      },
      getHGap: function getHGap() {
        return 150;
      },
    },
    nodeStateStyles: {
      active: {
        fill: "l(0) 0:#FF4500 1:#32CD32",
        stroke: "l(0) 0:#FF4500 1:#32CD32",
        lineWidth: 5,
      },
      selected: {
        fill: "l(0) 0:#FF4500 1:#32CD32",
        stroke: "l(0) 0:#FF4500 1:#32CD32",
        lineWidth: 5,
      },
    },
  });

  graph.node((node: { label: any; size: any }) => {
    return {
      label: node.label || formatLabel(node),
      icon: formatIcon(node),
      size: node.size || 40,
      labelCfg: { position: setLabelPos(node) },
      style: {
        fill: getNodeColor(),
        stroke: getNodeColor(),
      },
    };
  });
  let selfGrowthNum = 0;
  graph.edge((edge: any) => {
    // let {source, target } = edge   // 也可以根據 link的屬性不同自定義 連線顏色和label顏色,因為是測試資料,因此就用一個自增長的數判斷奇偶性來進行區分,以便明白其中定製化的方法
    selfGrowthNum++;
    return {
      style: {
        opacity: 0.5,
        stroke: selfGrowthNum % 2 ? "#ADD8E6" : "#FFDEAD",
        lineWidth: 2,
      },
      labelCfg: {
        position: "end",
        style: {
          fontSize: 16,
          fill: selfGrowthNum % 2 ? "#ADD8E6" : "#FFDEAD",
        },
      },
      label: selfGrowthNum % 2 ? "even" : "odd",
    };
  });

  graph.on("node:mouseenter", (evt: { item: any }) => {
    const { item } = evt;
    graph.setItemState(item, "active", true);
  });

  graph.on("node:mouseleave", (evt: { item: any }) => {
    const { item } = evt;
    graph.setItemState(item, "active", false);
  });

  const animateCfg = { duration: 200, easing: "easeCubic" };
  graph.on("node:click", (evt: { item: any }) => {
    const { item } = evt;
    const node = item?.get("model");
    if (node.id.includes("expand")) {
      const parentNode = graph.getNeighbors(item, "source")[0].get("model");
      console.log(parentNode, parentNode.childrenBak);
      graph.updateChildren(parentNode.childrenBak, parentNode.id);
    }
    setTimeout(() => {
      if (!node.id.includes("expand")) {
        graph.focusItem(item, true, animateCfg);
        graph.getNodes().forEach((node: any) => {
          graph.clearItemStates(node);
        });
        graph.setItemState(item, "selected", true);
      }
    }, 500);
  });

  graph.on("canvas:click", () => {
    graph.getNodes().forEach((node: any) => {
      graph.clearItemStates(node);
    });
  });

  graph.data(treeData);
  graph.render();
  graph.zoom(0.9);
  graph.fitCenter();
  graph.get("canvas").set("localRefresh", false);
}
// label 過長截斷 顯示...
const formatLabel = (node) => {
  const hasChildren = node.childrenBak?.length || node.children?.length;
  const ellipsis = node.id.length > 15 ? "..." : "";
  return `${node.id.slice(0, 15)}${ellipsis}${
    hasChildren ? " (" + hasChildren + ")" : ""
  }`;
};

// 葉子節點 圖示處理  擷取ID 前兩個字串
const formatIcon = (node) => {
  node.icon = {
    text: node.id.slice(0, 2),
    fill: "#fff",
    stroke: "#fff",
    textBaseline: "middle",
    fontSize: 20,
    width: 25,
    height: 25,
    show: true,
  };
};

// 葉子節點 背景顏色隨機填充
const getNodeColor = () => {
  const colors = ["#8470FF", "#A020F0", "#C0FF3E", "#FF4500", "#66d6d1"];
  return colors[Math.floor(Math.random() * colors.length)];
};

// 根據節點展開收起狀態 動態變更 label 顯示位置,展開時在上,收起時在右
const setLabelPos = (node: { collapsed: any; children: string | any[] }) => {
  return !node.collapsed && node.children?.length ? "top" : "right";
};
// label 顯示... 時,顯示提示 tip
const treeTooltip = () => {
  return new G6.Tooltip({
    offsetX: 10,
    offsetY: 20,
    shouldBegin(e: any) {
      return e.item?.get("model")?.label?.includes("...");
    },
    getContent(e: any) {
      let outDiv = document.createElement("p");
      outDiv.innerHTML = ` ${e.item.getModel().id} `;
      return outDiv;
    },
    itemTypes: ["node"],
  });
};

// 葉子節點超過 5(xxx)條, 摺疊葉子節點,顯示展開更多
const splitChild = (node: any) => {
  node.childrenBak = node.children ? [...node.children] : [];
  let result: any = [];

  if (node.children) {
    result = node.children.slice(0, 5);
    if (node.children.length > 5) {
      result.push({ id: `expand-${node.id}`, label: " 展開更多..." });
    }
    node.children = result;
    node.children.forEach((child: any) => {
      splitChild(child);
    });
  }
};
</script>
<style scoped>
#container >>> .g6-component-tooltip {
    background:#333;
    color:#fff;
    padding: 0 8px;
}

canvas {
  cursor: pointer !important;
}
</style>

相關文章