使用Antv之X6

1,450 阅读10分钟

前言

X6·图编辑引擎

X6 是基于 HTML 和 SVG 的图编辑引擎,提供低成本的定制能力和开箱即用的内置扩展,方便我们快速搭建 DAG 图、ER 图、流程图、血缘图等应用。

X6官网

image.png

需求展示

复杂事件处理 CEP [规则推导]

image.png

image.png

核心功能清单分析

当你列举的足够细时,你的大任务将被拆成很小的任务,因此每一个TODO项可以预估到0-2h内,每天的效率高

左侧树+产生节点

  1. 树节点拖拽
  2. 产生节点拖拽

中间画布

  1. 左侧业务节点渲染
  2. 右侧节点渲染
  3. 节点连线规则
  4. 容器节点点击
  5. 数据回显和提交

右侧逻辑节点

  1. 节点拖拽

总结核心功能引出需要解决问题

  1. 如何实现拖拽生成节点 [Q1]
  2. 如何定制生成节点的样式,包括非法样式以及合法样式切换[Q2]
  3. 怎么样响应业务事件,包括连线,业务校验 [Q3]
  4. 画布数据搜集和回显 [Q4]

一览X6的能力

快速上手

1.初始化

//目标容器
<div id="container"></div>   

// 核心包
import { Graph } from '@antv/x6' 

// 画布对象
const graph = new Graph({
  container: document.getElementById('container'),
  width: 800,
  height: 600,
  background: {
    color: '#F2F7FA',
  },
})
  1. 节点数据
const data = {
  nodes: [
    {
      id: 'node1',
      shape: 'custom-react-node',
      x: 40,
      y: 40,
      label: 'hello',
    },
    {
      id: 'node2',
      shape: 'custom-react-node',
      x: 160,
      y: 180,
      label: 'world',
    },
  ],
  edges: [
    {
      shape: 'edge',
      source: 'node1',
      target: 'node2',
      label: 'x6',
      attrs: {
        line: {
          stroke: '#8f8f8f',
          strokeWidth: 1,
        },
      },
    },
  ],
}

// 调用渲染
graph.fromJSON(data)
graph.centerContent()
  1. 插件
// 安装插件包
import { Snapline } from '@antv/x6-plugin-snapline'
// 初始化时使用
graph.use(
  new Snapline({
    enabled: true,
  }),
)
  1. 画布数据
graph.toJSON()

答案:[Q4]

X6 基础

X6基础

image.png

总结

  1. 和[Q2]相关的信息包括 ‘节点’、‘连接桩’、‘事件’
  2. 和[Q3]相关的信息应该包括 ‘交互’

Feel

看完基础,距离我们的需求好像差很多内容,不急进阶一把。

X6 进阶

X6进阶

image.png

自定义节点

  1. SVG方式
  2. Vue3节点

定义边/连接桩

  1. 连接桩基础
  2. 边基础

总结

  1. 进阶完后,大概一览出节点、边、连接点的大致信息
  2. 交互动作的相关信息

X6 插件

X6插件

image.png

使用插件

  1. 下载对应包
  2. 实例化
  3. graph.use(Plugin)

答案:[Q1] Dnd 插件

DnD插件的使用

import { Dnd } from '@antv/x6-plugin-dnd';

const graph = new Graph({ background: { color: '#F2F7FA', }, }) 
const dnd = new Dnd({ target: graph, })

// react-component
export default () => {
    const startDrag = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
        // 该 node 为拖拽的节点,默认也是放置到画布上的节点,可以自定义任何属性 
        const node = graph.createNode({ shape: 'rect', width: 100, height: 40, }) 
        dnd.start(node, e.nativeEvent) 
    } 
    return ( <ul> <li onMouseDown={startDrag}></li> </ul> ) 
 }

// 总结一下
1. 在 vue 中设置多个dnd 元素引用,绑定 DND-target;
2. 响应dnd实例的start事件生成对应节点,既可以完成节点拖拽响应以及对应位置更新事件

官方demo

Examples

XFlow

实战

构建Vue项目以及安装相关依赖

  1. npm create vue@latest project-name = [cep-x6]
  2. cd cep-x6 && npm install
  3. npm run dev
  4. 安装依赖
npm i @antv/x6 element-plus

  1. 修改项目文件如下

image.png

  1. 快速新建视图

image.png

  1. 树组件和拖拽事件注册

image.png

  1. 逻辑组件

image.png

定制Graph

  1. 初始化graph对象

image.png

  1. 可设置选项
    • 画布背景
    • 宽高
    • 支持鼠标滚动缩放
    • 自动响应容器resize
    • 网格参数

image.png

定制插件

定制插件 DnD 以及对齐线 Snapline

  1. Snapline

    image.png

  2. Dnd

    • 第一步 注册引用

      image.png

    • 第二步 连接拖拽事件

      image.png

      image.png

    • 第三步 加入原生节点测试

      image.png

至此:存储dev分支tag 1.0.0

定制节点

通过官方的示例一览

image.png

定制节点

markup

文档

markup 指定了渲染节点/边时使用的 SVG/HTML 片段,使用 JSON 格式描述

attrs

文档

属性选项 attrs 是一个复杂对象,该对象的 Key 是节点 Markup 定义中元素的选择器(selector),对应的值是应用到该 SVG 元素的 SVG 属性值(如 fill 和 stroke),如果你对 SVG 属性还不熟悉,可以参考 MDN 提供的填充和边框入门教程。

拆分节点组成

image.png

编码制定节点

  1. 书写配置
import { Graph } from '@antv/x6'
import { genCustomNode } from './node.helper'

// 逻辑节点容器
export const LOGIC_NODE_CONTAINER = 'Container'
// 树节点
export const TREE_NODE = 'TreeNode'

export interface IConfig {
  [LOGIC_NODE_CONTAINER]: string
  [TREE_NODE]: string
}

export const VALID_UI = {
  fill: '#f99b8d',
  stroke: '#f12000'
}

export const INVALID_UI = {
  fill: '#fff',
  stroke: '#d9dde1'
}

export interface CustomNodeConfig {
  componentKey: keyof IConfig
  label: string
  bussiness?: { [key: string]: any }
  width: number
  height: number
  xlink?: string
}

// 节点配置
export const nodesConfig: { [key in keyof IConfig]: CustomNodeConfig } = {
  Container: {
    componentKey: LOGIC_NODE_CONTAINER,
    label: '容器',
    bussiness: {
      nodes: []
    },
    width: 56,
    height: 56,
    xlink: './xlinks/container.svg'
  },
  TreeNode: {
    componentKey: TREE_NODE,
    label: '树节点',
    width: 134,
    height: 24
  }
}

// 注册自定义的节点
export default function registryNode() {
  for (const k in nodesConfig) {
    Graph.registerNode(k as any, genCustomNode(k as keyof IConfig))
  }
}

  1. 书写生成函数
import type { Markup, Registry } from '@antv/x6'
import { INVALID_UI, nodesConfig, type IConfig } from './node.config'

// body
const genBody = (componentKey: keyof IConfig) => {
  const { width, height } = nodesConfig[componentKey]
  const config: { markup: Markup; attrs: Registry.Attr.SimpleAttrs } = {
    markup: {
      tagName: 'rect',
      selector: 'body'
    },
    attrs: {
      ...INVALID_UI,
      rx: 4,
      ry: 4,
      strokeWidth: 1,
      width,
      height
    }
  }
  return config
}

// label
const genLabel = (componentKey: keyof IConfig, text: string) => {
  const fontSize = 14
  const { xlink } = nodesConfig[componentKey]
  const textPos = xlink
    ? {
        refX: '50%',
        refY: '100%',
        refY2: fontSize
      }
    : {
        refY: '50%',
        refY2: 1,
        textVerticalAnchor: 'middle'
      }
  const config: { markup: Markup; attrs: Registry.Attr.SimpleAttrs } = {
    markup: {
      tagName: 'text',
      selector: 'label'
    },
    attrs: {
      text: text,
      ...textPos,
      fontSize,
      textAnchor: 'middle',
      fill: '#001833'
    }
  }
  return config
}

// logo
const genLogo = (componentKey: keyof IConfig, link: string) => {
  const { width, height } = nodesConfig[componentKey]
  const config: { markup: Markup; attrs: Registry.Attr.SimpleAttrs } = {
    markup: {
      tagName: 'image',
      selector: 'logo'
    },
    attrs: {
      'xlink:href': link,
      width,
      height,
      x: 0,
      y: 0
    }
  }
  return config
}

export const genCustomNode = (componentKey: keyof IConfig, metadata?: { [key: string]: any }) => {
  const { label, bussiness, width, height, xlink } = nodesConfig[componentKey]
  const body = genBody(componentKey)
  const nodeLabel = genLabel(componentKey, metadata?.label ?? label)
  const node: { [key: string]: any } = {
    width,
    height,
    attrs: {
      body: body.attrs,
      label: nodeLabel.attrs
    },
    markup: [body.markup, nodeLabel.markup],
    data: { ...(bussiness || {}), ...(metadata || {}) }
  }

  if (xlink) {
    const logo = genLogo(componentKey, xlink)
    node.attrs = { ...node.attrs, logo: logo.attrs }
    node.markup = [...node.markup, logo.markup]
  }
  return node
}

  1. 使用并调用

    image.png

    image.png

存一个tag:dev 1.0.1

定制连接桩

连接桩

定义连接桩分组

import type { Registry, Markup } from '@antv/x6'
import { INVALID_UI } from './node.config'

export interface PortGroupMetadata {
  markup?: Markup // 连接桩 DOM 结构定义。
  attrs?: Registry.Attr.CellAttrs // 属性和样式。
  zIndex?: number | 'auto' // 连接桩的 DOM 层级,值越大层级越高。
  // 群组中连接桩的布局。
  position?: [number, number] | string | { name: string; args?: object }
  label?: {
    // 连接桩标签
    markup?: Markup
    position?: {
      // 连接桩标签布局
      name: string // 布局名称
      args?: object // 布局参数
    }
  }
}

export interface PortMetadata {
  id?: string // 连接桩唯一 ID,默认自动生成。
  group?: string // 分组名称,指定分组后将继承分组中的连接桩选项。
  args?: object // 为群组中指定的连接桩布局算法提供参数, 我们不能为单个连接桩指定布局算法,但可以为群组中指定的布局算法提供不同的参数。
  markup?: Markup // 连接桩的 DOM 结构定义。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
  attrs?: Registry.Attr.CellAttrs // 元素的属性样式。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
  zIndex?: number | 'auto' // 连接桩的 DOM 层级,值越大层级越高。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
  label?: {
    // 连接桩的标签。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
    markup?: Markup // 标签 DOM 结构
    position?: {
      // 标签位置
      name: string // 标签位置计算方法的名称
      args?: object // 标签位置计算方法的参数
    }
  }
}

interface PortGroup {
  in: string
  out: string
}

export default {
  in: {
    position: 'left',
    attrs: {
      circle: {
        magnet: true,
        stroke: INVALID_UI.stroke,
        fill: INVALID_UI.fill,
        r: 5
      }
    }
  },
  out: {
    position: 'right',
    attrs: {
      circle: {
        magnet: true,
        stroke: INVALID_UI.stroke,
        fill: INVALID_UI.fill,
        r: 5
      }
    }
  }
} as { [groupName in keyof PortGroup]: PortGroupMetadata }

给节点新增连接桩

  1. 新增port.config.ts

    import type { Registry, Markup } from '@antv/x6'
    import { INVALID_UI } from './ui.config'
    
    export interface PortGroupMetadata {
      markup?: Markup // 连接桩 DOM 结构定义。
      attrs?: Registry.Attr.CellAttrs // 属性和样式。
      zIndex?: number | 'auto' // 连接桩的 DOM 层级,值越大层级越高。
      // 群组中连接桩的布局。
      position?: [number, number] | string | { name: string; args?: object }
      label?: {
        // 连接桩标签
        markup?: Markup
        position?: {
          // 连接桩标签布局
          name: string // 布局名称
          args?: object // 布局参数
        }
      }
    }
    
    export interface PortMetadata {
      id?: string // 连接桩唯一 ID,默认自动生成。
      group?: string // 分组名称,指定分组后将继承分组中的连接桩选项。
      args?: object // 为群组中指定的连接桩布局算法提供参数, 我们不能为单个连接桩指定布局算法,但可以为群组中指定的布局算法提供不同的参数。
      markup?: Markup // 连接桩的 DOM 结构定义。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
      attrs?: Registry.Attr.CellAttrs // 元素的属性样式。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
      zIndex?: number | 'auto' // 连接桩的 DOM 层级,值越大层级越高。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
      label?: {
        // 连接桩的标签。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
        markup?: Markup // 标签 DOM 结构
        position?: {
          // 标签位置
          name: string // 标签位置计算方法的名称
          args?: object // 标签位置计算方法的参数
        }
      }
    }
    
    export interface PortGroup {
      in: string
      out: string
    }
    
    export type Subsequence<T extends any[], Prefix extends any[] = []> = T extends [
      infer F,
      ...infer R
    ]
      ? Subsequence<R, Prefix | [...Prefix, F]>
      : Prefix
    
    export default {
      in: {
        position: 'left',
        attrs: {
          circle: {
            magnet: true,
            stroke: INVALID_UI.stroke,
            fill: INVALID_UI.fill,
            r: 5
          }
        }
      },
      out: {
        position: 'right',
        attrs: {
          circle: {
            magnet: true,
            stroke: INVALID_UI.stroke,
            fill: INVALID_UI.fill,
            r: 5
          }
        }
      }
    } as { [groupName in keyof PortGroup]: PortGroupMetadata }
    
    
  2. 修改节点数据配置和生成函数 image.png

  3. 效果 image.png

多加几个节点

image.png

 // HomeView.vue
const handleMetadataDrag = (info: { e: DragEvent; data: any }) => {
  ;(info.e as any).dropEffect = 'move'
  const json = genCustomNode(info.data.componentKeys)
  const node = graph.createNode({
    id: String(Date.now()),
    shape: info.data.componentKeys,
    ...json
  })
  elDnD.start(node, info.e)
}

image.png

至此: 已经离我们的需求越来越近了 dev-tag 1.0.2

定制边

定制边

image.png

// edge.config.ts
import { Edge, Graph } from '@antv/x6'

export const CUSTOM_EDGE = 'custom-edge'

Edge.config({
  markup: [
    {
      tagName: 'path',
      selector: 'line',
      attrs: {
        fill: 'none'
      }
    },
    {
      tagName: 'path',
      selector: 'outline',
      attrs: {
        fill: 'none'
      }
    }
  ],
  connector: { name: 'smooth' },
  attrs: {
    line: {
      connection: true,
      stroke: '#8d98a4',
      strokeDasharray: 3,
      strokeWidth: 1,
      targetMarker: {
        name: 'block',
        size: 8,
        fill: '#fff'
      }
    },
    outlin: {
      connection: true,
      stroke: 'transparent',
      strokeWidth: 5
    }
  }
})

export default function registryEdge() {
  Graph.registerEdge(CUSTOM_EDGE, Edge)
}

可喜可贺:我们完成节点,连接桩,连线的自定义了,里程碑!【dev-tag 1.0.3】

设计业务数据与交互校验

接下来我们简单分析实现下业务数据的携带以及更多的交互设计

  1. 数据节点的弹窗交互【注册画布事件】

    image.png

  2. 连线校验

// 连线高亮
highlighting: {
  magnetAdsorbed: {
    name: 'stroke'
  },
  magnetAvailable: {
    name: 'stroke'
  }
},
connecting: {
  snap: { radius: 30 }, // 自动吸附范围
  allowBlank: false, // 允许连接空白
  allowLoop: false, // 允许连自己
  allowNode: false, // 直接连接节点
  highlight: true, // 开启高亮
  validateMagnet({ magnet }) {
    // 不允许 in 作为起点
    return magnet.getAttribute('port-group') !== 'in'
  },
  validateConnection({ sourceMagnet, targetMagnet }) {
    if (!sourceMagnet || sourceMagnet.getAttribute('port-group') === 'in') {
      return false
    }

    if (!targetMagnet || targetMagnet.getAttribute('port-group') !== 'in') {
      return false
    }

    return true
  },
  createEdge() {
    return graph.createEdge({ shape: CUSTOM_EDGE })
  }
}
  1. 业务数据
    • 在响应节点事件时,从节点取出data cell.data [getData]
    • 重新注入data, setData,坑点 注意对象结构的data,需要制定option的merge级别

业务data cell-Data

画布数据渲染与收集

  1. 提交阶段,收集所有节点的数据 graph.toJSON,处理节点与节点之间的 in & out 关系,形成一个链路
const payload = {
  draft: { // 画布数据
    version: '1.0',
    cells: [...], // 所有节点数据,包括node,edge
  },
  trigger: [
    {id: 'xxx0', in: [], out: ['xxx1'], props: {}},
    {id: 'xxx1', in: ['xxx0'], out: ['xxx2'], props: {}},
    {id: 'xxx2', in: ['xxx1'], out: [], props: {}},
  ] // 业务数据
}
  1. 回显阶段,在画布加载前拉取到应用数据,使用其中cells数组,渲染graph后调用 graph.fromJSON(cells)

  2. 节点交互设置,graph.on('node:mouseenter') + graph.on('node:mouseleave')

  3. 连线状态,节点的业务数据中携带 uiState [Boolean],初始化时都是 false,校验时机:连线+弹窗属性设置

应用方向

76ddfa65-8e27-437b-9b9e-805eaca77057.jpg

c60c7ee6-ca6a-4be9-b36d-62de992bca3d.jpg

d83220ad-dffd-4da5-8a60-404812c11b23.jpg

14759c6c-3362-4d90-86a1-fcc560a58778.jpg

e32ad24e-1c54-46cc-89f7-8964a13cdad5.jpg

  1. CEP
  2. 策略
  3. 机柜列+机柜+刀箱
  4. 关系拓扑
  5. 流程图
  6. 业务流转示意图等

总结

  1. 完全掌握并快速开发需求,需要时间去研究
  2. 好事多磨

资料链接