基於echarts的知識圖譜可視化實踐(組件封裝)

基於echarts的知識圖譜可視化實踐(組件封裝)

展示效果圖1

基於echarts的知識圖譜可視化實踐(組件封裝)

展示效果圖2

基於echarts的知識圖譜可視化實踐(組件封裝)

展示效果3

1.需求背景

  • 公司是做知識圖譜領域的,需要展示可視化的知識圖譜,在我入職之前後端的小哥哥們寫了一套 d3 版本的知識圖譜,代碼雜亂無章,各種嵌套,不加工具函數都有 1000 多行代碼,無論從代碼性能還是可維護性上說都很不友好。於是,決定重新尋找方案實現這塊內容。
  • 代碼寫的雜亂無章不是 d3 的鍋,d3 在可視化領域絕對是有一席之地的,但是對於沒有接觸過 d3 的同學,如果要在此基礎上再加上公司的業務邏輯,那絕對是一件令人 egg 疼的事情了,還好,現在有很多開源組件庫,可以為開發者提供絕美的生產力工具。
  • 選擇 echarts,文檔清晰,通俗易懂。

2.需求分析

原有的知識圖譜已經實現的功能,我們不能拋棄,想要替換之前的方案,最起碼原有的功能得全部保留。看下功能點:

  • 圖譜在初始化時都會有一段時間的位置調整動畫,這是力引導佈局的共性,差別在於如何更快的讓節點趨於穩定。(節點很少的時候,基本看不出來差別)
  • 節點單擊展示節點屬性表格,表格內容動態切換
  • 節點第一次雙擊拓展,第二次雙擊摺疊,類似於按鈕 toggle 過程。
  • 節點右鍵可出現操作菜單,如刪除節點,添加關注點等。
  • 放大縮小清空圖譜

以上為本次需求的基本功能點。

3.著手實現組件的設計

前端組件設計原則我們應該儘量遵守,如低耦合,單一原則等等。但是對於公司內部專業開發使用的組件,一般我們都沒有做到這麼嚴格。有一個原則是好用,易於維護,大家都看得懂。我在開發這個組件的時候考慮到以下幾點:

  • 我司後端經常參與前端項目的開發中,他們是否可以看得懂,用的好。
  • 後面入職的前端如果要接手這塊的內容是不是能很快理解。(以前版本的就很難理解,甚至無法直視)
  • 組件應該儘量避免有更多的業務邏輯,假設某次需求要把這個組件拿到另外一個場景使用,是否做到不影響。(老版本不僅參雜複雜業務邏輯,還有 ajax 請求)

4.coding time

最開始寫了一個 react hooks 版本,奈何這塊不是很熟悉,開發過程中出現很多 bug,最後放棄,還是選擇使用類組件。 組件的 state:

<code>this.state = {
graphData: {
nodes: [],
links: []
}, // 數據源
echartInstance: null, // 圖譜實體
shrink: [], // 收縮節點
clickNodes: [], // 點擊的節點
option: { // 圖譜的配置
tooltip: {
show: true,
formatter: "<div style='display:block;word-break: break-all;word-wrap: break-word;white-space:pre-wrap;max-width: 80px'>" + "{b} " + "</div>"
},
animationDurationUpdate: 2000,
animationEasingUpdate: 'quinticInOut',
series: [{
type: 'graph',
layout: 'force',
symbolSize: 35,
draggable: true,
roam: true,
focusNodeAdjacency: true,
edgeSymbol: ['', 'arrow'],
cursor: 'pointer',
emphasis: {
itemStyle: {
borderWidth: 10,
borderType: 'solid',
shadowBlur: 20,
shadowColor: '#eee',
},
label: {
show: true,
formatter: (record) => {
if (record.name.length > 10) {
return record.name.substr(0, 5) + '...'
} else {
return record.name
}
}
},
edgeLabel: {
width: 100
}
},
edgeLabel: {
normal: {

show: true,
textStyle: {
fontSize: 14,
color: '#fff'
},
formatter(x) {
return x.data.name;
}
}
},
label: {
show: true,
position: 'bottom',
color: '#fff',
formatter: (record) => {
if (record.name.length > 10) {
return record.name.substr(0, 5) + '...'
} else {
return record.name
}
}
},
force: {
initLayout: 'circular',
repulsion: 80,
gravity: 0.01,
edgeLength: 180,
layoutAnimation: true,
friction: 0.2
},
data: [],
links: []
}]
},
visible: false, // 右鍵菜單是否可視
wrapStyle: { // 右鍵菜單樣式
position: 'absolute',
width: '100px',
padding: '5px 0',
backgroundColor: '#fff',
},
addSource: '', // 右鍵選中的實體
}
/<code>

echarts option 配置佔用了 state 大部分位置,這塊內容也可以抽出來一個單獨的文件。其他 state 含義可以參照註釋瞭解。 組件的 render 方法:

<code>render() {
const { option, wrapStyle, visible } = this.state;
const { menuData } = this.props;
return (
<div className="relation_graph" style={{ height: '100%' }}>
<ReactEchart onEvents={this.onclick} ref={this.GraphRef} style={{ height: '100%', width: '100%' }} option={option} />
<div className="scare_action" style={{ top: this.props.tool.top, left: this.props.tool.left }}>
<div>
<Icon onClick={this.add} type="plus-circle" style={{ width: '64px', height: '64px', fontSize: '24px', color: 'rgba(255,255,255,0.65)', lineHeight: '64px', cursor: 'pointer' }}></Icon>
</div>
<div>
<Icon onClick={this.decrese} type="minus-circle" style={{ width: '64px', height: '64px', fontSize: '24px', color: 'rgba(255,255,255,0.65)', lineHeight: '64px', cursor: 'pointer' }}></Icon>
</div>
<div>
<Icon onClick={this.clearGraph} type="redo" style={{ width: '64px', height: '64px', fontSize: '24px', color: 'rgba(255,255,255,0.65)', lineHeight: '64px', cursor: 'pointer' }}></Icon>
</div>
</div>
{
visible ? <div onClick={this.clickMenu} className="contextMenu" style={wrapStyle}>
{
menuData && menuData.length > 0 ? menuData.map(item => <p key={item.id}>{item.name}</p>) : null
}
</div> : null
}
</div>
)
}
/<code>

這裡繪製圖譜部分使用了基於 echarts 封裝的 react 組件庫 echarts-for-react,還有部分圖標依賴的 antd 組件庫,事實上真正要做到低耦合最好別這樣做,可以選擇原生 echarts 和 svg 圖標。因為我們的系統都是基於 antd 開發的,所以這裡就直接用 antd 提供的組件。 在 componnetDidMount 生命週期內,初始化圖譜實例,刪除瀏覽器原生的 contextmenu,調用 setOption 函數初始化圖譜數據:

<code>componentDidMount() {
let echartInstance = this.GraphRef.current.getEchartsInstance(), that = this;
// 取消正常瀏覽器默認右鍵菜單
this.GraphRef.current.echartsElement.oncontextmenu = () => {
return false

}
// 點擊空白處 刪除右鍵菜單
this.GraphRef.current.echartsElement.onclick = function () {
that.setState({
visible: false
})
}
this.setState({
echartInstance
})
this.setStyle(this.props.graphData)
echartInstance.setOption({
series: {
data: this.props.graphData.nodes,
links: this.props.graphData.links
}
})
}
/<code>

當父組件 graphData 改變,子組件如何監聽並且重新渲染,在子組件生命週期內判斷:

<code> componentWillReceiveProps(nextProps) {
if (JSON.stringify(nextProps.graphData) !== JSON.stringify(this.props.graphData)) {
this.setStyle(nextProps.graphData)
this.state.echartInstance.setOption({
series: {
data: nextProps.graphData.nodes,
links: nextProps.graphData.links
}
})
}
}
/<code>

在 ReactEcharts 組件上綁定事件:

<code> onclick = {
'click': this.clickEchartsPie.bind(this),
'dblclick': this.dblclickPie.bind(this),
'contextmenu': this.rightMouse.bind(this),
}
/<code>

圖譜單擊事件:

<code>    clickEchartsPie(e) {
if (e.dataType !== 'node') {
return
}
this.props.clickCallback(e)
}
/<code>

屬性展示往往會自定義樣式,所以這裡不應該在組件內部封裝表格,而是將節點信息返回給父組件,在父組件定義展示屬性,這樣可以方便自定義表格。 圖譜雙擊事件:

<code>    dblclickPie(e) {
let { echartInstance, clickNodes, shrink } = this.state;
if (e.dataType !== 'node') {
return
}
if (clickNodes.includes(e.data.id)) {
if (shrink.includes(e.data.id)) {
let index = shrink.findIndex(item => item == e.data.id);
shrink.splice(index, 1)
} else {
shrink.push(e.data.id)
}
this.setState({
shrink
})
let nodes = this.props.graphData.nodes;
let links = this.props.graphData.links;

if (shrink.length > 0) {
for (let i in shrink) {
nodes = nodes.filter(function (d) {
return d.labels.indexOf(shrink[i]) == -1;
});
links = links.filter(function (d) {
return d.labels.indexOf(shrink[i]) == -1;
});
}
}
echartInstance.setOption({
series: {
data: nodes,
links

}
})
} else {
clickNodes.push(e.data.id)
this.setState({
clickNodes
})
this.props.dblCallback({
entityId: e.data.id,
entity: e.data
})
}
}
/<code>

定義點擊的節點和要收縮的節點數組,在下一次點擊的時候判斷要收縮的節點,通過每個 node 和 link 中 labels 屬性進行過濾,找出之前新增的 node 和 link,至於節點的 labels 屬性會在父組件調用的時候添加。 鼠標右鍵:

<code>rightMouse(e) {
let { wrapStyle } = this.state;
if (e.dataType !== 'node') {
return;
}
let event = e.event.event;
const pageX = event.pageX - 20;
const pageY = event.pageY;

this.setState({
wrapStyle: Object.assign({}, wrapStyle, { left: pageX + 'px', top: pageY + 'px' }),
visible: true,
addSource: e.data
})
}
/<code>

放大,縮小,清空:

<code>    add = () => {
let { echartInstance } = this.state;
let zoom = echartInstance.getOption().series[0].zoom;
const addNum = 0.2;
zoom += addNum

echartInstance.setOption({
series: {
zoom
}
})

}
decrese = () => {
let { echartInstance } = this.state;
let zoom = echartInstance.getOption().series[0].zoom;
const addNum = 0.2;
zoom -= addNum
echartInstance.setOption({
series: {
zoom
}
})
}
clearGraph = () => {
const { echartInstance } = this.state;
echartInstance.setOption({
series: {
data: [],
links: []
}
})
}

/<code>

右鍵菜單點擊返回給父組件子組件的操作:

<code>clickMenu = (e) => {
const { addSource } = this.state;
let param = {
target: e.target.innerHTML,
entity: addSource
}
this.props.clickMenuCallback(param)
}
/<code>

6.父組件調用

<code>  export default class Dashboard extends React.Component {
constructor(props) {
super(props)
this.state = {

originNodeId: 'xxxxxx',//原節點id
graphData: {
nodes: [],
links: []
},
clickNodes: [],
tool: {
left: '90%',
top: '600px'
},
menuData: [
{
name: '添加關注點',
id:1
},
{
name: '刪除節點',
id: 2
}
]
}
}

componentDidMount() {
queryRelationGraph({ id: this.state.originNodeId }).then(res => {
let nodes = res.data.data.nodes;
if (nodes && nodes.length > 0) {
nodes = nodes.map(item => {
if (item.id !== this.state.originNodeId) {
return { ...item, category: 1, labels: this.state.originNodeId }
} else {
return { ...item, category: 1, labels: 'origin' }
}
})
}
let links = res.data.data.links;

if (links && links.length > 0) {
links = links.map(item => {
return { ...item, name: item.ooName, labels: this.state.originNodeId }
})
}
res.data.data.links = links;
res.data.data.nodes = nodes;


this.setState({
graphData: res.data.data,
clickNodes: [this.state.originNodeId]

})
})
}
dblClick = (param) => {
let { graphData } = this.state;
queryRelationGraph({ entity_id: param.entityId }).then(res => {
let selectNode = param.entity;
let nodes = res.data.data.nodes;
if (nodes && nodes.length > 0) {
nodes = nodes.map(item => {
return { ...item, category: 1, labels: selectNode.labels + ',' + selectNode.id }
})
}
let links = res.data.data.links;
if (links && links.length > 0) {
links = links.map(item => {
return { ...item, name: item.ooName, labels: selectNode.labels + ',' + selectNode.id }
})
}
res.data.data.links = links;
res.data.data.nodes = nodes;


let oldNodes = graphData.nodes;
let oldLinks = graphData.links;

let newNodes = nodes;
let newLinks = links;



oldNodes = oldNodes.concat(newNodes);
let nodeObj = {}
oldNodes = oldNodes.reduce((pre, next) => {
nodeObj[next.id] ? "" : nodeObj[next.id] = true && pre.push(next)
return pre;
}, [])

oldLinks = oldLinks.concat(newLinks)
let linksObj = {};
oldLinks = oldLinks.reduce((pre, next) => {
linksObj[next.id] ? "" : linksObj[next.id] = true && pre.push(next)
return pre;
}, [])
this.linkMark(oldLinks)
this.setState({
graphData: Object.assign({}, graphData, { nodes: oldNodes, links: oldLinks }),
}, () => {
})
})

}
// 對links重複的關係進行打標
linkMark = (links) => {
let linkGroup = {};
//對連接線進行統計和分組,不區分連接線的方向,只要屬於同兩個實體,即認為是同一組
let linkmap = {};
for (let i = 0; i < links.length; i++) {
if (typeof links[i].source == "string" || typeof links[i].target
== "string") {
var key = links[i].source < links[i].target ? links[i].source + ':'
+ links[i].target : links[i].target + ':' + links[i].source;
} else {
var key = links[i].source.id < links[i].target.id ? links[i].source.id
+ ':' + links[i].target.id : links[i].target.id + ':'
+ links[i].source.id;
}
if (!linkmap.hasOwnProperty(key)) {
linkmap[key] = 0;
}
linkmap[key] += 1;
if (!linkGroup.hasOwnProperty(key)) {
linkGroup[key] = [];
}
linkGroup[key].push(links[i]);
}
for (let i = 0; i < links.length; i++) {
if (typeof links[i].source == "string" || typeof links[i].target == "string") {
var key = links[i].source < links[i].target ?
links[i].source + ':' + links[i].target
:
links[i].target + ':' + links[i].source;
} else {
var key = links[i].source.id < links[i].target.id ? links[i].source.id
+ ':' + links[i].target.id : links[i].target.id + ':'
+ links[i].source.id;
}
links[i].size = linkmap[key];
// 同一組的關係進行編號
let group = linkGroup[key];
// 給節點分配編號
setLinkNumber(group);
}
function setLinkNumber(group) {
if (group.length == 0) {

return;
}
if (group.length == 1) {
group[0].linknum = 0;
return;
}
group.forEach((item, index) => {
item.linknum = index
})
}
}
clickGraph = (param) => {
// console.log(param, 'param')
}
clickMenu = (param) => {
console.log(param)
}
render() {
const { graphData, tool, menuData } = this.state;
return (
<div className="Dashboard">
{
Object.values(graphData)[0].length > 0 || Object.values(graphData)[1].length > 0 ? <MyRelationGraph
graphData={graphData}
dblCallback={this.dblClick}
clickCallback={this.clickGraph}
tool={tool}
clickMenuCallback={this.clickMenu}
menuData={menuData}
/> : null
}

</div>
)
}
}
/<code>

7.結語

-大家有不懂的可以留言,代碼已經全部在這裡。勤于思考,善於變通,條條大路通北京,祝大家都能升職加薪。

本文使用 mdnice 排版


分享到:


相關文章: