群组 Group

阅读时间约 4 分钟

基础嵌套

我们可以通过父子嵌套来实现群组,并提供了一系列获取和设置嵌套关系的方法

const child = graph.addNode({
  x: 120,
  y: 80,
  width: 120,
  height: 60,
  zIndex: 10,
  label: 'Child\n(embedded)',
  attrs: {
    body: {
      fill: 'green',
    },
    label: {
      fill: '#fff',
    },
  },
})

const parent = graph.addNode({
  x: 80,
  y: 40,
  width: 320,
  height: 240,
  zIndex: 1,
  label: 'Parent\n(try to move me)',
})

parent.addChild(child)

需要注意的是,子节点的位置是相对画布左上角的位置,我们并没有提供相对父节点的相对定位方法。在上面例子中,我们通过绝对定位和 zIndex 使子节点看起来像位于父节点内部一样,当移动父节点时子节点也会跟着移动。实际上,即便子节点位于父节点外部,移动父节点时子节点也将跟着移动。

创建边时,默认将起始节点和终止节点的共同父节点作为边的父节点。移动父节点时,边的路径点将跟随移动。

parent.addChild(source)
parent.addChild(target)

graph.addEdge({
  source,
  target,
  vertices: [
    { x: 120, y: 60 },
    { x: 200, y: 100 },
  ],
})

通过交互创建嵌套

有时候我们需要将一个节点拖动到另一个节点中,使其成为另一节点的子节点,这时我们可以通过 embedding 选项来开启,在节点被移动时通过 findParent 指定的方法返回父节点。

new Graph({
  embedding: {
    enabled: true,
    findParent({ node }) {
      const bbox = node.getBBox()
      return this.getNodes().filter((node) => {
        // 只有 data.parent 为 true 的节点才是父节点
        const data = node.getData<any>()
        if (data && data.parent) {
          const targetBBox = node.getBBox()
          return bbox.isIntersectWithRect(targetBBox)
        }
        return false
      })
    }
  }
})

限制子节点的移动

有时候需要将子节点的移动范围限制在父节点内,可以在创建 Graph 实例时通过 translating.restrict 选项来实现。

const graph = new Graph({
  container: this.container,
  translating: {
    restrict(view) {
      const cell = view.cell
      if (cell.isNode()) {
        const parent = cell.getParent()
        if (parent) {
          return parent.getBBox()
        }
      }

      return null
    },
  },
})

自动扩展父节点

通过监听 node:change:position 事件,当节点移动时自动扩展/收缩父节点的大小,使父节点完全包围住子节点。

graph.on('node:change:size', ({ node, options }) => {
  if (options.skipParentHandler) {
    return
  }

  const children = node.getChildren()
  if (children && children.length) {
    node.prop('originSize', node.getSize())
  }
})

graph.on('node:change:position', ({ node, options }) => {
  if (options.skipParentHandler) {
    return
  }

  const children = node.getChildren()
  if (children && children.length) {
    node.prop('originPosition', node.getPosition())
  }

  const parent = node.getParent()
  if (parent && parent.isNode()) {
    let originSize = parent.prop('originSize')
    if (originSize == null) {
      parent.prop('originSize', parent.getSize())
    }
    originSize = parent.prop('originSize')

    let originPosition = parent.prop('originPosition')
    if (originPosition == null) {
      parent.prop('originPosition', parent.getPosition())
    }
    originPosition = parent.prop('originPosition')

    let x = originPosition.x
    let y = originPosition.y
    let cornerX = originPosition.x + originSize.width
    let cornerY = originPosition.y + originSize.height
    let hasChange = false

    const children = parent.getChildren()
    if (children) {
      children.forEach((child) => {
        const bbox = child.getBBox()
        const corner = bbox.getCorner()

        if (bbox.x < x) {
          x = bbox.x
          hasChange = true
        }

        if (bbox.y < y) {
          y = bbox.y
          hasChange = true
        }

        if (corner.x > cornerX) {
          cornerX = corner.x
          hasChange = true
        }

        if (corner.y > cornerY) {
          cornerY = corner.y
          hasChange = true
        }
      })
    }


    if (hasChange) {
      parent.prop(
        {
          position: { x, y },
          size: { width: cornerX - x, height: cornerY - y },
        },
        // Note that we also pass a flag so that we know we shouldn't 
        // adjust the `originPosition` and `originSize` in our handlers.
        { skipParentHandler: true },
      )
    }
  }
})

展开/折叠父节点

首先,我们自定义了一个 Group 节点,该节点的左上角渲染了一个展开/折叠按钮,并在该按钮上设置了自定义事件 'node:collapse'

import { Node } from '@antv/x6'

export class Group extends Node {
  private collapsed: boolean = false
  private expandSize: { width: number; height: number }

  protected postprocess() {
    this.toggleCollapse(false)
  }

  isCollapsed() {
    return this.collapsed
  }

  toggleCollapse(collapsed?: boolean) {
    const target = collapsed == null ? !this.collapsed : collapsed
    if (target) {
      this.attr('buttonSign', { d: 'M 1 5 9 5 M 5 1 5 9' })
      this.expandSize = this.getSize()
      this.resize(100, 32)
    } else {
      this.attr('buttonSign', { d: 'M 2 5 8 5' })
      if (this.expandSize) {
        this.resize(this.expandSize.width, this.expandSize.height)
      }
    }
    this.collapsed = target
  }
}

Group.config({
  markup: [
    {
      tagName: 'rect',
      selector: 'body',
    },
    {
      tagName: 'text',
      selector: 'label',
    },
    {
      tagName: 'g',
      selector: 'buttonGroup',
      children: [
        {
          tagName: 'rect',
          selector: 'button',
        },
        {
          tagName: 'path',
          selector: 'buttonSign',
        },
      ],
    },
  ],
  attrs: {
    body: { ... },
    label: { ... },
    buttonGroup: { ... },
    button: {
      ...
      // 自定义事件
      event: 'node:collapse',
    },
    buttonSign: { ... },
  },
})

然后,在 graph 上监听 node:collapse 事件,当父节点展开/折叠时显示/隐藏对应的子节点:

graph.on('node:collapse', ({ node }: { node: Group }) => {
  node.toggleCollapse()
  const collapsed = node.isCollapsed()
  const cells = node.getDescendants()
  cells.forEach((node) => {
    if (collapsed) {
      node.hide()
    } else {
      node.show()
    }
  })
})