Skip to content

Antv x6 封装使用

shell
pnpm add @antv/x6 @antv/layout @antv/x6-vue-shape @antv/x6-plugin-clipboard @antv/x6-plugin-export @antv/x6-plugin-history @antv/x6-plugin-keyboard @antv/x6-plugin-minimap @antv/x6-plugin-scroller @antv/x6-plugin-selection @antv/x6-plugin-snapline @antv/x6-plugin-stencil @antv/x6-plugin-transform

下载封装资源

查看封装代码
vue
<template>
  <div class="x6-edit">
    <IntervalContainer align="middle">
      <a-button type="primary" size="small" :disabled="!historyInfo.canUndo" @click="graph?.undo()">
        后退
      </a-button>
      <a-button type="primary" size="small" :disabled="!historyInfo.canRedo" @click="graph?.redo()">
        前进
      </a-button>
      <a-button
        ghost
        danger
        :disabled="!graph?.getCellCount()"
        size="small"
        @click="clearGraphData"
      >
        清空
      </a-button>
    </IntervalContainer>
    <div class="mt-2 mb-2">
      快捷键:
      <a-typography-text keyboard>Ctrl + C (复制)</a-typography-text>
      <a-typography-text keyboard>Ctrl + V (粘贴)</a-typography-text>
      <a-typography-text keyboard>Ctrl + Z (撤销)</a-typography-text>
      <a-typography-text keyboard>Delete 或 Backspace(删除)</a-typography-text>
      <a-typography-text keyboard>节点上右键(菜单)</a-typography-text>
      <a-typography-text keyboard>单击或框选(选中)</a-typography-text>
    </div>

    <a-row :gutter="8" class="content-container">
      <a-col :span="20">
        <div class="content" @contextmenu="e => e.preventDefault()">
          <div class="x6-stencil-container" />

          <div class="x6-box">
            <div id="x6-container" />
            <TeleportContainer />
          </div>
          <div class="x6-minimap-container" />
        </div>
      </a-col>
      <a-col :span="4">
        <a-card class="attr-card" size="small" title="属性">
          <X6EditForm ref="x6EditFormRef" />
        </a-card>
      </a-col>
    </a-row>
  </div>
</template>
<script lang="ts" setup>
import { Graph } from '@antv/x6'
import { onMounted, reactive, ref, shallowRef } from 'vue'
import { TeleportContainer, registerGraphEvent, registerGraphPlugin } from '../'
import X6EditForm from './X6EditForm.vue'
import { graphOptions, registerEvents, registerStencil } from './x6-edit-helper'

const historyInfo = reactive({ canRedo: false, canUndo: false })
const graph = shallowRef<Graph>()

const x6EditFormRef = ref<InstanceType<typeof X6EditForm>>()

function initGraph() {
  const container = document.querySelector('#x6-container') as HTMLDivElement
  const graph = new Graph({
    container,
    ...graphOptions
  })
  registerGraphEvent(graph)
  registerGraphPlugin(graph)
  registerStencil(graph)
  registerEvents(graph)

  graph.on('history:change', () => {
    const currentSelects = graph.getSelectedCells()
    if (currentSelects.length === 1) {
      x6EditFormRef.value?.setCell(currentSelects[0])
    } else {
      x6EditFormRef.value?.setCell(undefined)
    }
    historyInfo.canRedo = graph.canRedo()
    historyInfo.canUndo = graph.canUndo()
  })

  graph.on('selection:changed', ({ selected }) => {
    if (selected.length === 1) {
      x6EditFormRef.value?.setCell(selected[0])
    } else {
      x6EditFormRef.value?.setCell(undefined)
    }
  })
  return graph
}

function clearGraphData() {
  graph.value?.clearCells()
}

/**
 * 设置画布的数据
 * @param data 数据
 * @param mode 设置模式,add: 添加,cover: 覆盖
 */
function setGraphData(data: any, mode: 'add' | 'cover' = 'cover') {
  if (mode === 'cover') {
    // graph.value?.fromJSON(data) // 直接这样加载数据会应用不上自定义的内容
    graph.value?.clearCells()
  }
  const { cells = [] } = data
  if (cells && cells.length) {
    graph.value?.startBatch('add-datas')
    graph.value?.cleanSelection()
    cells.forEach((info: any) => {
      const cell =
        info.shape === 'edge' ? graph.value?.createEdge(info) : graph.value?.createNode(info)
      if (cell) {
        graph.value?.addCell(cell)
      }
    })
    graph.value?.stopBatch('add-datas')
  }
}

function getGraphData() {
  const data = graph.value?.toJSON()
  data?.cells.forEach(cell => {
    Reflect.deleteProperty(cell, 'disableTransform')
    Reflect.deleteProperty(cell, 'disableSelect')
    Reflect.deleteProperty(cell, 'disableMove')
    Reflect.deleteProperty(cell, 'disableProts')
  })
  return data
}

defineExpose({
  setGraphData,
  getGraphData
})
onMounted(() => {
  graph.value = initGraph()
})
</script>
<style lang="less" scoped>
.x6-edit {
  width: 100%;
  .content-container {
    height: calc(100% - 60px);
    & > .ant-col {
      max-height: 100%;
    }
  }
  .attr-card {
    height: 100%;
    overflow: auto;
    box-shadow: 0px 4px 8px 0px #ededf4;
  }
  .content {
    height: 100%;
    position: relative;
    display: flex;
    .x6-stencil-container {
      position: relative;
      height: 100%;
      min-width: 200px;
      width: 200px;
    }
    .x6-minimap-container {
      position: absolute;
      bottom: 24px;
      right: 24px;
    }
    .x6-box {
      width: calc(100% - 200px);
      height: 100%;
      margin-left: 4px;
      :deep(.icon-envelope) {
        opacity: 0;
        position: absolute;
        width: 48px;
        font-size: 48px;
        left: 0;
        top: 0;
        color: var(--color-primary);
      }
      :deep(.x6-widget-selection-box) {
        border: 2px dashed var(--color-primary);
      }

      :deep(.x6-widget-selection-inner) {
        border: 1px solid var(--color-primary);
      }
    }
  }

  .x6-tooltip-helper {
    position: fixed;
    opacity: 0;
    z-index: -1;
    left: 0;
    top: 0;
  }
  :global(.x6-tooltip-helper-form) {
    width: 200px;
  }
  :global(.x6-tooltip-helper-form .ant-form-item) {
    margin-bottom: 0;
  }

  // 左侧文字超出省略
  :global(.x6-widget-stencil-content div.image-node div.label div) {
    max-width: 80px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
  }
}
</style>
ts
import type { Edge, Graph } from '@antv/x6'
import { Stencil } from '@antv/x6-plugin-stencil'
import {
  addPortsForDragNode,
  createImageNode as originalCreateImageNode,
  type GraphOptions
} from '../'
import type { ImageNodeOptionI } from '../constants'
import { createBorderNode, createTextNode } from '../create'
export const nodePortOptions = {
  ports: {
    groups: {
      left: {
        position: 'left',
        attrs: {
          circle: {
            magnet: true,
            stroke: '#8f8f8f',
            r: 4
          }
        },
        label: {
          position: 'left'
        }
      },

      top: {
        position: 'top',
        attrs: {
          circle: {
            magnet: true,
            stroke: '#8f8f8f',
            r: 4
          }
        },
        label: {
          position: 'top'
        }
      },
      right: {
        position: 'right',
        attrs: {
          circle: {
            magnet: true,
            stroke: '#8f8f8f',
            r: 4
          }
        },
        label: {
          position: 'right'
        }
      },
      bottom: {
        position: 'bottom',
        attrs: {
          circle: {
            magnet: true,
            stroke: '#8f8f8f',
            r: 4
          }
        },
        label: {
          position: 'top'
        }
      }
    }
  }
}

export const createImageNode = (options: ImageNodeOptionI) =>
  originalCreateImageNode({ ...nodePortOptions, isHolder: true, ...options })

/** 普通节点 */
const generalTypes = ['node', 'container']
const needIpTypes = ['network', 'route']
export const graphOptions = {
  autoResize: true,
  background: {
    // color: '#F2F7FA'
  },
  mousewheel: {
    enabled: true,
    modifiers: ['ctrl']
  },

  // virtual: true,
  grid: {
    visible: true,
    type: 'doubleMesh',
    args: [
      {
        color: '#eee', // 主网格线颜色
        thickness: 1 // 主网格线宽度
      },
      {
        color: '#ddd', // 次网格线颜色
        thickness: 1, // 次网格线宽度
        factor: 4 // 主次网格线间隔
      }
    ]
  },
  // 连线
  connecting: {
    // connectionPoint: {
    //   name: 'bbox',
    //   args: {
    //     sticky: true
    //   }
    // },

    // 吸附
    snap: true,
    allowBlank: false,
    allowLoop: false,
    allowEdge: false,
    allowNode: false,
    allowPort({ sourceCell, targetCell }) {
      const sourceType = sourceCell?.getProp('type')
      const targetType = targetCell?.getProp('type')
      // 节点和节点之间不能互相连接
      if (generalTypes.includes(sourceType) && generalTypes.includes(targetType)) {
        return false
      }
      // 当是流量告警设备时,不能和普通节点连接
      if (
        (sourceType === 'device' && targetType === 'device') ||
        (sourceType === 'device' && generalTypes.includes(targetType)) ||
        (targetType === 'device' && generalTypes.includes(sourceType))
      ) {
        return false
      }

      // 当是物理网络时,只能和节点或者容器连接
      if (
        (sourceType === 'physics' && targetType === 'physics') ||
        (sourceType === 'physics' && !generalTypes.includes(targetType)) ||
        (targetType === 'physics' && !generalTypes.includes(sourceType))
      ) {
        return false
      }

      return true
    },
    highlight: true
  }
} as Partial<GraphOptions.Manual>

export function registerStencil(graph: Graph) {
  const stencil = new Stencil({
    title: '元素',
    target: graph,
    stencilGraphWidth: 200,
    stencilGraphHeight: 0,
    stencilGraphPadding: 20,
    groups: [
      // { title: '默认', name: 'default' },
      { title: '文本', name: 'text' },
      { title: '节点', name: 'node' }
    ],
    getDropNode(_) {
      const node = _.clone()
      node.prop('isHolder', null)
      if (node.shape === 'border-node') {
        node.prop('size/width', 200)
        node.prop('size/height', 160)
      }
      if (node.shape === 'image-node') {
        addPortsForDragNode(node)
      }
      const type = node.getProp('type')
      const label = node.getProp('label')

      // 判断同一种的话后面个数滚动
      const count = graph
        .getNodes()
        .filter(
          n =>
            n.getProp('type') === type && n.getProp('label').split('_')[0] === node.getProp('label')
        ).length
      node.setProp('label', `${label}_${count + 1}`)

      return node
    }
  })
  // 需要一个容纳 stencil 的 Dom 容器 stencilContainer
  const stencilContainer = document.querySelector('.x6-stencil-container') as HTMLDivElement

  stencilContainer.appendChild(stencil.container)
  const text = graph.createNode(createTextNode({ label: '文本', isHolder: true }))
  const border = graph.createNode(
    createBorderNode({ label: '边框', width: 48, height: 48, isHolder: true })
  )
  stencil.load([text, border], 'text')
  stencil.load(
    [
      graph.createNode(createImageNode({ label: '节点', type: 'node' })),
      graph.createNode(createImageNode({ label: '设备', type: 'device' })),
      graph.createNode(createImageNode({ label: '交换机', type: 'network' })),
      graph.createNode(createImageNode({ label: '路由器', type: 'route' }))
    ],
    'node'
  )
  return stencil
}

/**
 * 检查和设置连线的data
 * @param edge
 */
export function checkEdgeData(edge: Edge<Edge.Properties>) {
  const { line: { stroke = '#333333', strokeWidth = 2 } = {} } = edge.getProp('attrs') || {}

  const data = edge.getData() // 如果不在新增时设置,此项是undefined,考虑撤销功能或者导入功能
  if (!data) {
    edge.setData({
      stroke,
      strokeWidth
    } as DragView.EdgeData)
  } else {
    edge.setData(data)
  }
}

export function registerEvents(graph: Graph) {
  // 撤销重做时重新添加或初始添加时需要把showPorts的逻辑加上
  graph.on('cell:added', ({ cell }) => {
    if (cell.isEdge()) {
      cell.prop('attrs/line/targetMarker', null)
      checkEdgeData(cell)
    }
  })

  // 连接时添加ip占位字段
  graph.on('edge:connected', ({ edge }) => {
    const targetNode = edge.getTargetNode()
    const targetType = targetNode?.getProp('type')
    const sourceNode = edge.getSourceNode()
    const sourceType = sourceNode?.getProp('type')
    // 如果两端的某一端是交换机或路由,另一端是节点或容器
    if (needIpTypes.includes(targetType) && generalTypes.includes(sourceType)) {
      const data = sourceNode?.getData() || {}
      const ipInfos = data.ipInfos ? [...data.ipInfos] : []
      ipInfos.push({
        // 目标交换机或路由的id
        target: targetNode?.id,
        label: targetNode?.getProp('label'),
        ip: '',
        autoIp: true
      })
      sourceNode?.setData({ ...data, ipInfos })
    }

    if (needIpTypes.includes(sourceType) && generalTypes.includes(targetType)) {
      const data = targetNode?.getData() || {}
      const ipInfos = data.ipInfos ? [...data.ipInfos] : []
      ipInfos.push({
        target: sourceNode?.id,
        label: sourceNode?.getProp('label'),
        ip: '',
        autoIp: true
      })
      targetNode?.setData({ ...data, ipInfos })
    }
  })
  // 删除时移除ip占位字段
  graph.on('edge:removed', ({ cell }) => {
    if (cell.isEdge()) {
      // @ts-ignore
      const targetNodeId = cell.target.cell || ''
      const targetNode = graph.getCellById(targetNodeId)
      const targetType = targetNode?.getProp('type')
      // @ts-ignore
      const sourceNodeId = cell.source.cell || ''
      const sourceNode = graph.getCellById(sourceNodeId)
      const sourceType = sourceNode?.getProp('type')
      // 如果两端的某一端是交换机或路由,另一端是节点或容器
      if (needIpTypes.includes(targetType) && generalTypes.includes(sourceType)) {
        const data = sourceNode?.getData() || {}
        const ipInfos = [...data.ipInfos]
        const index = ipInfos.findIndex(item => item.target === targetNode?.id)
        if (index != -1) {
          ipInfos.splice(index, 1)
        }
        sourceNode?.replaceData({ ...data, ipInfos })
      }

      if (needIpTypes.includes(sourceType) && generalTypes.includes(targetType)) {
        const data = targetNode?.getData() || {}
        const ipInfos = [...data.ipInfos]
        const index = ipInfos.findIndex(item => item.target === sourceNode?.id)
        if (index != -1) {
          ipInfos.splice(index, 1)
        }
        targetNode?.replaceData({ ...data, ipInfos })
      }
    }
  })

  graph.on('edge:change:data', ({ edge, current }) => {
    const { stroke, strokeWidth } = current as DragView.EdgeData
    edge.prop('attrs/line', { stroke, strokeWidth })
  })
}

基本使用

TIP

本例中不知何原因点击缩起会导致不显示,但在平时项目中不影响

快捷键: Ctrl + C (复制)Ctrl + V (粘贴)Ctrl + Z (撤销)Delete 或 Backspace(删除)节点上右键(菜单)单击或框选(选中)
属性
查看代码
vue
<template>
  <div>
    <a-button type="primary" @click="logData">控制台打印数据</a-button>
    <X6Edit class="mt h-lg" ref="x6EditRef" />
  </div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import antvPlugin from './assets'
import X6Edit from './assets/usage/X6Edit.vue'

antvPlugin.install() // 在进入页面之前安装插件

const x6EditRef = ref<InstanceType<typeof X6Edit>>()
const logData = () => {
  const data = x6EditRef.value?.getGraphData()
  console.log('[ data ]', data)
}
</script>