1. Demand background

  • The company is engaged in the field of knowledge graph, and it needs to display visual knowledge graph. Before I joined the company, my brothers at the back end wrote a D3 version of knowledge graph, with chaotic codes, various nested codes and more than 1000 lines of code without processing functions, which were unfriendly both in terms of code performance and maintainability. Therefore, I decided to find a new solution to achieve this content.
  • D3 has its place in visualization, but for those of you who haven’t been exposed to D3, adding the company’s business logic on top of it would be a pain in the egg’s eye. Can provide developers with beautiful productivity tools.
  • Choose Echarts. Documentation is clear and easy to understand.

2. Requirements analysis

We cannot abandon the functions already realized by the original knowledge graph. If we want to replace the previous scheme, at least all the original functions should be retained. Take a look at the function points:

  • The map will have a period of position adjustment animation during initialization. This is common to force-guided layouts. The difference is how to stabilize nodes more quickly. (When there are few nodes, you can hardly see the difference)
  • Node Click to display the node property table. The table content is dynamically changed
  • Double – click the first time to expand the node, double – click the second time to collapse the node, similar to the button toggle process.
  • Right-click the node to display the operation menu, such as deleting the node, adding concerns, etc.
  • Zoom in and out to clear the map

The above are the basic function points of this requirement.

3. Start implementing the design of the components

Front-end component design principles should be followed as much as possible, such as low coupling, single principle, etc. However, we are generally not so strict about components that are professionally developed and used within the company. There is a principle is easy to use, easy to maintain, everyone can understand. I developed this component with the following in mind:

  • The back end of our company often participates in the development of the front end project, whether they can understand it and use it well.
  • Does the back end of the incoming front end understand quickly if they are going to take over the content? (Previous versions were hard to understand or even look at.)
  • Components should try to avoid having more business logic, assuming they need to be used in another scenario at some point. (Older versions involved complex business logic as well as Ajax requests.)

4.coding time

– Initially wrote a react hooks version, but I didn’t know much about it. There were a lot of bugs in the development process, so I decided to use class components instead. Component state:

this.state = {
            graphData: {
                nodes: [],
                links: []
}, // Data sourceEchartInstance: null, // Map entityShrink: [], // Shrink the nodeClickNodes: [], // Click the nodeOption: {// Map configuration tooltip: {  show: true. formatter: "
                 
"
+ "{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// Right click menu is visibleWrapStyle: {// right-click menu style position: 'absolute'. width: '100px'. padding: '5px 0'. backgroundColor: '#fff'. }, addSource: ' '// Right-click the selected entity }
Copy the code

The Echarts Option configuration takes up most of state, which can also be pulled out in a separate file. Other meanings of state can be understood by referring to the notes. The render method of the component:

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 (255255255,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 (255255255,0.65)', lineHeight: '64px', cursor: 'pointer' }}></Icon>  </div>  <div>  <Icon onClick={this.clearGraph} type="redo" style={{ width: '64px', height: '64px', fontSize: '24px', color: 'rgba (255255255,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>  )  } Copy the code

Here, the react component library echarts-for-React based on echarts package is used for drawing diagrams, as well as the ANTD component library based on ICONS. In fact, to achieve low coupling, it is best not to do this, you can choose native Echarts and SVG ICONS. Since our systems are developed based on ANTD, we will use components provided by ANTD directly. During the life cycle of componnetDidMount, initialize the atlas instance, delete the browser’s native ContextMenu, and call setOption function to initialize the atlas data:

componentDidMount() {
        let echartInstance = this.GraphRef.current.getEchartsInstance(), that = this;
// Cancel the normal browser default right-click menu        this.GraphRef.current.echartsElement.oncontextmenu = () => {
            return false
 } // Click the blank to delete the right-click menu 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  }  })  } Copy the code

When the parent component graphData changes, how does the child component listen and rerender to determine during the child component’s life cycle:

 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  }  })  }  } Copy the code

Bind events on the ReactEcharts component:

 onclick = {
        'click': this.clickEchartsPie.bind(this),
        'dblclick': this.dblclickPie.bind(this),
        'contextmenu': this.rightMouse.bind(this),
 }
Copy the code

Atlas Click event:

    clickEchartsPie(e) {
        if(e.dataType ! = ='node') {
            return
        }
        this.props.clickCallback(e)
 } Copy the code

Property presentations tend to be custom styled, so instead of encapsulating the table inside the component, you should return node information to the parent and define the presentation properties in the parent to make it easier to customize the table. Atlas double-click event:

    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  })  }  } Copy the code

Define the array of nodes clicked and nodes to shrink, judge the nodes to shrink when clicked next time, filter the labels attribute in each node and link, find the newly added nodes and links, and the labels attribute of nodes will be added when the parent component calls. Right mouse button:

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  })  } Copy the code

Zoom in, out, or empty:

    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: []  }  })  }  Copy the code

Right-click on the menu and click on the action to return to parent and child:

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

6. Parent component call

  export default class Dashboard extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            originNodeId: 'xxxxxx',// Original node ID graphData: {  nodes: [],  links: []  },  clickNodes: [],  tool: {  left: '90%'. top: '600px'  },  menuData: [  {  name: 'Add concern'. id:1  },  {  name: 'Delete node'. 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 }), = > {}, () })  })  } // Mark repeated links linkMark = (links) => {  let linkGroup = {}; // The connection is counted and grouped, regardless of the direction of the connection, as long as it belongs to the same two entities, it is considered to be the same group 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]; // The relationship of the same group is numbered let group = linkGroup[key]; // Assign a node number 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>  )  } } Copy the code

7. Conclusion

– You can leave a message if you don’t understand, the code is all here. Being thoughtful and flexible, all roads lead to Beijing. I wish you all a promotion and a raise.


This article is formatted using MDNICE