记一次X6在项目中的使用实践

1,670 阅读7分钟

相关背景:

最近由于手头在做手头的相关业务,其中有一个业务涉及到图形编辑,故查阅了相关资料,经过仔细查阅对比,最终选择了AntV下的X6X6AntV旗下的图形编辑引擎,提供了一些列开箱即用的交互组件和简单易用的节点定制能力,从而快速搭建流程图、DAG图、ER图等图应用。特点是节点、边等元素定制能力特别强。

主要相关事件及Api:

  • Graph(画布):画布是图的载体,,它包含了图上的所有元素(节点、边等),同时挂载了图的相关操作(如交互监听、元素操作、渲染等)。
const graph = new Graph({
    panning: true,
})
// 可以添加相关属性来设置画布的基础属性,具体见https://x6.antv.vision/zh/docs/tutorial/basic/graph
  • Cell(基类):定义了节点和边共同属性和方法,如属性样式、可见性、业务数据等,并且在实例化、定制样式、配置默认选项等方面具有相同的行为。
  • Node(节点):X6 的 Shape 命名空间中内置了一些基础节点,,如 RectCircleEllipse 等,可以使用这些节点的构造函数来创建节点。
import { Shape } from '@antv/x6'
// 创建节点
const rect = new Shape.Rect({
    x: 100,
    y: 200,
    width: 80,
    height: 40,
    angle: 30,
    attrs: {
        body: {
            fill: 'blue',
        },
        label: {
            text: 'Hello',
            fill: 'white',
        },
    },
})
// 添加到画布
graph.addNode(rect)
  • Edge(边): X6 的 Shape 命名空间中内置 EdgeDoubleEdgeShadowEdge 三种边,可以使用这些边的构造函数来创建边。
import { Shape } from '@antv/x6'
// 创建边
const edge = new Shape.Edge({
    source: rect1,
    target: rect2,
})
// 添加到画布
graph.addEdge(edge)
  • Port(链接桩):链接桩是节点上的固定连接点,很多图应用都有链接桩,并且有些应用还将链接桩分为输入链接桩输出连接桩
创建节点时我们可以通过 `ports` 选项来配置链接桩
const node = new Node({
    ports: {
        groups: { ... }, // 链接桩组定义
        items: [ ... ], // 链接桩
    }
})
  • Dnd(拖拽):Dnd 是 Addon 命名空间中的一个插件,提供了基础的拖拽能力。
// 首先,创建一个 Dnd 的实例,并提供了一些选项来定制拖拽行为。
import { Addon } from '@antv/x6'
const dnd = new Addon.Dnd(options)
// 相关api
/**
*开始拖拽
*dnd.start(node, e)
*@params node:开始拖拽的节点。
*@params e: 鼠标事件
**/
  • 序列化/反序列化: graph.toJSON()graph.fromJSON 两个方法来序列化和反序列化图,
// 序列化
// 我们可以调用 `graph.toJSON()` 方法来导出图中的节点和边,返回一个具有{ cells: [] }` 结构的对象,其中 cells 数组**按渲染顺序**保存节点和边。
// 节点结构如下
{
    id: string,
    shape: string,
    position: {
        x: number
        y: number
    },
    size: {
        width: number
        height: number
    },
    attrs: object,
    zIndex: number,
}
// 边结构如下
{
    id: string,
    shape: string,
    source: object,
    target: object,
    attrs: object,
    zIndex: number,
}
// 反序列化,支持节点/边元数据数组
graph.fromJSON(cells: (Node.Metadata | Edge.Metadata)[])。
graph.fromJSON([
    {
        id: 'node1',
        x: 40,
        y: 40,
        width: 100,
        height: 40,
        label: 'Hello',
        shape: 'rect',
    },
    {
        id: 'node2',
        x: 40,
        y: 40,
        width: 100,
        height: 40,
        label: 'Hello',
        shape: 'ellipse',
    },
    {
        id: 'edge1',
        source: 'node1',
        target: 'node2',
        shape: 'edge',
    }
])
// 可以通过 `graph.fromJSON({ cells: [...] })` 来渲染 `graph.toJSON()` 导出的数据。

事件系统

  • 视图交互事件
    • 鼠标事件
事件cell 节点/边node 节点edge 边blank 画布空白区域
单击cell:clicknode:clickedge:clickblank:click
双击cell:dblclicknode:dblclickedge:dblclickblank:dblclick
右键cell:contextmenunode:contextmenuedge:contextmenublank:contextmenu
鼠标按下cell:mousedownnode:mousedownedge:mousedownblank:mousedown
移动鼠标cell:mousemovenode:mousemoveedge:mousemoveblank:mousemove
鼠标抬起cell:mouseupnode:mouseupedge:mouseupblank:mouseup
鼠标滚轮cell:mousewheelnode:mousewheeledge:mousewheelblank:mousewheel
鼠标进入cell:mouseenternode:mouseenteredge:mouseentergraph:mouseenter
鼠标离开cell:mouseleavenode:mouseleaveedge:mouseleavegraph:mouseleave
graph.on('cell:click', ({ e, x, y, cell, view }) => { })
graph.on('node:click', ({ e, x, y, node, view }) => { })
graph.on('edge:click', ({ e, x, y, edge, view }) => { })
graph.on('blank:click', ({ e, x, y }) => { })
graph.on('cell:mouseenter', ({ e, cell, view }) => { })
graph.on('node:mouseenter', ({ e, node, view }) => { })
graph.on('edge:mouseenter', ({ e, edge, view }) => { })
graph.on('graph:mouseenter', ({ e }) => { })
  • 自定义事件
// 在节点/边的 DOM 元素上添加自定义属性 `event` 或 `data-event` 来监听该元素的点击事件
node.attr({
    // 表示一个删除按钮,点击时删除该节点
    image: {
        event: 'node:delete',
        xlinkHref: 'trash.png',
        width: 20,
        height: 20,
    },
})
// 可以通过绑定的事件名 `node:delete` 或通用的 `cell:customevent`、`node:customevet`、`edge:customevent` 事件名来监听。
graph.on('node:delete', ({ view, e }) => {
    e.stopPropagation()
    view.cell.remove()
})
graph.on('node:customevent', ({ name, view, e }) => {
    if (name === 'node:delete') {
        e.stopPropagation()
        view.cell.remove()
    }
})
// 这只是简单事件相关api,具体详见:https://x6.antv.vision/zh/docs/tutorial/intermediate/events

相关实践:

以上主要相关api基本介绍完毕了,接下来正式进入图编辑相关实践,其中分为以下步骤

页面初始化

  • 生成画布,并设置相关属性。
import { Graph, Dom, Addon, Shape } from '@antv/x6';
const graph = new Graph({
    container: this.container, // 画布容器
    grid: true, // 网格
    history: { // 操作历史
        enabled: true,
    },
    snapline: { // 对齐线
        enabled: true,
        sharp: true,
    },
    scroller: { // 滚动画布
        enabled: true,
        pageVisible: false,
        pageBreak: false,
        pannable: true,
    },
    mousewheel: { // 鼠标滚轮缩放
        enabled: true,
        modifiers: ['ctrl', 'meta'],
    },
    connecting: { // 连线选项
        router: 'manhattan',
        connector: {
            name: 'rounded',
            args: {
                radius: 8
            },
        },
        anchor: 'center',
        allowBlank: false,
        snap: {
            radius: 20,
        },
        createEdge() { // 创建边属性
            return new Shape.Edge({
                attrs: {
                line: {
                    stroke: '#A2B1C3',
                    strokeWidth: 2,
                    targetMarker: {
                        name: 'block',
                        width: 12,
                        height: 8,
                    }
                },
            },
              zIndex: 0,
            });
        },
        validateConnection({ targetMagnet }) {
            return !!targetMagnet;
        },
     }
});
  • 创建Dnd实例,并设置拖拽行为
this.dnd = new Dnd({

    target: graph,

    scaled: false,

    animation: true,

    getDropNode: (draggingNode: any, GetDragNodeOptions) => {

        // 节点拖拽介绍执行相关动作

        this.closeStepModal();

        return draggingNode.clone({ keepId: true });

    }

});
  • 监听节点/边相关事件
// 监听节点点击事件
graph.on('node:click', ({ e, x, y, node, view }: any) => {

    node.attr({

        body: {

            stroke: '#13c1c2',

            strokeDasharray: '5, 1',

        },

    });

});
// 监听节点移动事件
graph.on('node:moved', ({ node }) => {

    this.props.setEditting(true);

});

// @ts-ignore

graph.on('node:delete', ({ view, e }) => {

    e.stopPropagation();

    console.log('节点删除');

});

// 监听节点被删除事件

graph.on('node:removed', ({ node, index, options }) => {

    this.props.setEditting(true);

});

// 监听边被删除事件

graph.on('edge:removed', ({ edge, index, options }) => {

    console.log('edge', edge);

    this.props.setEditting(true);

});
// 鼠标移入节点时添加删除图标

graph.on('node:mouseenter', ({ node }) => {

    node.addTools([

        {

        name: 'button-remove',

        args: { x: 163, y: 5 },

        },

    ]);

});

// 监听鼠标离开节点事件

graph.on('node:mouseleave', ({ node }) => {

    if (node.hasTool('button-remove')) {

        node.removeTool('button-remove');

    }

    node.attr({

        body: {

        stroke: '#13c1c2',

        strokeDasharray: '0',

        },

    });

});
// 鼠标移入边时添加删除图标

graph.on('edge:mouseenter', ({ cell }) => {

    cell.addTools([

    {

        name: 'button-remove',

        args: { distance: 15 },

    },

    ]);

});

// 监听鼠标离开边事件

graph.on('edge:mouseleave', ({ cell }) => {

    if (cell.hasTool('button-remove')) {

        cell.removeTool('button-remove');

    }

});
  • 动态生成画布中的节点
 createGraphNode = (arr: any[], graph: any) => {
    // console.log('nodearr', arr);
    let _this = this;
    if (arr && arr.length === 0) return;
    arr.forEach((item, index) => {
      // console.log('item', item);
      let y = 80 * (index + 1);
      graph.addNode({
        width: 160,
        height: 30,
        x: 130,
        y,
        tools: [
          {
            name: 'button',
            args: {
              markup: [
                {
                  tagName: 'circle',
                  selector: 'button',
                  attrs: {
                    r: 8,
                    stroke: '#13c1c2',
                    'stroke-width': 2,
                    fill: 'white',
                    cursor: 'pointer',
                  },
                },
                {
                  tagName: 'text',
                  textContent: '+',
                  selector: 'icon',
                  attrs: {
                    fill: '#13c1c2',
                    'font-size': 12,
                    'text-anchor': 'middle',
                    'pointer-events': 'none',
                    y: '0.3em',
                  },
                },
              ],
              x: 163,
              y: 32,
              onClick({ view }: any) {
                // console.log(view);
                _this.closeStepModal();
                _this.setState({
                  currentNode: view?.attr?.view?.cell?.store?.data?.attrs?.label,
                });
              },
            },
          },
          {
            name: 'button',
            args: {
              markup: [
                {
                  tagName: 'circle',
                  selector: 'button',
                  attrs: {
                    r: 8,
                    stroke: '#13c1c2',
                    'stroke-width': 2,
                    fill: 'white',
                    cursor: 'pointer',
                  },
                },
                {
                  tagName: 'text',
                  textContent: '+',
                  selector: 'icon',
                  attrs: {
                    fill: '#13c1c2',
                    'font-size': 12,
                    'text-anchor': 'middle',
                    'pointer-events': 'none',
                    y: '0.3em',
                  },
                },
              ],
              x: 0,
              y: 32,
              onClick({ view }: any) {
                _this.getLevelTimeout(view?.attr?.view?.cell?.store?.data?.attrs?.label.text);
              },
            },
          },
        ],
        ports: {
          groups: {
            // 输入链接桩群组定义
            in: {
              position: 'top',
              attrs: {
                circle: {
                  r: 6,
                  magnet: true,
                  stroke: '#31d0c6',
                  strokeWidth: 2,
                  fill: '#fff',
                },
              },
            },
            // 输出链接桩群组定义
            out: {
              position: 'bottom',
              attrs: {
                circle: {
                  r: 6,
                  magnet: true,
                  stroke: '#31d0c6',
                  strokeWidth: 2,
                  fill: '#fff',
                },
              },
            },
          },
          items: [
            {
              id: `${item.nodeType}-${createEightRandom()}-1`,
              group: 'in',
            },
            {
              id: `${item.nodeType}-${createEightRandom()}-2`,
              group: 'out',
            },
          ],
        },
        attrs: {
          label: {
            text: item.nodeName,
            fill: '#6a6c8a',
            id: item.nodeType,
          },
          body: {
            stroke: '#31d0c6',
            strokeWidth: 2,
          },
        },
      });
    });
    const { cells } = graph.toJSON();
    // console.log(111, cells);
    for (let i = 0; i < cells.length; i++) {
      if (i < cells.length - 1) {
        let isEdge: boolean = false;
        arr.forEach((item) => {
          if (item.nodeName === cells[i + 1].attrs.label.text) {
            if (item.depends.length > 0) {
              isEdge = true;
            }
          }
        });
        if (isEdge) {
          graph.addEdge({
            source: { cell: cells[i].id, port: cells[i].ports.items[1].id }, // 源节点和链接桩 ID
            target: {
              cell: cells[i + 1].id,
              port: cells[i + 1].ports.items[0].id,
            }, // 目标节点 ID 和链接桩 ID
            attrs: {
              line: {
                stroke: '#A2B1C3',
                strokeWidth: 2,
                targetMarker: {
                  name: 'block',
                  width: 12,
                  height: 8,
                },
              },
            },
          });
        }
      }
    }
  };

初始化完成,从左侧列表中拖拽节点到画布中

  • 监听左侧列表中节点信息
<div

data-type="rect"

data-msg={JSON.stringify(item)}

className="dnd-rect"

onMouseDown={this.startDrag}

key={item.id}

>
<script>

const startDrag = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
    let _this = this;
    const target = e.currentTarget;
    const type = target.getAttribute('data-type');
    const data: any = target.getAttribute('data-msg');
    const JsonData = JSON.parse(data);
    const node =
      type === 'rect'
        ? this.graph.createNode({
            width: 160,
            height: 30,
            ports: {
              groups: {
                // 输入链接桩群组定义
                in: {
                  position: 'top',
                  attrs: {
                    circle: {
                      r: 6,
                      magnet: true,
                      stroke: '#31d0c6',
                      strokeWidth: 2,
                      fill: '#fff',
                    },
                  },
                },
                // 输出链接桩群组定义
                out: {
                  position: 'bottom',
                  attrs: {
                    circle: {
                      r: 6,
                      magnet: true,
                      stroke: '#31d0c6',
                      strokeWidth: 2,
                      fill: '#fff',
                    },
                  },
                },
              },
              items: [
                {
                  id: `${JsonData.id}-${createEightRandom()}-1`,
                  group: 'in',
                  // onChange: () => this.connnect(e),
                },
                {
                  id: `${JsonData.id}-${createEightRandom()}-2`,
                  group: 'out',
                },
              ],
            },
            tools: [
              {
                name: 'button',
                args: {
                  markup: [
                    {
                      tagName: 'circle',
                      selector: 'button',
                      attrs: {
                        r: 8,
                        stroke: '#13c1c2',
                        'stroke-width': 2,
                        fill: 'white',
                        cursor: 'pointer',
                      },
                    },
                    {
                      tagName: 'text',
                      textContent: '+',
                      selector: 'icon',
                      attrs: {
                        fill: '#13c1c2',
                        'font-size': 12,
                        'text-anchor': 'middle',
                        'pointer-events': 'none',
                        y: '0.3em',
                      },
                    },
                  ],
                  x: 163,
                  y: 32,
                  onClick({ view }: any) {
                    // console.log(view);
                    _this.closeStepModal();
                    _this.setState({
                      currentNode: view?.attr?.view?.cell?.store?.data?.attrs?.label,
                    });
                  },
                },
              },
              {
                name: 'button',
                args: {
                  markup: [
                    {
                      tagName: 'circle',
                      selector: 'button',
                      attrs: {
                        r: 8,
                        stroke: '#13c1c2',
                        'stroke-width': 2,
                        fill: 'white',
                        cursor: 'pointer',
                      },
                    },
                    {
                      tagName: 'text',
                      textContent: '+',
                      selector: 'icon',
                      attrs: {
                        fill: '#13c1c2',
                        'font-size': 12,
                        'text-anchor': 'middle',
                        'pointer-events': 'none',
                        y: '0.3em',
                      },
                    },
                  ],
                  x: 0,
                  y: 32,
                  onClick({ view }: any) {
                    _this.getLevelTimeout(view?.attr?.view?.cell?.store?.data?.attrs?.label.text);
                  },
                },
              },
            ],
            attrs: {
              label: {
                text: `${JsonData.name}-${createEightRandom()}`,
                fill: '#6a6c8a',
                id: JsonData.id,
              },
              body: {
                stroke: '#31d0c6',
                strokeWidth: 2,
              },
            },
          })
        : this.graph.createNode({
            width: 60,
            height: 60,
            shape: 'html',
            html: () => {
              const wrap = document.createElement('div');
              wrap.style.width = '100%';
              wrap.style.height = '100%';
              wrap.style.display = 'flex';
              wrap.style.alignItems = 'center';
              wrap.style.justifyContent = 'center';
              wrap.style.border = '2px solid rgb(49, 208, 198)';
              wrap.style.background = '#fff';
              wrap.style.borderRadius = '100%';
              wrap.innerText = 'Circle';
              return wrap;
            },
          });
    this.dnd.start(node, e.nativeEvent as any);
  };

</script>

到此整个流程图就基本渲染结束了,最后贴上最后的流程图

image.png

最后总结下整个流程:

  1. 页面初始化节点:渲染画布,并设置相关属性 --> 获取数据-->动态创建节点,创建链接桩,监听画布中节点事件--> 根据节点依赖绘制节点连接线。
  2. 节点拖拽阶段:监听节点开始移动,并在节点拖拽结束在画布中生成相关节点,并设置节点的相关信息(节点的工具列表,节点连接桩等)。