轮廓包围 Hull
概述
轮廓包围(Hull)用于处理和表示一组点的凸多边形或凹多边形包围盒。它可以将一组节点包裹在一个最小的几何形状中,帮助用户更好地理解和分析数据集。
- 凸包(Convex Hull):这是一个凸多边形,它包含所有的点,并且没有任何凹陷。
- 凹包(Concave Hull):这是一个凹多边形,它同样包含所有的点,但是可能会有凹陷。凹包的凹陷程度由 concavity 参数控制。
使用场景
轮廓包围插件主要适用于以下场景:
- 数据可视化中的节点集合包裹
- 提供视觉参考,增强空间感知
- 在复杂网络图中标识特定节点的集合关系
基本用法
以下是一个简单的 Hull 插件初始化示例:
const graph = new Graph({
plugins: [
{
type: 'hull',
key: 'my-hull', // 指定唯一标识符,便于后续动态更新
members: ['node-1', 'node-2'], // 需要包裹的节点 ID 列表
concavity: Infinity, // 默认为凸包
},
],
});
在线体验
import { createGraph } from '/demo-runtime.ts';
createGraph(
{
data: {
nodes: [
{
id: 'node-0',
data: { cluster: 'a' },
style: { x: 555, y: 151 },
},
{
id: 'node-1',
data: { cluster: 'a' },
style: { x: 532, y: 323 },
},
{
id: 'node-2',
data: { cluster: 'a' },
style: { x: 473, y: 227 },
},
{
id: 'node-3',
data: { cluster: 'a' },
style: { x: 349, y: 212 },
},
{
id: 'node-4',
data: { cluster: 'b' },
style: { x: 234, y: 201 },
},
{
id: 'node-5',
data: { cluster: 'b' },
style: { x: 338, y: 333 },
},
{
id: 'node-6',
data: { cluster: 'b' },
style: { x: 365, y: 91 },
},
],
edges: [
{
source: 'node-0',
target: 'node-2',
},
{
source: 'node-1',
target: 'node-2',
},
{
source: 'node-2',
target: 'node-3',
},
{
source: 'node-3',
target: 'node-4',
},
{
source: 'node-3',
target: 'node-5',
},
{
source: 'node-3',
target: 'node-6',
},
],
},
node: {
style: { labelText: (d) => d.id },
palette: { field: 'cluster', color: ['#7e3feb', '#ffa940'] },
},
behaviors: ['drag-canvas', 'drag-element'],
plugins: [
'grid-line',
{
type: 'hull',
key: 'hull-a',
members: ['node-0', 'node-1', 'node-2', 'node-3'],
labelText: 'hull-a',
fill: '#7e3feb',
stroke: '#7e3feb',
fillOpacity: 0.1,
strokeOpacity: 1,
labelFill: '#fff',
labelPadding: 2,
labelBackgroundFill: '#7e3feb',
labelBackgroundRadius: 5,
},
],
},
{ width: 600, height: 450 },
(gui, graph) => {
const options = {
type: 'hull',
members: ['node-0', 'node-1', 'node-2', 'node-3'],
concavity: Infinity,
corner: 'rounded',
padding: 10,
// style
fill: '#7e3feb',
stroke: '#7e3feb',
fillOpacity: 0.1,
strokeOpacity: 1,
// label
label: true,
labelCloseToPath: true,
labelAutoRotate: true,
labelOffsetX: 0,
labelOffsetY: 0,
labelPlacement: 'bottom',
};
const optionFolder = gui.addFolder('Hull Options');
optionFolder.add(options, 'type').disable();
optionFolder.add(options, 'concavity', 0, 200, 1);
optionFolder.add(options, 'corner', ['rounded', 'smooth', 'sharp']);
optionFolder.add(options, 'padding', 0, 20, 1);
optionFolder.addColor(options, 'fill');
optionFolder.addColor(options, 'stroke');
optionFolder.add(options, 'fillOpacity', 0, 1, 0.1);
optionFolder.add(options, 'strokeOpacity', 0, 1, 0.1);
optionFolder.add(options, 'label');
optionFolder.add(options, 'labelCloseToPath');
optionFolder.add(options, 'labelAutoRotate');
optionFolder.add(options, 'labelOffsetX', 0, 20, 1);
optionFolder.add(options, 'labelOffsetY', 0, 20, 1);
optionFolder.add(options, 'labelPlacement', ['left', 'right', 'top', 'bottom', 'center']);
optionFolder.onChange(({ property, value }) => {
graph.updatePlugin({
key: 'hull-a',
[property]: value,
});
graph.render();
});
const apiConfig = {
member: 'node-1',
};
const apiFolder = gui.addFolder('Hull API');
const instance = graph.getPluginInstance('hull-a');
apiFolder.add(
apiConfig,
'member',
new Array(7).fill(0).map((_, index) => `node-${index}`),
);
apiFolder.add({ addMember: () => instance.addMember(apiConfig.member) }, 'addMember').name('add member');
apiFolder
.add({ removeMember: () => instance.removeMember(apiConfig.member) }, 'removeMember')
.name('remove member');
apiFolder
.add({ removeMember: () => alert('Members in Hull-a: ' + instance.getMember()) }, 'removeMember')
.name('get member');
},
);
import { Graph } from '@antv/g6';
import GUI from 'lil-gui';
export function addPanel(renderPanel: (gui: GUI) => void) {
const gui = new GUI({ container: document.body });
gui.title('Control');
Object.assign(gui.domElement.style, { position: 'absolute', top: '0', right: '0', zIndex: '10' });
renderPanel(gui);
addEventListener('pagehide', () => gui.destroy(), { once: true });
}
export async function createGraph(
options: ConstructorParameters<typeof Graph>[0],
size: { width?: number; height?: number } = {},
renderPanel?: (gui: GUI, graph: Graph) => void,
) {
const container = document.createElement('div');
Object.assign(container.style, {
width: '100%',
maxWidth: `${size.width || 600}px`,
height: `${size.height || 400}px`,
});
document.getElementById('container')!.append(container);
const graph = new Graph({ ...size, ...options, width: container.clientWidth, container, autoResize: true });
addEventListener('pagehide', () => graph.destroy(), { once: true });
await graph.render();
if (renderPanel) addPanel((gui) => renderPanel(gui, graph));
return container;
}配置项
| 属性 | 描述 | 类型 | 默认值 | 必选 |
|---|---|---|---|---|
| type | 插件类型 | string | hull |
✓ |
| key | 插件唯一标识符,用于后续更新 | string | - | |
| members | Hull 内的元素,包括节点和边 | string[] | - | ✓ |
| concavity | 凹度,数值越大凹度越小;默认为 Infinity 代表为 Convex Hull | number | Infinity | |
| corner | 拐角类型,可选值为 rounded | smooth | sharp |
string | rounded |
|
| padding | 内边距 | number | 10 |
|
| label | 是否显示标签 | boolean | true | |
| labelPlacement | 标签位置 | left | right | top | bottom | center |
bottom |
|
| labelBackground | 是否显示背景 | boolean | false | |
| labelPadding | 标签内边距 | number | number[] | 0 | |
| labelCloseToPath | 标签是否贴合轮廓 | boolean | true | |
| labelAutoRotate | 标签是否跟随轮廓旋转,仅在 closeToPath 为 true 时生效 | boolean | true | |
| labelOffsetX | x 轴偏移量 | number | 0 | |
| labelOffsetY | y 轴偏移量 | number | 0 | |
| labelMaxWidth | 文本的最大宽度,超出会自动省略 | number | 0 |
完整的标签样式见此链接
concavity
concavity 属性用于控制 Hull 的凹度。当设置为 Infinity 时,生成的是凸包;否则会生成凹包。
// 凸包示例
const graph = new Graph({
plugins: [
{
type: 'hull',
concavity: Infinity, // 凸包
members: ['node-1', 'node-2'],
},
],
});
// 凹包示例
const graph = new Graph({
plugins: [
{
type: 'hull',
concavity: 50, // 凹包
members: ['node-1', 'node-2'],
},
],
});
代码示例
基础 Hull
最简单的方式是直接使用预设配置:
const graph = new Graph({
plugins: [
{
type: 'hull',
members: ['node-1', 'node-2'], // 需要包裹的节点 ID 列表
},
],
});
效果如下:
import { Graph } from '@antv/g6';
const graph = new Graph({
container: 'container',
width: 300,
height: 150,
autoFit: 'view',
data: {
nodes: [
{
id: 'node-0',
data: { cluster: 'a' },
style: { x: 555, y: 151 },
},
{
id: 'node-1',
data: { cluster: 'a' },
style: { x: 532, y: 323 },
},
{
id: 'node-2',
data: { cluster: 'a' },
style: { x: 473, y: 227 },
},
{
id: 'node-3',
data: { cluster: 'a' },
style: { x: 349, y: 212 },
},
{
id: 'node-4',
data: { cluster: 'b' },
style: { x: 234, y: 201 },
},
{
id: 'node-5',
data: { cluster: 'b' },
style: { x: 338, y: 333 },
},
{
id: 'node-6',
data: { cluster: 'b' },
style: { x: 365, y: 91 },
},
],
edges: [
{
source: 'node-0',
target: 'node-2',
},
{
source: 'node-1',
target: 'node-2',
},
{
source: 'node-2',
target: 'node-3',
},
{
source: 'node-3',
target: 'node-4',
},
{
source: 'node-3',
target: 'node-5',
},
{
source: 'node-3',
target: 'node-6',
},
],
},
plugins: [
{
type: 'hull',
members: ['node-1', 'node-2'], // 需要包裹的节点 ID 列表
},
],
behaviors: ['zoom-canvas', 'drag-canvas'],
});
graph.render();
import { Graph } from '@antv/g6';
import GUI from 'lil-gui';
export function addPanel(renderPanel: (gui: GUI) => void) {
const gui = new GUI({ container: document.body });
gui.title('Control');
Object.assign(gui.domElement.style, { position: 'absolute', top: '0', right: '0', zIndex: '10' });
renderPanel(gui);
addEventListener('pagehide', () => gui.destroy(), { once: true });
}
export async function createGraph(
options: ConstructorParameters<typeof Graph>[0],
size: { width?: number; height?: number } = {},
renderPanel?: (gui: GUI, graph: Graph) => void,
) {
const container = document.createElement('div');
Object.assign(container.style, {
width: '100%',
maxWidth: `${size.width || 600}px`,
height: `${size.height || 400}px`,
});
document.getElementById('container')!.append(container);
const graph = new Graph({ ...size, ...options, width: container.clientWidth, container, autoResize: true });
addEventListener('pagehide', () => graph.destroy(), { once: true });
await graph.render();
if (renderPanel) addPanel((gui) => renderPanel(gui, graph));
return container;
}自定义样式
您可以根据需要自定义 Hull 的样式,例如调整颜色、透明度等属性。
const graph = new Graph({
plugins: [
{
type: 'hull',
members: ['node-1', 'node-2', 'node-3'],
stroke: '#ff000033', // 红色半透明边框
fill: '#7e3feb', // 浅紫色填充
fillOpacity: 0.2,
lineWidth: 2,
padding: 15, // 更大的内边距
},
],
});
效果如下:
import { Graph } from '@antv/g6';
const graph = new Graph({
container: 'container',
width: 300,
height: 150,
autoFit: 'view',
data: {
nodes: [
{
id: 'node-0',
data: { cluster: 'a' },
style: { x: 555, y: 151 },
},
{
id: 'node-1',
data: { cluster: 'a' },
style: { x: 532, y: 323 },
},
{
id: 'node-2',
data: { cluster: 'a' },
style: { x: 473, y: 227 },
},
{
id: 'node-3',
data: { cluster: 'a' },
style: { x: 349, y: 212 },
},
{
id: 'node-4',
data: { cluster: 'b' },
style: { x: 234, y: 201 },
},
{
id: 'node-5',
data: { cluster: 'b' },
style: { x: 338, y: 333 },
},
{
id: 'node-6',
data: { cluster: 'b' },
style: { x: 365, y: 91 },
},
],
edges: [
{
source: 'node-0',
target: 'node-2',
},
{
source: 'node-1',
target: 'node-2',
},
{
source: 'node-2',
target: 'node-3',
},
{
source: 'node-3',
target: 'node-4',
},
{
source: 'node-3',
target: 'node-5',
},
{
source: 'node-3',
target: 'node-6',
},
],
},
plugins: [
{
type: 'hull',
members: ['node-1', 'node-2', 'node-3'],
stroke: '#ff000033', // 红色半透明边框
fill: '#7e3feb', // 浅紫色填充
fillOpacity: 0.2,
lineWidth: 2,
padding: 15, // 更大的内边距
},
],
behaviors: ['zoom-canvas', 'drag-canvas'],
});
graph.render();
import { Graph } from '@antv/g6';
import GUI from 'lil-gui';
export function addPanel(renderPanel: (gui: GUI) => void) {
const gui = new GUI({ container: document.body });
gui.title('Control');
Object.assign(gui.domElement.style, { position: 'absolute', top: '0', right: '0', zIndex: '10' });
renderPanel(gui);
addEventListener('pagehide', () => gui.destroy(), { once: true });
}
export async function createGraph(
options: ConstructorParameters<typeof Graph>[0],
size: { width?: number; height?: number } = {},
renderPanel?: (gui: GUI, graph: Graph) => void,
) {
const container = document.createElement('div');
Object.assign(container.style, {
width: '100%',
maxWidth: `${size.width || 600}px`,
height: `${size.height || 400}px`,
});
document.getElementById('container')!.append(container);
const graph = new Graph({ ...size, ...options, width: container.clientWidth, container, autoResize: true });
addEventListener('pagehide', () => graph.destroy(), { once: true });
await graph.render();
if (renderPanel) addPanel((gui) => renderPanel(gui, graph));
return container;
}标签配置
您可以配置标签的位置、背景、偏移量等属性,以增强可视化效果。
const graph = new Graph({
plugins: [
{
type: 'hull',
members: ['node-1', 'node-2'],
label: true, // 显示标签
labelText: 'hull-a',
labelPlacement: 'top', // 标签位置
labelBackground: true, // 显示标签背景
labelPadding: 5, // 标签内边距
},
],
});
效果如下:
import { Graph } from '@antv/g6';
const graph = new Graph({
container: 'container',
width: 300,
height: 150,
autoFit: 'center',
data: {
nodes: [
{
id: 'node-0',
data: { cluster: 'a' },
style: { x: 555, y: 151 },
},
{
id: 'node-1',
data: { cluster: 'a' },
style: { x: 532, y: 323 },
},
{
id: 'node-2',
data: { cluster: 'a' },
style: { x: 473, y: 227 },
},
{
id: 'node-3',
data: { cluster: 'a' },
style: { x: 349, y: 212 },
},
{
id: 'node-4',
data: { cluster: 'b' },
style: { x: 234, y: 201 },
},
{
id: 'node-5',
data: { cluster: 'b' },
style: { x: 338, y: 333 },
},
{
id: 'node-6',
data: { cluster: 'b' },
style: { x: 365, y: 91 },
},
],
edges: [
{
source: 'node-0',
target: 'node-2',
},
{
source: 'node-1',
target: 'node-2',
},
{
source: 'node-2',
target: 'node-3',
},
{
source: 'node-3',
target: 'node-4',
},
{
source: 'node-3',
target: 'node-5',
},
{
source: 'node-3',
target: 'node-6',
},
],
},
plugins: [
{
type: 'hull',
members: ['node-1', 'node-2'],
label: true, // 显示标签
labelText: 'hull-a',
labelPlacement: 'top', // 标签位置
labelBackground: true, // 显示标签背景
labelPadding: 5, // 标签内边距
},
],
behaviors: ['zoom-canvas', 'drag-canvas'],
});
graph.render();
import { Graph } from '@antv/g6';
import GUI from 'lil-gui';
export function addPanel(renderPanel: (gui: GUI) => void) {
const gui = new GUI({ container: document.body });
gui.title('Control');
Object.assign(gui.domElement.style, { position: 'absolute', top: '0', right: '0', zIndex: '10' });
renderPanel(gui);
addEventListener('pagehide', () => gui.destroy(), { once: true });
}
export async function createGraph(
options: ConstructorParameters<typeof Graph>[0],
size: { width?: number; height?: number } = {},
renderPanel?: (gui: GUI, graph: Graph) => void,
) {
const container = document.createElement('div');
Object.assign(container.style, {
width: '100%',
maxWidth: `${size.width || 600}px`,
height: `${size.height || 400}px`,
});
document.getElementById('container')!.append(container);
const graph = new Graph({ ...size, ...options, width: container.clientWidth, container, autoResize: true });
addEventListener('pagehide', () => graph.destroy(), { once: true });
await graph.render();
if (renderPanel) addPanel((gui) => renderPanel(gui, graph));
return container;
}实际案例
import { Graph } from '@antv/g6';
fetch('https://assets.antv.antgroup.com/g6/collection.json')
.then((res) => res.json())
.then((data) => {
const groupedNodesByCluster = data.nodes.reduce((acc, node) => {
const cluster = node.data.cluster;
acc[cluster] ||= [];
acc[cluster].push(node.id);
return acc;
}, {});
const createStyle = (baseColor) => ({
fill: baseColor,
stroke: baseColor,
labelFill: '#fff',
labelPadding: 2,
labelBackgroundFill: baseColor,
labelBackgroundRadius: 5,
});
const graph = new Graph({
container: 'container',
data,
behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'],
node: {
palette: { field: 'cluster' },
},
layout: {
type: 'force',
preventOverlap: true,
linkDistance: (d) => {
if (d.source === 'node0' || d.target === 'node0') {
return 200;
}
return 80;
},
},
plugins: [
{
key: 'hull-a',
type: 'hull',
members: groupedNodesByCluster['a'],
labelText: 'cluster-a',
...createStyle('#1783FF'),
},
{
key: 'hull-b',
type: 'hull',
members: groupedNodesByCluster['b'],
labelText: 'cluster-b',
...createStyle('#00C9C9'),
},
{
key: 'hull-c',
type: 'hull',
members: groupedNodesByCluster['c'],
labelText: 'cluster-c',
...createStyle('#F08F56'),
},
{
key: 'hull-d',
type: 'hull',
members: groupedNodesByCluster['d'],
labelText: 'cluster-d',
...createStyle('#D580FF'),
},
],
autoFit: 'center',
});
graph.render();
});
import { Graph } from '@antv/g6';
import GUI from 'lil-gui';
export function addPanel(renderPanel: (gui: GUI) => void) {
const gui = new GUI({ container: document.body });
gui.title('Control');
Object.assign(gui.domElement.style, { position: 'absolute', top: '0', right: '0', zIndex: '10' });
renderPanel(gui);
addEventListener('pagehide', () => gui.destroy(), { once: true });
}
export async function createGraph(
options: ConstructorParameters<typeof Graph>[0],
size: { width?: number; height?: number } = {},
renderPanel?: (gui: GUI, graph: Graph) => void,
) {
const container = document.createElement('div');
Object.assign(container.style, {
width: '100%',
maxWidth: `${size.width || 600}px`,
height: `${size.height || 400}px`,
});
document.getElementById('container')!.append(container);
const graph = new Graph({ ...size, ...options, width: container.clientWidth, container, autoResize: true });
addEventListener('pagehide', () => graph.destroy(), { once: true });
await graph.render();
if (renderPanel) addPanel((gui) => renderPanel(gui, graph));
return container;
}