1. 背景
業(yè)務(wù)背景:CRM 系統(tǒng)隨著各業(yè)務(wù)條線對線索精細(xì)化分配的訴求逐漸增加,各個條線的流向規(guī)則會越來越復(fù)雜,各個條線甚至整個 CRM 的線索流轉(zhuǎn)規(guī)則急需一種樹形的可視化的圖來表達(dá)。
技術(shù)背景:在開發(fā)之前考慮了三種方案,原生 canvas、fabric 以及 G6,三種方案各有優(yōu)劣勢
原生 canvas | fabric | G6 | |
---|---|---|---|
優(yōu)點(diǎn) | 靈活、自由、可定制化非常強(qiáng) | 封裝了 canvas 的 api,使用簡單靈活 | 提供了復(fù)雜樹、圖等 api,只需要按照文檔配置即可 |
缺點(diǎn) | 開發(fā)復(fù)雜、耗時 | 對于構(gòu)建大型樹、圖等復(fù)雜、耗時 | 在開發(fā)前需要認(rèn)真閱讀 api 文檔,上手慢 |
通過上述表格對比就可以看出來,對于構(gòu)建更為復(fù)雜的樹、圖等,G6 具備明顯的優(yōu)勢,而且又有活躍的開源社區(qū),為后續(xù)的維護(hù)升級提供了保證。
2. 基礎(chǔ)知識
下面是關(guān)于本次雙樹流轉(zhuǎn)繪制的幾個核心概念,簡單介紹一下,如有興趣,還是建議閱讀 G6 官方 API 文檔
圖 Graph
通過 new G6.Graph (config) 進(jìn)行圖的實例化。其中參數(shù) config 是 Object 類型的圖的配置項,圖的大部分功能可以通過該配置項進(jìn)行全局配置。
如果是樹圖,需要使用 new G6.TreeGraph 方法進(jìn)行實例化
const graph = new G6.Graph({ container: 'mountNode', // 指定圖畫布的容器 id,與第 9 行的容器對應(yīng) // 畫布寬高 width: 800, height: 500, }); // 讀取數(shù)據(jù) graph.data(data); // 渲染圖 graph.render();
圖元素 - 節(jié)點(diǎn)
G6 的內(nèi)置節(jié)點(diǎn)包括 circle,rect,ellipse,diamond,triangle,star,image,modelRect,donut(v4.2.5 起支持)。這些內(nèi)置節(jié)點(diǎn)的默認(rèn)樣式分別如下圖所示。
內(nèi)置節(jié)點(diǎn)配置
const graph = new G6.Graph({ container: 'mountNode', width: 800, height: 600, defaultNode: { type: 'circle', // 節(jié)點(diǎn)類型 // ... 其他配置 }, });
自定義節(jié)點(diǎn)配置
// 在節(jié)點(diǎn)配置中配置自定義節(jié)點(diǎn)名稱以及尺寸 defaultNode: { type: 'card-node', size: [338, 70], } // 使用G6.registerNode自定義節(jié)點(diǎn),在自定義節(jié)點(diǎn)中可以自定義各種形狀的圖形,包括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:直線,不支持控制點(diǎn); polyline:折線,支持多個控制點(diǎn); arc:圓弧線; quadratic:二階貝塞爾曲線; cubic:三階貝塞爾曲線; cubic-vertical:垂直方向的三階貝塞爾曲線,不考慮用戶從外部傳入的控制點(diǎn); cubic-horizontal:水平方向的三階貝塞爾曲線,不考慮用戶從外部傳入的控制點(diǎn); 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é)點(diǎn)布局對齊到同一層); Indented Layout:縮進(jìn)布局; 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é)點(diǎn)之間間距 rankSep: 100, // 每個層級之間的間距 excludeInvisibles: true, // 布局計算是否排除掉隱藏的節(jié)點(diǎn),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é)點(diǎn) / 邊 上的事件
例如 node:mousedown,edge:click,combo:click 等,以 type:eventName 為事件名稱。
graph.on('node:click', (ev) => { const node = ev.item; // 被點(diǎn)擊的節(jié)點(diǎn)元素 const shape = ev.target; // 被點(diǎn)擊的圖形,可根據(jù)該信息作出不同響應(yīng),以達(dá)到局部響應(yīng)效果 // ... do sth }); graph.on('edge:click', (ev) => { const edge = ev.item; // 被點(diǎn)擊的邊元素 const shape = ev.target; // 被點(diǎn)擊的圖形,可根據(jù)該信息作出不同響應(yīng),以達(dá)到局部響應(yīng)效果 // ... do sth }); graph.on('combo:click', (ev) => { const combo = ev.item; // 被點(diǎn)擊 combo 元素 const shape = ev.target; // 被點(diǎn)擊的圖形,可根據(jù)該信息作出不同響應(yīng),以達(dá)到局部響應(yīng)效果 // ... do sth });
3. 技術(shù)方案 & 實施
數(shù)據(jù)準(zhǔn)備
要求數(shù)據(jù)中每一個節(jié)點(diǎn)必須有 id,且 id 為字符串類型。module 字段 root 表示根節(jié)點(diǎn),right 表示子節(jié)點(diǎn),left 表示父節(jié)點(diǎn)。flowCountList 表示邊,從某個節(jié)點(diǎn)到某個節(jié)點(diǎn)
data: { id: '1', name: '根節(jié)點(diǎn)', flowInCount: 9999, flowOutCount: 9999, currentCount: 9999, module: 'root', children: [ { id: '2', name: '右一節(jié)點(diǎn)', flowInCount: 9999, flowOutCount: 9999, currentCount: 9999, module: 'son', }, { id: '3', name: '左一節(jié)點(diǎn)', 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é)點(diǎn)增加 tooltip 提示。 layout 中 getSide 方法會根據(jù)數(shù)據(jù)類型判斷當(dāng)前節(jié)點(diǎn)屬于父節(jié)點(diǎn)還是子節(jié)點(diǎn),本方法只針對根節(jié)點(diǎn)有效。
this.graph = new G6.TreeGraph({ container: 'clueCanvas', width:1000, // width和height可以根據(jù)自己畫布大小進(jìn)行賦值 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é)點(diǎn)高度 getWidth: () => {return 338;}, // 節(jié)點(diǎn)寬度 getVGap: () => {return 8;}, // 節(jié)點(diǎn)之間的垂直間距 getHGap: () => {return 100;}, // 節(jié)點(diǎn)之間的水平間距 getSide: (d) => { if (d.data.module === 'father') { return 'left'; } return 'right'; }, }, plugins: [minimap] }); this.graph.data(data); this.graph.render(); // 渲染 this.graph.fitView(); // 全屏展示
自定義節(jié)點(diǎn)
自定義節(jié)點(diǎn)是在默認(rèn)節(jié)點(diǎn)不能滿足我們的需求時,自己定義一些圖形要素,可以單個的也可以是多個進(jìn)行組合,如下圖所示:
由于篇幅有限,以下代碼只展示了外邊框和眼睛圖標(biāo)的繪制,其他元素的繪制基本類似。其中 setState 回調(diào)方法是監(jiān)聽狀態(tài)變化的,比如本例中監(jiān)聽樹節(jié)點(diǎn)展開 / 收起,進(jìn)行眼睛圖標(biāo)的切換以及節(jié)點(diǎn)底色的變更邏輯。
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é)點(diǎn)事件監(jiān)聽
當(dāng)點(diǎn)擊節(jié)點(diǎn)中的眼睛圖標(biāo)則執(zhí)行展開 / 收起,并更新其狀態(tài),進(jìn)行重繪。狀態(tài)更新之后,上文中的 setState 回調(diào)函數(shù)就被出發(fā),隨后根據(jù)新的展開 / 收起狀態(tài)對節(jié)點(diǎn)樣式進(jìn)行變更
this.graph.on('node:click', (e) => { // 點(diǎn)擊眼睛圖標(biāo)展開子節(jié)點(diǎn) 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 }) // 如果未找到,在進(jìn)行反向查一次 if(!current) { current = self.flowCountList.find(item=>{ return source === item.toPoolId && target === item.fromPoolId }) } // 獲取圖形組中的第一個圖形,在這里就是邊的路徑圖形 const shape = group.get('children')[0]; // 獲取路徑圖形的中點(diǎn)坐標(biāo) const midPoint = shape.getPoint(0.5); // 在中點(diǎn)增加一個矩形,注意矩形的原點(diǎn)在其左上角 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. 踩坑記錄
縮進(jìn)樹 - 頂部對齊,左側(cè)樹不會對齊
配置如下 indented 表示縮進(jìn)樹,dropCap 置為 false 表示關(guān)閉下垂樹,只有右側(cè)樹時,顯示正常,如果雙樹渲染,左側(cè)樹不會縮進(jìn),屬于 g6 本身的 bug
layout: { type: 'indented', dropCap: false, },
初次加載樹結(jié)構(gòu),限制只展開兩級只需要在二級節(jié)點(diǎn)增加屬性 collapsed : true 數(shù)據(jù)結(jié)構(gòu)如下:
{ id: '1', children: [{ id: '1-1', children: [{ id: '1-1-1', collapsed: true }] }] }
一個節(jié)點(diǎn)有兩個局部區(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é)點(diǎn)多個局部區(qū)域增加tooltip plugins: [tooltip,tooltip1]
關(guān)于編譯報錯的問題
由于我本地 ts 版本太低,導(dǎo)致 ts 在對 g6 進(jìn)行代碼檢查時報錯,具體報錯信息如下截圖:
原因分析: g6 代碼庫用到了 ts 的高級語法,導(dǎo)致當(dāng)前低版本 ts 在對其進(jìn)行檢查時未通過 解決方法:
(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)可行,編譯沒有任何問題,而且功能運(yùn)行正常,體驗較差的是 @antv/g6 關(guān)于每一個版本的升級都沒有提及這個問題,所以需要按版本試驗,整整折騰了一天才找到這個版本,后續(xù)如果有人遇到這個問題可以進(jìn)行借鑒
5. 成果展示
全局展示
局部展示
審核編輯:劉清
-
CRM
+關(guān)注
關(guān)注
1文章
145瀏覽量
21142
原文標(biāo)題:如何基于G6進(jìn)行雙樹流轉(zhuǎn)繪制?
文章出處:【微信號:OSC開源社區(qū),微信公眾號:OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論