## 1. 背景 - 業務背景:CRM系統隨著各業務條線對線索精細化分配的訴求逐漸增加,各個條線的流向規則會越來越複雜,各個條線甚至整個CRM的線索流轉規則急需一種樹形的可視化的圖來表達。 - 技術背景:在開發之前考慮了三種方案,原生canvas、fabric以及G6,三種方案各有優劣勢 | | ...
1. 背景
-
業務背景:CRM系統隨著各業務條線對線索精細化分配的訴求逐漸增加,各個條線的流向規則會越來越複雜,各個條線甚至整個CRM的線索流轉規則急需一種樹形的可視化的圖來表達。
-
技術背景:在開發之前考慮了三種方案,原生canvas、fabric以及G6,三種方案各有優劣勢
原生canvas | fabric | G6 | |
---|---|---|---|
優點 | 靈活、自由、可定製化非常強 | 封裝了canvas的api,使用簡單靈活 | 提供了複雜樹、圖等api,只需要按照文檔配置即可 |
缺點 | 開發複雜、耗時 | 對於構建大型樹、圖等複雜、耗時 | 在開發前需要認真閱讀api文檔,上手慢 |
通過上述表格對比就可以看出來,對於構建更為複雜的樹、圖等,G6具備明顯的優勢,而且又有活躍的開源社區,為後續的維護升級提供了保證。
2. 基礎知識
下麵是關於本次雙樹流轉繪製的幾個核心概念,簡單介紹一下,如有興趣,還是建議閱讀G6官方API文檔
- 圖Graph
通過 new G6.Graph(config) 進行圖的實例化。其中參數 config是 Object 類型的圖的配置項,圖的大部分功能可以通過該配置項進行全局配置。
如果是樹圖,需要使用new G6.TreeGraph方法進行實例化
const graph = new G6.Graph({
container: 'mountNode', // 指定圖畫布的容器 id,與第 9 行的容器對應
// 畫布寬高
width: 800,
height: 500,
});
// 讀取數據
graph.data(data);
// 渲染圖
graph.render();
- 圖元素-節點
G6 的內置節點包括 circle,rect,ellipse,diamond,triangle,star,image,modelRect,donut(v4.2.5 起支持)。這些內置節點的預設樣式分別如下圖所示。
內置節點配置
const graph = new G6.Graph({
container: 'mountNode',
width: 800,
height: 600,
defaultNode: {
type: 'circle', // 節點類型
// ... 其他配置
},
});
自定義節點配置
// 在節點配置中配置自定義節點名稱以及尺寸
defaultNode: {
type: 'card-node',
size: [338, 70],
}
// 使用G6.registerNode自定義節點,在自定義節點中可以自定義各種形狀的圖形,包括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 種內置邊:
line:直線,不支持控制點;
polyline:折線,支持多個控制點;
arc:圓弧線;
quadratic:二階貝塞爾曲線;
cubic:三階貝塞爾曲線;
cubic-vertical:垂直方向的三階貝塞爾曲線,不考慮用戶從外部傳入的控制點;
cubic-horizontal:水平方向的三階貝塞爾曲線,不考慮用戶從外部傳入的控制點;
loop:自環。
內置邊配置
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:樹狀佈局(葉子節點佈局對齊到同一層);
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, // 節點之間間距
rankSep: 100, // 每個層級之間的間距
excludeInvisibles: true, // 佈局計算是否排除掉隱藏的節點,v4.8.8 起支持
},
});
- 交互與事件
全局事件
只要在畫布上範圍內發生均會被觸發,如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 空白處被觸發,如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();
}
});
節點/邊 上的事件
例如node:mousedown,edge:click,combo:click等,以type:eventName為事件名稱。
graph.on('node:click', (ev) => {
const node = ev.item; // 被點擊的節點元素
const shape = ev.target; // 被點擊的圖形,可根據該信息作出不同響應,以達到局部響應效果
// ... do sth
});
graph.on('edge:click', (ev) => {
const edge = ev.item; // 被點擊的邊元素
const shape = ev.target; // 被點擊的圖形,可根據該信息作出不同響應,以達到局部響應效果
// ... do sth
});
graph.on('combo:click', (ev) => {
const combo = ev.item; // 被點擊 combo 元素
const shape = ev.target; // 被點擊的圖形,可根據該信息作出不同響應,以達到局部響應效果
// ... do sth
});
3. 技術方案&實施
- 數據準備
要求數據中每一個節點必須有id,且id為字元串類型。module欄位root表示根節點,right表示子節點,left表示父節點。flowCountList表示邊,從某個節點到某個節點
data: {
id: '1',
name: '根節點',
flowInCount: 9999,
flowOutCount: 9999,
currentCount: 9999,
module: 'root',
children: [
{
id: '2',
name: '右一節點',
flowInCount: 9999,
flowOutCount: 9999,
currentCount: 9999,
module: 'son',
},
{
id: '3',
name: '左一節點',
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表示給節點增加tooltip提示。
layout中getSide方法會根據數據類型判斷當前節點屬於父節點還是子節點,本方法只針對根節點有效。
this.graph = new G6.TreeGraph({
container: 'clueCanvas',
width:1000, // width和height可以根據自己畫布大小進行賦值
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;}, //節點高度
getWidth: () => {return 338;}, // 節點寬度
getVGap: () => {return 8;}, // 節點之間的垂直間距
getHGap: () => {return 100;}, // 節點之間的水平間距
getSide: (d) => {
if (d.data.module === 'father') {
return 'left';
}
return 'right';
},
},
plugins: [minimap]
});
this.graph.data(data);
this.graph.render(); // 渲染
this.graph.fitView(); // 全屏展示
- 自定義節點
自定義節點是在預設節點不能滿足我們的需求時,自己定義一些圖形要素,可以單個的也可以是多個進行組合,如下圖所示:
由於篇幅有限,以下代碼只展示了外邊框和眼睛圖標的繪製,其他元素的繪製基本類似。其中setState回調方法是監聽狀態變化的,比如本例中監聽樹節點展開/收起,進行眼睛圖標的切換以及節點底色的變更邏輯。
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') {
// 替換眼睛圖標
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)
}
},
});
- 節點事件監聽
當點擊節點中的眼睛圖標則執行展開/收起,並更新其狀態,進行重繪。狀態更新之後,上文中的setState回調函數就被出發,隨後根據新的展開/收起狀態對節點樣式進行變更
this.graph.on('node:click', (e) => {
// 點擊眼睛圖標展開子節點
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();
}
});
- 自定義邊
邊的預設樣式使用內置邊cubic-horizontal類型,當滑鼠移到邊上時會出現一個數量數據如下圖所示:
當狀態變為激活狀態active時,將邊上的元素透明度置為1可見,否則置為0不可見。預設為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];
// 獲取路徑圖形的中點坐標
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',
});
},
// // 響應狀態變化
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',
);
- 邊事件監聽
監聽邊的mouseenter和mouseleave事件,更新其激活狀態
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);
});
到此雙數渲染邏輯介紹完畢,如果有類似案例,歡迎參考
4. 踩坑記錄
- 縮進樹-頂部對齊,左側樹不會對齊
配置如下indented表示縮進樹,dropCap置為false表示關閉下垂樹,只有右側樹時,顯示正常,如果雙樹渲染,左側樹不會縮進,屬於g6本身的bug
layout: {
type: 'indented',
dropCap: false,
},
- 初次載入樹結構,限制只展開兩級只需要在二級節點增加屬性collapsed : true 數據結構如下:
{
id: '1',
children: [{
id: '1-1',
children: [{
id: '1-1-1',
collapsed: true
}]
}]
}
- 一個節點有兩個局部區域增加tooltip
const tooltip = new G6.Tooltip({
className: 'g6-tooltip',
offsetX: -5,
offsetY: -165,
getContent(e) {
return '111'
},
shouldBegin(e){
return true
},
itemTypes: ['node']
});
// 增加tooltip插件來實現一個節點多個局部區域增加tooltip
plugins: [tooltip,tooltip1]
- 關於編譯報錯的問題
由於我本地ts版本太低,導致ts在對g6進行代碼檢查時報錯,具體報錯信息如下截圖:
原因分析:
g6代碼庫用到了ts的高級語法,導致當前低版本ts在對其進行檢查時未通過
解決方法:
(1).在tsconfig.json中增加"skipLibCheck": true跳過ts的檢查,但是發現測試環境和預發環境編譯通過,上線時使用npm run build編譯不成功
(2).升級ts版本,將當前使用的3.5.3升級至最新版,發現雖然上述問題解決了,但是系統中其他地方有報錯,為了降低上線的風險,放棄了這種方式
(3).降級@antv/g6的版本,使其從最新版本降到了4.3.4,最後發現可行,編譯沒有任何問題,而且功能運行正常,體驗較差的是@antv/g6關於每一個版本的升級都沒有提及這個問題,所以需要按版本試驗,整整折騰了一天才找到這個版本,後續如果有人遇到這個問題可以進行借鑒
5. 成果展示
- 全局展示
- 局部展示
作者:京東物流 田雷雷
來源:京東雲開發者社區