Group
This chapter mainly introduces knowledge related to grouping. By reading, you can learn about
- How to group nodes
- How to group nodes via interaction
- How to restrict child node movement in a group
- How to automatically expand the parent node
- How to expand and collapse the parent node
Group Nodes
You can build groups via parent–child relationships, with methods to get and set them.
import './index.less'
import { createRoot } from 'react-dom/client'
import React from 'react'
import { Graph } from '@antv/x6'
Graph.registerNode(
'custom-group-node',
{
inherit: 'rect',
width: 100,
height: 40,
attrs: {
body: {
stroke: '#8f8f8f',
strokeWidth: 1,
fill: '#fff',
rx: 6,
ry: 6,
},
},
},
true,
)
export default class Example extends React.Component {
private container: HTMLDivElement
componentDidMount() {
const graph = new Graph({
container: this.container,
background: {
color: '#F2F7FA',
},
})
const source = graph.addNode({
shape: 'custom-group-node',
x: 60,
y: 100,
label: 'Child\n(inner)',
zIndex: 2,
})
const target = graph.addNode({
shape: 'custom-group-node',
x: 420,
y: 80,
label: 'Child\n(outer)',
zIndex: 2,
})
const parent = graph.addNode({
shape: 'custom-group-node',
x: 40,
y: 40,
width: 360,
height: 160,
zIndex: 1,
label: 'Parent\n(try to move me)',
})
parent.addChild(source)
parent.addChild(target)
graph.addEdge({
source,
target,
vertices: [
{ x: 120, y: 60 },
{ x: 200, y: 100 },
],
attrs: {
line: {
stroke: '#8f8f8f',
strokeWidth: 1,
},
},
})
}
refContainer = (container: HTMLDivElement) => {
this.container = container
}
render() {
return (
<div className="embed-edge-app">
<div className="app-content" ref={this.refContainer} />
</div>
)
}
}
const root = createRoot(document.getElementById('container')!)
root.render(<Example />)
window.addEventListener('pagehide', () => root.unmount(), { once: true })
.embed-edge-app {
display: flex;
width: 100%;
border: 1px solid #f0f0f0;
padding: 16px 8px;
font-family: sans-serif;
.app-content {
flex: 1;
height: 240px;
margin-right: 8px;
margin-left: 8px;
box-shadow: 0 0 10px 1px #e9e9e9;
}
}From the example above:
- Moving the parent node also moves its children, even if they are outside.
- By default, the edge’s parent is the common parent of its terminals. Moving the parent moves the edge’s vertices.
Group via Interaction
Sometimes you need to drag one node into another to make it a child. Enable embedding and implement findParent to return the parent on move. See the API for details.
import './index.less'
import { createRoot } from 'react-dom/client'
import React from 'react'
import { Graph } from '@antv/x6'
Graph.registerNode(
'custom-node',
{
inherit: 'rect',
width: 100,
height: 40,
attrs: {
body: {
stroke: '#8f8f8f',
strokeWidth: 1,
fill: '#fff',
rx: 6,
ry: 6,
},
},
},
true,
)
export default class Example extends React.Component {
private container: HTMLDivElement
componentDidMount() {
const graph = new Graph({
container: this.container,
background: {
color: '#F2F7FA',
},
embedding: {
enabled: true,
findParent({ node }) {
const bbox = node.getBBox()
return this.getNodes().filter((node) => {
const data = node.getData<{ parent: boolean }>()
if (data && data.parent) {
const targetBBox = node.getBBox()
return bbox.isIntersectWithRect(targetBBox)
}
return false
})
},
},
})
graph.addNode({
shape: 'custom-node',
x: 40,
y: 140,
width: 120,
height: 60,
label: 'Child\n(unembed)',
zIndex: 10,
})
graph.addNode({
shape: 'custom-node',
x: 500,
y: 100,
width: 120,
height: 60,
label: 'Child\n(unembed)',
zIndex: 10,
})
graph.addNode({
shape: 'custom-node',
x: 200,
y: 80,
width: 240,
height: 160,
zIndex: 1,
label: 'Parent',
data: {
parent: true,
},
})
graph.on('node:change:parent', ({ node }) => {
node.attr({
label: {
text: 'Child\n(embed)',
},
})
})
}
refContainer = (container: HTMLDivElement) => {
this.container = container
}
render() {
return (
<div className="embedding-app">
<div className="app-content" ref={this.refContainer} />
</div>
)
}
}
const root = createRoot(document.getElementById('container')!)
root.render(<Example />)
window.addEventListener('pagehide', () => root.unmount(), { once: true })
.embedding-app {
display: flex;
width: 100%;
border: 1px solid #f0f0f0;
padding: 16px 8px;
font-family: sans-serif;
.app-content {
flex: 1;
height: 320px;
margin-right: 8px;
margin-left: 8px;
box-shadow: 0 0 10px 1px #e9e9e9;
}
}Restrict Child Movement
Limit the movement range of child nodes within the parent by using the translating.restrict option when creating the Graph instance.
import './index.less'
import { createRoot } from 'react-dom/client'
import React from 'react'
import { Graph } from '@antv/x6'
Graph.registerNode(
'custom-group-node',
{
inherit: 'rect',
width: 100,
height: 40,
attrs: {
body: {
stroke: '#8f8f8f',
strokeWidth: 1,
fill: '#fff',
rx: 6,
ry: 6,
},
},
},
true,
)
export default class Example extends React.Component {
private container: HTMLDivElement
componentDidMount() {
const graph = new Graph({
container: this.container,
background: {
color: '#F2F7FA',
},
translating: {
restrict(view) {
if (view) {
const cell = view.cell
if (cell.isNode()) {
const parent = cell.getParent()
if (parent) {
return parent.getBBox()
}
}
}
return null
},
},
})
const child = graph.addNode({
shape: 'custom-group-node',
x: 100,
y: 60,
label: 'Child',
zIndex: 2,
})
const parent = graph.addNode({
shape: 'custom-group-node',
x: 40,
y: 40,
width: 240,
height: 160,
zIndex: 1,
label: 'Parent\n(try to move me)',
})
parent.addChild(child)
}
refContainer = (container: HTMLDivElement) => {
this.container = container
}
render() {
return (
<div className="restrict-app">
<div className="app-content" ref={this.refContainer} />
</div>
)
}
}
const root = createRoot(document.getElementById('container')!)
root.render(<Example />)
window.addEventListener('pagehide', () => root.unmount(), { once: true })
.restrict-app {
display: flex;
width: 100%;
border: 1px solid #f0f0f0;
padding: 16px 8px;
font-family: sans-serif;
.app-content {
flex: 1;
height: 240px;
margin-right: 8px;
margin-left: 8px;
box-shadow: 0 0 10px 1px #e9e9e9;
}
}Auto-expand Parent Node
Listen to node:change:position to automatically expand/shrink the parent when a child moves, ensuring it fully encompasses the children. The example code is a bit involved; click the demo’s CodeSandbox link to view the full implementation.
import './index.less'
import { createRoot } from 'react-dom/client'
import React from 'react'
import { Graph, Dom } from '@antv/x6'
import { Settings, State } from './settings.tsx'
Graph.registerNode(
'custom-group-node',
{
inherit: 'rect',
width: 80,
height: 40,
attrs: {
body: {
stroke: '#8f8f8f',
strokeWidth: 1,
fill: '#fff',
rx: 6,
ry: 6,
},
},
},
true,
)
export default class Example extends React.Component {
private container: HTMLDivElement
private embedPadding = 20
componentDidMount() {
const graph = new Graph({
container: this.container,
background: {
color: '#F2F7FA',
},
embedding: {
enabled: true,
},
})
const source = graph.addNode({
shape: 'custom-group-node',
x: 80,
y: 100,
width: 80,
height: 40,
label: 'Child',
zIndex: 2,
})
const target = graph.addNode({
shape: 'custom-group-node',
x: 280,
y: 80,
label: 'Child',
zIndex: 2,
})
const parent = graph.addNode({
shape: 'custom-group-node',
x: 40,
y: 40,
width: 360,
height: 160,
zIndex: 1,
label: 'Parent',
})
parent.addChild(source)
parent.addChild(target)
let ctrlPressed = false
graph.on('node:embedding', ({ e }: { e: Dom.MouseMoveEvent }) => {
ctrlPressed = e.metaKey || e.ctrlKey
})
graph.on('node:embedded', () => {
ctrlPressed = false
})
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 || ctrlPressed) {
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) {
originSize = parent.getSize()
parent.prop('originSize', originSize)
}
let originPosition = parent.prop('originPosition')
if (originPosition == null) {
originPosition = parent.getPosition()
parent.prop('originPosition', 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().inflate(this.embedPadding)
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 },
)
}
}
})
}
refContainer = (container: HTMLDivElement) => {
this.container = container
}
onSettingsChanged = (state: State) => {
this.embedPadding = state.padding
}
render() {
return (
<div className="expand-shrink-app">
<div className="app-side">
<Settings onChange={this.onSettingsChanged} />
</div>
<div className="app-content" ref={this.refContainer} />
</div>
)
}
}
const root = createRoot(document.getElementById('container')!)
root.render(<Example />)
window.addEventListener('pagehide', () => root.unmount(), { once: true })
.expand-shrink-app {
display: flex;
border: 1px solid #f0f0f0;
padding: 16px 8px;
font-family: sans-serif;
.app-side {
bottom: 0;
padding: 0 8px;
}
.app-content {
flex: 1;
height: 240px;
margin-right: 8px;
margin-left: 8px;
box-shadow: 0 0 10px 1px #e9e9e9;
}
.slider-value {
display: inline-block;
margin-left: 8px;
padding: 3px 7px;
color: #333;
font-size: 12px;
line-height: 1.25;
background: #eee;
border-radius: 10px;
}
.ant-card {
box-shadow: 0 0 10px 1px #e9e9e9;
}
.ant-card-head-title {
text-align: center;
}
.ant-row {
margin: 16px 0;
text-align: left;
}
}import React from 'react'
import { Card, Row, Col, Slider } from 'antd'
export interface Props {
onChange: (res: State) => void
}
export interface State {
padding: number
}
export class Settings extends React.Component<Props, State> {
state: State = {
padding: 20,
}
notifyChange() {
this.props.onChange({
...this.state,
})
}
onPaddingChanged = (padding: number) => {
this.setState({ padding }, () => {
this.notifyChange()
})
}
render() {
return (
<Card
title="Settings"
size="small"
bordered={false}
style={{ width: 320 }}
>
<Row align="middle">
<Col span={6}>Padding</Col>
<Col span={12}>
<Slider
min={0}
max={40}
step={1}
value={this.state.padding}
onChange={this.onPaddingChanged}
/>
</Col>
<Col span={1} offset={1}>
<div className="slider-value">{this.state.padding}</div>
</Col>
</Row>
<Row align="middle" style={{ borderTop: '1px solid #f0f0f0' }}>
<Col style={{ padding: '24px 0 8px 0', color: '#666' }}>
Press and hold on{' '}
<strong style={{ color: '#faad14' }}>Ctrl or Command</strong> key,
then move the child node to remove it from parent node.
</Col>
</Row>
</Card>
)
}
}Expand/Collapse Parent Node
First, define a custom Group node that renders an expand/collapse button at the top-left corner and sets a custom event node:collapse on that button:
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: {
...
// Custom event
event: 'node:collapse',
},
buttonSign: { ... },
},
})
Then listen for node:collapse on the graph, and show/hide the corresponding child nodes when the parent node is expanded/collapsed:
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()
}
})
})
import './index.less'
import { createRoot } from 'react-dom/client'
import React from 'react'
import { Graph } from '@antv/x6'
import { Group } from './shape.ts'
Graph.registerNode(
'custom-group-node',
{
inherit: 'rect',
width: 100,
height: 40,
attrs: {
body: {
stroke: '#8f8f8f',
strokeWidth: 1,
fill: '#fff',
rx: 6,
ry: 6,
},
},
},
true,
)
export default class Example extends React.Component {
private container: HTMLDivElement
componentDidMount() {
const graph = new Graph({
container: this.container,
background: {
color: '#F2F7FA',
},
})
const createGroup = (
id: string,
x: number,
y: number,
width: number,
height: number,
fill: string,
) => {
const group = new Group({
id,
x,
y,
width,
height,
attrs: {
body: { fill },
label: { text: id },
},
})
graph.addNode(group)
return group
}
const createNode = (
id: string,
x: number,
y: number,
width: number,
height: number,
) => {
return graph.addNode({
shape: 'custom-group-node',
id,
x,
y,
width,
height,
label: id,
})
}
const createEdge = (
id: string,
source: string,
target: string,
vertices?: { x: number; y: number }[],
) => {
return graph.addEdge({
id,
source,
target,
vertices,
label: id,
attrs: {
line: {
stroke: '#8f8f8f',
strokeWidth: 1,
},
},
})
}
const a = createGroup('a', 100, 40, 480, 280, '#91d5ff')
const aa = createGroup('aa', 180, 100, 160, 140, '#47C769')
const aaa = createGroup('aaa', 200, 160, 120, 40, '#0491e4')
const b = createNode('b', 450, 200, 50, 50)
a.addChild(aa)
aa.addChild(aaa)
a.addChild(b)
createNode('c', 680, 80, 50, 50)
createEdge('edge1', 'aa', 'b')
createEdge('edge3', 'b', 'c')
aa.addChild(
createEdge('edge2', 'aa', 'aaa', [
{ x: 60, y: 140 },
{ x: 60, y: 220 },
]),
)
graph.on('node:collapse', ({ node }: { node: Group }) => {
node.toggleCollapse()
const collapsed = node.isCollapsed()
const collapse = (parent: Group) => {
const cells = parent.getChildren()
if (cells) {
cells.forEach((cell) => {
if (collapsed) {
cell.hide()
} else {
cell.show()
}
if (cell instanceof Group) {
if (!cell.isCollapsed()) {
collapse(cell)
}
}
})
}
}
collapse(node)
})
}
refContainer = (container: HTMLDivElement) => {
this.container = container
}
render() {
return (
<div className="collapsable-app">
<div className="app-content" ref={this.refContainer} />
</div>
)
}
}
const root = createRoot(document.getElementById('container')!)
root.render(<Example />)
window.addEventListener('pagehide', () => root.unmount(), { once: true })
.collapsable-app {
display: flex;
width: 100%;
border: 1px solid #f0f0f0;
padding: 16px 8px;
font-family: sans-serif;
.app-content {
flex: 1;
height: 360px;
margin-right: 8px;
margin-left: 8px;
box-shadow: 0 0 10px 1px #e9e9e9;
}
}import { Node } from '@antv/x6'
export class Group extends Node {
private collapsed = 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',
attrs: {
'pointer-events': 'visiblePainted',
},
},
{
tagName: 'path',
selector: 'buttonSign',
attrs: {
fill: 'none',
'pointer-events': 'none',
},
},
],
},
],
attrs: {
body: {
refWidth: '100%',
refHeight: '100%',
stroke: 'none',
fill: '#fff',
},
buttonGroup: {
refX: 8,
refY: 8,
},
button: {
height: 14,
width: 16,
rx: 2,
ry: 2,
fill: '#f5f5f5',
stroke: '#ccc',
cursor: 'pointer',
event: 'node:collapse',
},
buttonSign: {
refX: 3,
refY: 2,
stroke: '#808080',
},
label: {
fontSize: 12,
fill: '#fff',
refX: 32,
refY: 10,
},
},
})