基于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 排版


分享到:


相關文章: