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 詳細瞭解內推。

相關文章
相關標籤/搜索