1. 背景
業(yè)務(wù)背景:CRM 系統(tǒng)隨著各業(yè)務(wù)條線對線索精細(xì)化分配的訴求逐漸增加,各個條線的流向規(guī)則會越來越復(fù)雜,各個條線甚至整個 CRM 的線索流轉(zhuǎn)規(guī)則急需一種樹形的可視化的圖來表達。
技術(shù)背景:在開發(fā)之前考慮了三種方案,原生 canvas、fabric 以及 G6,三種方案各有優(yōu)劣勢
| 原生 canvas | fabric | G6 | |
|---|---|---|---|
| 優(yōu)點 | 靈活、自由、可定制化非常強 | 封裝了 canvas 的 api,使用簡單靈活 | 提供了復(fù)雜樹、圖等 api,只需要按照文檔配置即可 |
| 缺點 | 開發(fā)復(fù)雜、耗時 | 對于構(gòu)建大型樹、圖等復(fù)雜、耗時 | 在開發(fā)前需要認(rèn)真閱讀 api 文檔,上手慢 |
通過上述表格對比就可以看出來,對于構(gòu)建更為復(fù)雜的樹、圖等,G6 具備明顯的優(yōu)勢,而且又有活躍的開源社區(qū),為后續(xù)的維護升級提供了保證。
2. 基礎(chǔ)知識

下面是關(guān)于本次雙樹流轉(zhuǎn)繪制的幾個核心概念,簡單介紹一下,如有興趣,還是建議閱讀 G6 官方 API 文檔
圖 Graph
通過 new G6.Graph (config) 進行圖的實例化。其中參數(shù) config 是 Object 類型的圖的配置項,圖的大部分功能可以通過該配置項進行全局配置。
如果是樹圖,需要使用 new G6.TreeGraph 方法進行實例化
const graph = new G6.Graph({
container: 'mountNode', // 指定圖畫布的容器 id,與第 9 行的容器對應(yīng)
// 畫布寬高
width: 800,
height: 500,
});
// 讀取數(shù)據(jù)
graph.data(data);
// 渲染圖
graph.render();
圖元素 - 節(jié)點
G6 的內(nèi)置節(jié)點包括 circle,rect,ellipse,diamond,triangle,star,image,modelRect,donut(v4.2.5 起支持)。這些內(nèi)置節(jié)點的默認(rèn)樣式分別如下圖所示。
內(nèi)置節(jié)點配置
const graph = new G6.Graph({
container: 'mountNode',
width: 800,
height: 600,
defaultNode: {
type: 'circle', // 節(jié)點類型
// ... 其他配置
},
});
自定義節(jié)點配置
// 在節(jié)點配置中配置自定義節(jié)點名稱以及尺寸
defaultNode: {
type: 'card-node',
size: [338, 70],
}
// 使用G6.registerNode自定義節(jié)點,在自定義節(jié)點中可以自定義各種形狀的圖形,包括text等
G6.registerNode('card-node', {
draw: function drawShape(cfg, group) {
const w = cfg.size[0];
const h = cfg.size[1];
group.addShape('rect', {
attrs: {
x: -w / 2 - 2,
y: -(h - 30) / 2,
width: 6, //200,
height: h - 30, // 60
fill: '#3c6ef0',
radius: [0, 4, 4, 0]
},
name: 'mark-box',
draggable: true,
});
圖元素 - 邊
G6 提供了 9 種內(nèi)置邊: line:直線,不支持控制點; polyline:折線,支持多個控制點; arc:圓弧線; quadratic:二階貝塞爾曲線; cubic:三階貝塞爾曲線; cubic-vertical:垂直方向的三階貝塞爾曲線,不考慮用戶從外部傳入的控制點; cubic-horizontal:水平方向的三階貝塞爾曲線,不考慮用戶從外部傳入的控制點; loop:自環(huán)。
內(nèi)置邊配置
const graph = new G6.Graph({
container: 'mountNode',
width: 800,
height: 600,
defaultEdge: {
type: 'cubic',
// 其他配置
},
});
自定義邊配置
在配置中引用自定義邊
defaultEdge: {
type: 'hvh',
// 其他配置
}
// 使用G6.registerEdge方法配置自定義邊
G6.registerEdge('hvh', {
draw(cfg, group) {
const startPoint = cfg.startPoint;
const endPoint = cfg.endPoint;
const shape = group.addShape('path', {
attrs: {
stroke: '#333',
path: [
['M', startPoint.x, startPoint.y],
['L', endPoint.x / 3 + (2 / 3) * startPoint.x, startPoint.y], // 三分之一處
['L', endPoint.x / 3 + (2 / 3) * startPoint.x, endPoint.y], // 三分之二處
['L', endPoint.x, endPoint.y],
],
},
// 在 G6 3.3 及之后的版本中,必須指定 name,可以是任意字符串,但需要在同一個自定義元素類型中保持唯一性
name: 'path-shape',
});
return shape;
},
});
圖布局 - 樹圖布局
樹圖 TreeGraph 布局方法總覽 CompactBox Layout:緊湊樹布局; Dendrogram Layout:樹狀布局(葉子節(jié)點布局對齊到同一層); Indented Layout:縮進布局; Mindmap Layout:腦圖布局
const graph = new G6.TreeGraph({
container: 'mountNode',
modes: {
default: [
{
// 定義展開/收縮行為
type: 'collapse-expand',
},
'drag-canvas',
],
},
// 定義布局
layout: {
type: 'dendrogram', // 布局類型
direction: 'LR', // 自左至右布局,可選的有 H / V / LR / RL / TB / BT
nodeSep: 50, // 節(jié)點之間間距
rankSep: 100, // 每個層級之間的間距
excludeInvisibles: true, // 布局計算是否排除掉隱藏的節(jié)點,v4.8.8 起支持
},
});
交互與事件
全局事件 只要在畫布上范圍內(nèi)發(fā)生均會被觸發(fā),如 mousedown,mouseup,click,mouseenter,mouseleave 等。
graph.on('click', (ev) => {
const shape = ev.target;
const item = ev.item;
if (item) {
const type = item.getType();
}
});
canvas 事件
只在 canvas 空白處被觸發(fā),如 canvas:mousedown,canvas:click 等,以 canvas:eventName 為事件名稱。
graph.on('canvas:click', (ev) => {
const shape = ev.target;
const item = ev.item;
if (item) {
const type = item.getType();
}
});
節(jié)點 / 邊 上的事件
例如 node:mousedown,edge:click,combo:click 等,以 type:eventName 為事件名稱。
graph.on('node:click', (ev) => {
const node = ev.item; // 被點擊的節(jié)點元素
const shape = ev.target; // 被點擊的圖形,可根據(jù)該信息作出不同響應(yīng),以達到局部響應(yīng)效果
// ... do sth
});
graph.on('edge:click', (ev) => {
const edge = ev.item; // 被點擊的邊元素
const shape = ev.target; // 被點擊的圖形,可根據(jù)該信息作出不同響應(yīng),以達到局部響應(yīng)效果
// ... do sth
});
graph.on('combo:click', (ev) => {
const combo = ev.item; // 被點擊 combo 元素
const shape = ev.target; // 被點擊的圖形,可根據(jù)該信息作出不同響應(yīng),以達到局部響應(yīng)效果
// ... do sth
});
3. 技術(shù)方案 & 實施
數(shù)據(jù)準(zhǔn)備
要求數(shù)據(jù)中每一個節(jié)點必須有 id,且 id 為字符串類型。module 字段 root 表示根節(jié)點,right 表示子節(jié)點,left 表示父節(jié)點。flowCountList 表示邊,從某個節(jié)點到某個節(jié)點
data: {
id: '1',
name: '根節(jié)點',
flowInCount: 9999,
flowOutCount: 9999,
currentCount: 9999,
module: 'root',
children: [
{
id: '2',
name: '右一節(jié)點',
flowInCount: 9999,
flowOutCount: 9999,
currentCount: 9999,
module: 'son',
},
{
id: '3',
name: '左一節(jié)點',
flowInCount: 9999,
flowOutCount: 9999,
currentCount: 9999,
module: 'father',
}
]
}
flowCountList: [
{
fromPoolId: '1',
toPoolId: '2',
clueCount: 111
},
{
fromPoolId: '1',
toPoolId: '3',
clueCount: 222
}
]
初始化 Minimap 實例
如果需要加 Minimap 可以在畫布外層 div 中增加一個 div#minimap,將小地圖放入其中。下面配置中 delegate 表示小地圖只渲染畫布中元素的框架,以此來降低性能損耗。
const miniMap = document.getElementById('minimap');
const minimap = new G6.Minimap({
container: miniMap,
type: 'delegate',
size: [178, 138]
});
初始化樹圖
modes 中配置的 drag-canvas 表示支持畫布拖拽,zoom-canvas 表示支持畫布放大縮小,tooltip 表示給節(jié)點增加 tooltip 提示。 layout 中 getSide 方法會根據(jù)數(shù)據(jù)類型判斷當(dāng)前節(jié)點屬于父節(jié)點還是子節(jié)點,本方法只針對根節(jié)點有效。
this.graph = new G6.TreeGraph({
container: 'clueCanvas',
width:1000, // width和height可以根據(jù)自己畫布大小進行賦值
height:500,
modes: {
default: ['drag-canvas', 'zoom-canvas',{
type: 'tooltip',
formatText: function formatText(model) {
return model.name;
},
shouldBegin: (e) => {
const target = e.target;
if (target.get('name') === 'title') return true;
return false;
},
}],
},
defaultNode: {
type: 'card-node',
size: [338, 70],
},
defaultEdge: {
type: 'custom-edge',
style: {
lineWidth: 4,
lineAppendWidth: 8,
stroke: '#BABEC7',
}
},
layout: {
type: 'mindmap',
direction: 'H',
getHeight: () => {return 70;}, //節(jié)點高度
getWidth: () => {return 338;}, // 節(jié)點寬度
getVGap: () => {return 8;}, // 節(jié)點之間的垂直間距
getHGap: () => {return 100;}, // 節(jié)點之間的水平間距
getSide: (d) => {
if (d.data.module === 'father') {
return 'left';
}
return 'right';
},
},
plugins: [minimap]
});
this.graph.data(data);
this.graph.render(); // 渲染
this.graph.fitView(); // 全屏展示
自定義節(jié)點
自定義節(jié)點是在默認(rèn)節(jié)點不能滿足我們的需求時,自己定義一些圖形要素,可以單個的也可以是多個進行組合,如下圖所示:

由于篇幅有限,以下代碼只展示了外邊框和眼睛圖標(biāo)的繪制,其他元素的繪制基本類似。其中 setState 回調(diào)方法是監(jiān)聽狀態(tài)變化的,比如本例中監(jiān)聽樹節(jié)點展開 / 收起,進行眼睛圖標(biāo)的切換以及節(jié)點底色的變更邏輯。
G6.registerNode('card-node', {
draw: function drawShape(cfg, group) {
const w = cfg.size[0];
const h = cfg.size[1];
const shape = group.addShape('rect', {
attrs: {
x: -w / 2,
y: -h / 2,
width: w, //200,
height: h, // 60
radius: 8,
fill: 'l(0) 0:rgba(197,213,255,1) 0.3:rgba(226,233,253,1) 1:rgba(255,255,255,1)',
shadowOffsetX: -2,
shadowOffsetY: 0,
shadowColor: '#82A2F5',
shadowBlur: 0,
stroke: 'l(0) 0.1:rgba(138,169,249,1) 1:rgba(202,216,254,1)'
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: 'main-box',
draggable: true,
});
cfg.children &&
group.addShape('image', {
attrs: {
x: w / 2 - 35,
y: -h / 2 + 10,
cursor: 'pointer',
img: flowEyeOpen,//cfg.collapsed ? flowEyeOpen : flowEyeClose,
width: 16,
height: 16
},
name: 'collapse-icon',
});
return shape;
},
setState(name, value, item) {
if (name === 'collapsed') {
// 替換眼睛圖標(biāo)
const marker = item.get('group').find((ele) => ele.get('name') === 'collapse-icon');
const icon = value ? flowEyeClose : flowEyeOpen;
marker.attr('img', icon);
// 替換卡片背景
const mainBox = item.get('group').find((ele) => ele.get('name') === 'main-box');
const fill = value ? '#fff' : 'l(0) 0:rgba(197,213,255,1) 0.3:rgba(226,233,253,1) 1:rgba(255,255,255,1)'
const shadowOffsetX = value ? 0 : -2
mainBox.attr('fill', fill)
mainBox.attr('shadowOffsetX', shadowOffsetX)
}
},
});
節(jié)點事件監(jiān)聽
當(dāng)點擊節(jié)點中的眼睛圖標(biāo)則執(zhí)行展開 / 收起,并更新其狀態(tài),進行重繪。狀態(tài)更新之后,上文中的 setState 回調(diào)函數(shù)就被出發(fā),隨后根據(jù)新的展開 / 收起狀態(tài)對節(jié)點樣式進行變更
this.graph.on('node:click', (e) => {
// 點擊眼睛圖標(biāo)展開子節(jié)點
if (e.target.get('name') === 'collapse-icon') {
e.item.getModel().collapsed = !e.item.getModel().collapsed;
this.graph.setItemState(e.item, 'collapsed', e.item.getModel().collapsed);
this.graph.layout();
}
});
自定義邊
邊的默認(rèn)樣式使用內(nèi)置邊 cubic-horizontal 類型,當(dāng)鼠標(biāo)移到邊上時會出現(xiàn)一個數(shù)量數(shù)據(jù)如下圖所示: 
當(dāng)狀態(tài)變?yōu)榧せ顮顟B(tài) active 時,將邊上的元素透明度置為 1 可見,否則置為 0 不可見。默認(rèn)為 0
G6.registerEdge(
'custom-edge',
{
afterDraw(cfg, group) {
const source = cfg.sourceNode._cfg.model.id
const target = cfg.targetNode._cfg.model.id
let current = self.flowCountList.find(item=>{
return source === item.fromPoolId && target === item.toPoolId
})
// 如果未找到,在進行反向查一次
if(!current) {
current = self.flowCountList.find(item=>{
return source === item.toPoolId && target === item.fromPoolId
})
}
// 獲取圖形組中的第一個圖形,在這里就是邊的路徑圖形
const shape = group.get('children')[0];
// 獲取路徑圖形的中點坐標(biāo)
const midPoint = shape.getPoint(0.5);
// 在中點增加一個矩形,注意矩形的原點在其左上角
group.addShape('rect', {
attrs: {
width: 92,
height: 20,
fill: '#BABEC7',
stroke: '#868D9F',
lineWidth: 1,
radius: 10,
opacity: 0,
// x 和 y 分別減去 width / 2 與 height / 2,使矩形中心在 midPoint 上
x: midPoint.x - 92/2,
y: midPoint.y - 20/2,
},
name: 'edge-count', // 在 G6 3.3 及之后的版本中,必須指定 name,可以是任意字符串,但需要在同一個自定義元素類型中保持唯一性
});
group.addShape('text', {
attrs: {
textBaseline: 'top',
x: midPoint.x - 92/2 + 20,
y: midPoint.y - 12/2 + 1,
lineHeight: 12,
text: `流出 ${current ? current.clueCount : 0}`,
fontSize: 12,
fill: '#ffffff',
opacity: 0,
},
name: 'edge-text',
});
},
// // 響應(yīng)狀態(tài)變化
setState(name, value, item) {
if (name === 'active') {
const edgeCount = item.get('group').find((ele) => ele.get('name') === 'edge-count');
const edgeText = item.get('group').find((ele) => ele.get('name') === 'edge-text');
edgeCount.attr('opacity', value ? 1 : 0)
edgeText.attr('opacity', value ? 1 : 0)
}
},
},
'cubic-horizontal',
);
邊事件監(jiān)聽
監(jiān)聽邊的 mouseenter 和 mouseleave 事件,更新其激活狀態(tài)
this.graph.on('edge:mouseenter', (ev) => {
const edge = ev.item;
this.graph.setItemState(edge, 'active', true);
});
this.graph.on('edge:mouseleave', (ev) => {
const edge = ev.item;
this.graph.setItemState(edge, 'active', false);
});
到此雙數(shù)渲染邏輯介紹完畢,如果有類似案例,歡迎參考
4. 踩坑記錄
縮進樹 - 頂部對齊,左側(cè)樹不會對齊
配置如下 indented 表示縮進樹,dropCap 置為 false 表示關(guān)閉下垂樹,只有右側(cè)樹時,顯示正常,如果雙樹渲染,左側(cè)樹不會縮進,屬于 g6 本身的 bug
layout: {
type: 'indented',
dropCap: false,
},

初次加載樹結(jié)構(gòu),限制只展開兩級只需要在二級節(jié)點增加屬性 collapsed : true 數(shù)據(jù)結(jié)構(gòu)如下:
{
id: '1',
children: [{
id: '1-1',
children: [{
id: '1-1-1',
collapsed: true
}]
}]
}

一個節(jié)點有兩個局部區(qū)域增加 tooltip
const tooltip = new G6.Tooltip({
className: 'g6-tooltip',
offsetX: -5,
offsetY: -165,
getContent(e) {
return '111'
},
shouldBegin(e){
return true
},
itemTypes: ['node']
});
// 增加tooltip插件來實現(xiàn)一個節(jié)點多個局部區(qū)域增加tooltip
plugins: [tooltip,tooltip1]
關(guān)于編譯報錯的問題
由于我本地 ts 版本太低,導(dǎo)致 ts 在對 g6 進行代碼檢查時報錯,具體報錯信息如下截圖:
原因分析: g6 代碼庫用到了 ts 的高級語法,導(dǎo)致當(dāng)前低版本 ts 在對其進行檢查時未通過 解決方法:
(1). 在 tsconfig.json 中增加 "skipLibCheck": true 跳過 ts 的檢查,但是發(fā)現(xiàn)測試環(huán)境和預(yù)發(fā)環(huán)境編譯通過,上線時使用 npm run build 編譯不成功
(2). 升級 ts 版本,將當(dāng)前使用的 3.5.3 升級至最新版,發(fā)現(xiàn)雖然上述問題解決了,但是系統(tǒng)中其他地方有報錯,為了降低上線的風(fēng)險,放棄了這種方式
(3). 降級 @antv/g6 的版本,使其從最新版本降到了 4.3.4,最后發(fā)現(xiàn)可行,編譯沒有任何問題,而且功能運行正常,體驗較差的是 @antv/g6 關(guān)于每一個版本的升級都沒有提及這個問題,所以需要按版本試驗,整整折騰了一天才找到這個版本,后續(xù)如果有人遇到這個問題可以進行借鑒
5. 成果展示
全局展示

局部展示

審核編輯:劉清
-
CRM
+關(guān)注
關(guān)注
1文章
152瀏覽量
21812
原文標(biāo)題:如何基于G6進行雙樹流轉(zhuǎn)繪制?
文章出處:【微信號:OSC開源社區(qū),微信公眾號:OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
軟銀集團針對 7GHz 厘米波頻譜 6G 頻段進行實測
宇樹科技上新,首款輪式機器人G1-D發(fā)布
E203工程源碼時鐘樹解析
5G與6G:從“萬物互聯(lián)“到“智能無界“的跨越
STM32G473進行雙bank升級的時候,進行FLASH的批量擦除回卡死是怎么回事?
STM32H745XIH6不能進行雙核調(diào)試,CM4不能進行在線調(diào)試怎么解決?
2025款小鵬G6全面搭載遠峰科技流媒體內(nèi)后視鏡
天馬微電子助力2025款小鵬G6轎跑SUV上市
宇樹科技人形機器人G1京東下架
Orcad繪制原理圖的元器件對齊方法

如何基于G6進行雙樹流轉(zhuǎn)繪制?
評論