Gract —— 从零开始实现一个基于react的关系图库

前言

如今开源界存在的大部分关系图相关的库基本上一类是基于Canvas的渲染,一部分是基于SVG的渲染。本次将从零开始开发一个基于React而且结合CSS来实现的关系图显示系统,经过实践来综合运用React,CSS,等相关的知识。 本文会着重从功能点的设计以及实现来进行展现,从零到一的打造一个简单可用的可视化库。javascript

功能分析

技术依赖: react 16.8及以上,构建工具使用Parcel进行一键构建 功能点分析:css

  • 画布:一个包含全部实体的空间,默承认以无限延展,而且支持拖动,缩放等操做
  • 节点:关系图中的节点,节点能够经过传入React组件来自定义样式和交互
  • 边:链接各个节点的边,而且支持传入自定义path来自定义形状,因为实现细节问题必须使用svg来制做
  • 布局:能够经过自定义的布局算法来进行整个图的自动布局

实现

画布

画布首先须要的是承载全部的节点和边,他是一个带有能够传入children的组件,画布应该只关心该层的显示逻辑(好比拖拽画布),而不该该影响到内部元素的渲染。 因此开始分析画布须要哪些props和state,现阶段先以提供画布的拖拽功能为例,使用css来实现。html

  • state:position(相对位置),mouse(是否在拖动)
  • props:children(用于承接画布的内容)

首先,为了方便,构造一个hook来做为位置管理:前端

function usePosition(initX = 0, initY = 0) {
  const [x, setX] = useState(initX);
  const [y, setY] = useState(initY);
  const setPosition = (x, y) => { setX(x); setY(y) };

  return { x, y, setPosition };
}
复制代码

以及构造一个容许多状态存在的itemState:java

function useItemState(initialState = []) {
  // 这里用Set也是一样可行的
  const [is, setis] = useState(initialState);
  const set = (key, bool) => setis(bool ? [...is, key] : [...is.filter(e => e !== key)]);
  const get = (key) => is.includes(key);
  return {
    set, get
  }
}
复制代码

经过itemState的状态来管理是否鼠标已经点击这个事件,鼠标移动画布显然是一个包含反作用的操做,这里同时兼容了touch操做:node

useEffect(() => {
    if (el.current) {
      const container = el.current;
      const { addEventListener } = container;
      const mousedown = (e) => { 
        // 确认点击事件发生在了画布上
        if (e.target === inner.current || e.target === el.current) {
          itemState.set('mouse', true);
        }
      };
      const mousemove = ({movementX, movementY}) => {
        if (itemState.get('mouse')) {
          setPosition(x - movementX, y - movementY)
        }
      }
      const mouseup = (e) => {
        if (e.target === inner.current || e.target === el.current) {
          itemState.set('mouse', false);
        }
      };
      [['mousedown', mousedown], ['mousemove', mousemove], ['mouseup', mouseup]].forEach(([name, cb]) => {
        addEventListener(name, cb)
      })
      return () => {
        if (el.current) {
          const { removeEventListener } = el.current;
          [['mousedown', mousedown], ['mousemove', mousemove], ['mouseup', mouseup]].forEach(([name, cb]) => {
            removeEventListener(name, cb)
          })
        }
      }
    }
  }, [itemState])
复制代码

最后根据几个状态得出的jsx主体, 经过transform来让画布能够无限拖动:react

<div
      ref={el}
      style={{
        width: '100%',
        height: '100%',
        overflow: 'hidden',
        cursor: itemState.get('mouse') ? 'grabbing' : 'grab',
        ...style,
      }}
    >
      <div
        ref={inner}
        style={{ width: '100%', height: '100%', transform: `translate3d(${-x}px, ${-y}px, 0)` }}
      >
        {children}
      </div>
    </div>
复制代码

这样就完成了一个简单的画布,相关知识点:c++

节点

首先,这里的节点指的是一个用来容纳内容的实体,自己和内容并无关系,负责处理了位置,大小等关系。做为节点,数量较多,因此不可以延续以前只要是状态修改就可以从新装载监听事件的特色,因此此处使用ref跨作数据暂存。

节点自己也存在着诸多属性,可是这些属性大可能是由外界提供,例如位置,大小,因此经过函数的方式将状态变化回传,由上层来决定是否须要变动节点数据。

  • props: x, y, children, onMove(移动时的回调), onSize(大小状态的回调)

    在反作用的回调里面使用state实际上是不会根据state生效的,这是react的hook一个不太好用的地方,因此采用引用来解决这个问题。

useEffect(() => {
    if (el.current) {
      const { addEventListener, removeEventListener } = el.current;
      const mousedown = e => {
        // 阻止事件冒泡
        e.stopPropagation();
        if (e.path.some(e => e === el.current)) {
          isMoving.current = true;
        }
      };
      const mousemove = ({ movementX, movementY }) => {
        if (isMoving.current) {
          const pos = posRef.current;
          onMove(pos.x + movementX, pos.y + movementY);
        }
      };
      const mouseup = e => {
        isMoving.current = false;
      };
      [
        ['mousedown', mousedown],
        ['mousemove', mousemove],
        ['mouseup', mouseup],
      ].forEach(([name, cb]) => {
        addEventListener(name, cb);
      });
      return () => {
        [
          ['mousedown', mousedown],
          ['mousemove', mousemove],
          ['mouseup', mouseup],
        ].forEach(([name, cb]) => {
          removeEventListener(name, cb);
        });
      };
    }
  }, []);

复制代码

由于是容器的关系,没有渲染节点就没法得知节点大小,这时还须要一个可以感知节点大小的方法:

useEffect(() => {
    if (el.current && onSize) {
      onSize(el.current.getBoundingClientRect());
    }
  });
复制代码

一样使用CSS来控制位置,简单的给出jsx结构:

<div
      ref={el}
      style={{
        position: 'absolute',
        transform: `translate3d(${x}px, ${y}px, 0)`,
        cursor: 'default',
        zIndex: 2,
      }}
    >
      {children}
    </div>
复制代码

相关知识点

关于边的实现,因为目前html技术中尚未一个能够绕开svg,canvas,css的视图方案,为了方便管理事件,采用svg技术来实现边的形状是比较靠谱的。而且在设计时,但愿这个边能够有更好的拓展性,因此采用路径来做为自定义边的一个元素。

  • props: start(开始点), end(结束点), type (边类型)

    首先,定义多种内置边路径,都是经过g元素的path来实现的:

const pathMap = {
  // 曲线边
  curve: (width, height) => {
    if (width > height) {
      return `M0,0 C${width},0 0,${height} ${width} ${height}`;
    }
      return `M0,0 C0,${height} ${width},0 ${width} ${height}`;
  },
  // 直线边
  line: (width, height) => {
    return `M0,0 L${width} ${height}`;
  },
  // 折线边
  polyline: (width, height) => {
    if (width < height) {
      return `M0,0 L${width / 2},0 L${width / 2},${height} L${width} ${height}`;
    }
      return `M0,0 L0,${height / 2} L${width},${height / 2} L${width} ${height}`;
  },
};
复制代码

对于关系图来讲,还有一个更重要的方面,那就是指向性,因此还须要在边上绘制尖头,由于采用的是svg技术,因此能够用maker来实现。

<svg
        width={`${Math.abs(width)}px`}
        height={`${Math.abs(height)}px`}
        xmlns="http://www.w3.org/2000/svg"
        version="1.1"
        style={{ overflow: 'visible' }}
      >
        <defs> {' '} <marker style={{ overflow: 'visible' }} id="arrow" markerWidth="20" markerHeight="20" refx="0" refy="0" orient="auto" markerUnits="strokeWidth" > {' '} <path d="M0,-3 L0,3 L9,0 z" fill="black" />{' '} </marker>{' '} </defs> <g> <path d={generatePath()} fill="transparent" stroke="black" strokeWidth="1" strokeLinecap="round" markerEnd="url(#arrow)" /> </g> </svg> 复制代码

相关知识点:

Gract组件

基于上面的基础组件,咱们须要把逻辑都细化,而且统一管理事件和交互,这就是Gract组件的自己。

出于自定义点的须要,定义一个能够全局注册点的map:

// 点的对应
const globalNodeMap = new Map();
// 注册节点
const registerNode = (type, component) => globalNodeMap.set(type, component);
// 取节点
const getTypeNode = type => (globalNodeMap.has(type) ? globalNodeMap.get(type) : DefaultNode);
复制代码

那么如此,须要一个兜底的节点来渲染。

function DefaultNode({ data }) {
  return (
    <div style={{ border: '2px solid #66ccff', padding: 12, background: 'white' }}> {Object.entries(data).map(([k, v], i) => ( <p key={i}> {k}: {typeof v === 'object' ? safeStringify(v) : v} </p> ))} </div>
  );
}
复制代码

​ 在这里就能够看出,对于Gract,一个节点其实就是任意一个组件,这使得不少组件和逻辑交互,均可以使用React组件来达到。

接下来,就是把一切都组装起来的时候了;

  • 定义点的anchor:

    const nodeAnchors = [
      [0, 0.5],
      [0.5, 1],
      [1, 0.5],
      [0.5, 0],
    ];
    复制代码
  • 渲染点

    {_nodes.map(node => {
            const { x = 0, y = 0, type, id } = { ...defaultNode, ...node };
            const Node = getTypeNode(type);
            return (
              <BaseNode x={x} y={y} onMove={(x, y) => updateNodePosition(id, x, y)} key={id} onSize={s => nodeSizeMap.current.set(id, s)} > <Node data={node} /> </BaseNode> ); })} 复制代码
  • 渲染边,这里用到了一个寻找两个点之间最小的距离来构造边,而且根据点的变化来更新。

    useEffect(() => {
        const res = [];
        edges.map(({ source, target, ...rest }) => {
          const nMap = nodeSizeMap.current;
          const _n = nodesRef.current;
          if (nMap.has(source) && nMap.has(target)) {
            const s = nMap.get(source);
            const t = nMap.get(target);
            const sNode = _n.find(e => e.id === source);
            const tNode = _n.find(e => e.id === target);
            const startPoints = nodeAnchors.map(([xx, yy]) => [
              s.width * xx + sNode.x,
              s.height * yy + sNode.y,
            ]);
            const endPoints = nodeAnchors.map(([xx, yy]) => [
              t.width * xx + tNode.x,
              t.height * yy + tNode.y,
            ]);
            let resPoint = [];
            let minPos = null;
            startPoints.forEach(([sx, sy]) => {
              endPoints.forEach(([ex, ey]) => {
                const dis = Math.pow(ex - sx, 2) + Math.pow(ey - sy, 2);
                if (minPos === null || dis < minPos) {
                  resPoint = [
                    { x: sx, y: sy },
                    { x: ex, y: ey },
                  ];
                  minPos = dis;
                }
              });
            });
    
            res.push(<BaseEdge start={resPoint[0]} end={resPoint[1]} {...rest} {...defautEdge} />); } setEdges(res); }); }, [_nodes]); 复制代码

Demo演示

使用以下代码来使用Gract:

import React from 'react';
import ReactDOM from 'react-dom';
import dagre from 'dagre';
import Gract from './gract';
import test from './test';

const el = document.getElementById('mountNode');

function ExampleNode({ data: { label = 'example', type } = {} }) {
  return (
    <div style={{ borderRadius: '4px', textAlign: 'center', color: 'white', background: 'linear-gradient(to right, #40e0d0, #ff8c00, #ff0080)', boxShadow: 'rgba(0,0,0,.25) 0 1px 2px', }} > <h3 style={{ margin: 0, padding: 10 }}>{label}</h3> <p style={{ padding: 6, fontSize: 12, color: 'black', textAlign: 'left', background: 'white', maxWidth: 200, overflow: 'scroll', margin: 0, }} > type: {type} <br /> </p> </div>
  );
}

Gract.registerNode('gradient', ExampleNode);

const dagreLayout = (nodes, edges) => {
  const graph = new dagre.graphlib.Graph();
  graph.setDefaultEdgeLabel(() => {
    return {};
  });
  graph.setGraph({});
  nodes.forEach(({ id, rect: { width, height } }) => {
    graph.setNode(id, { width, height, label: id });
  });
  edges.map(({ source, target }) => {
    graph.setEdge(source, target);
  });
  dagre.layout(graph);
  graph.nodes().forEach(e => {
    const aNode = graph.node(e);
    const node = nodes.find(e => e.id === aNode.label);
    console.log(e, node, aNode);
    node.x = aNode.x;
    node.y = aNode.y;
  });
  console.log(nodes);
  return nodes;
};

const nodes = [
  {
    id: '1',
    label: 'node0',
    x: 0,
    y: 0,
  },
  {
    id: '2',
    label: 'node2',
    x: 0,
    y: 200,
  },
  {
    id: '3',
    label: 'node3',
    x: 200,
    y: 0,
  },
];

const edges = [
  {
    source: '1',
    target: '2',
  },
  {
    source: '1',
    target: '3',
  },
];

ReactDOM.render(
  <Gract data={{ nodes, edges }} defaultNode={{ type: 'gradient' }} layout={dagreLayout} option={{ nodeMove: true }} />, el, ); 复制代码

最终效果页面:mxz96102.github.io/Gract/dist/

项目页面:github.com/mxz96102/Gr…

附带招聘广告)

蚂蚁金服数据技术部校园招聘求前端,要求21届毕业生,咱们是整个蚂蚁金服的数据引擎底座,场景涉及人工智能,大数据计算,分布式。中台平台涉及IDE建设,数据可视化等一系列热门场景,机会大,挑战大。 同时c++以及java研发工程师咱们也很须要,若是有意向添加微信 mxz96102 详细了解内推。

相关文章
相关标签/搜索