鱼骨布局 Fishbone
概述
鱼骨布局是一种专门用于表示层次结构数据的图形布局方式。它通过模拟鱼骨的形状,将数据节点按照层次结构排列,使得数据的层次关系更加清晰直观。鱼骨布局特别适用于需要展示因果关系、层次结构或分类信息的数据集。
使用场景
- 需要展示层次结构数据,如组织结构、分类体系
- 需要展示问题分析过程,如故障分析、质量分析
- 需要展示决策过程,如决策树、影响因素分析
在线体验
import { createGraph } from '/demo-runtime.ts';
createGraph(
{
data: {
nodes: [
{
id: 'Quality',
depth: 0,
children: ['Machine', 'Method', 'Material', 'Man Power', 'Measurement', 'Milieu'],
},
{
id: 'Machine',
depth: 1,
children: ['Mill', 'Mixer', 'Metal Lathe'],
},
{
id: 'Mill',
depth: 2,
},
{
id: 'Mixer',
depth: 2,
},
{
id: 'Metal Lathe',
depth: 2,
children: ['Milling'],
},
{
id: 'Milling',
depth: 3,
},
{
id: 'Method',
depth: 1,
},
{
id: 'Material',
depth: 1,
children: ['Masonite', 'Marscapone', 'Meat'],
},
{
id: 'Masonite',
depth: 2,
children: ['spearMint', 'pepperMint', 'test1'],
},
{
id: 'spearMint',
depth: 3,
},
{
id: 'pepperMint',
depth: 3,
children: ['test3'],
},
{
id: 'test3',
depth: 4,
},
{
id: 'test1',
depth: 3,
children: ['test4'],
},
{
id: 'test4',
depth: 4,
},
{
id: 'Marscapone',
depth: 2,
children: ['Malty', 'Minty'],
},
{
id: 'Malty',
depth: 3,
},
{
id: 'Minty',
depth: 3,
},
{
id: 'Meat',
depth: 2,
children: ['Mutton'],
},
{
id: 'Mutton',
depth: 3,
},
{
id: 'Man Power',
depth: 1,
children: ['Manager', "Master's Student", 'Magician', 'Miner', 'Magister', 'Massage Artist'],
},
{
id: 'Manager',
depth: 2,
},
{
id: "Master's Student",
depth: 2,
},
{
id: 'Magician',
depth: 2,
},
{
id: 'Miner',
depth: 2,
},
{
id: 'Magister',
depth: 2,
children: ['Malpractice'],
},
{
id: 'Malpractice',
depth: 3,
},
{
id: 'Massage Artist',
depth: 2,
children: ['Masseur', 'Masseuse'],
},
{
id: 'Masseur',
depth: 3,
},
{
id: 'Masseuse',
depth: 3,
},
{
id: 'Measurement',
depth: 1,
children: ['Malleability'],
},
{
id: 'Malleability',
depth: 2,
},
{
id: 'Milieu',
depth: 1,
children: ['Marine'],
},
{
id: 'Marine',
depth: 2,
},
],
edges: [
{
source: 'Quality',
target: 'Machine',
},
{
source: 'Quality',
target: 'Method',
},
{
source: 'Quality',
target: 'Material',
},
{
source: 'Quality',
target: 'Man Power',
},
{
source: 'Quality',
target: 'Measurement',
},
{
source: 'Quality',
target: 'Milieu',
},
{
source: 'Machine',
target: 'Mill',
},
{
source: 'Machine',
target: 'Mixer',
},
{
source: 'Machine',
target: 'Metal Lathe',
},
{
source: 'Metal Lathe',
target: 'Milling',
},
{
source: 'Material',
target: 'Masonite',
},
{
source: 'Material',
target: 'Marscapone',
},
{
source: 'Material',
target: 'Meat',
},
{
source: 'Masonite',
target: 'spearMint',
},
{
source: 'Masonite',
target: 'pepperMint',
},
{
source: 'Masonite',
target: 'test1',
},
{
source: 'pepperMint',
target: 'test3',
},
{
source: 'test1',
target: 'test4',
},
{
source: 'Marscapone',
target: 'Malty',
},
{
source: 'Marscapone',
target: 'Minty',
},
{
source: 'Meat',
target: 'Mutton',
},
{
source: 'Man Power',
target: 'Manager',
},
{
source: 'Man Power',
target: "Master's Student",
},
{
source: 'Man Power',
target: 'Magician',
},
{
source: 'Man Power',
target: 'Miner',
},
{
source: 'Man Power',
target: 'Magister',
},
{
source: 'Man Power',
target: 'Massage Artist',
},
{
source: 'Magister',
target: 'Malpractice',
},
{
source: 'Massage Artist',
target: 'Masseur',
},
{
source: 'Massage Artist',
target: 'Masseuse',
},
{
source: 'Measurement',
target: 'Malleability',
},
{
source: 'Milieu',
target: 'Marine',
},
],
},
node: {
type: 'rect',
style: {
size: [32, 32],
// fill: () => randomColor(),
label: false,
labelFill: '#262626',
labelFontFamily: 'Gill Sans',
labelMaxLines: 2,
labelMaxWidth: '100%',
labelPlacement: 'center',
labelText: (d) => d.id,
labelWordWrap: true,
},
},
edge: {
type: 'polyline',
style: {
lineWidth: 3,
},
},
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
autoFit: 'view',
layout: {
type: 'fishbone',
direction: 'RL',
hGap: 50,
vGap: 50,
getRibSep: () => 60,
},
},
{ width: 600, height: 400 },
(gui, graph) => {
const options = {
type: 'fishbone',
direction: 'RL',
hGap: 50,
vGap: 50,
getRibSep: 60,
};
const optionFolder = gui.addFolder('Fishbone Layout Options');
optionFolder.add(options, 'type').disable(true);
optionFolder.add(options, 'direction', ['RL', 'LR']);
optionFolder.add(options, 'hGap', 20, 100, 10);
optionFolder.add(options, 'vGap', 20, 100, 10);
optionFolder.add(options, 'getRibSep', 30, 100, 10);
optionFolder.onChange(async ({ property, value }) => {
graph.setLayout(
Object.assign({}, graph.getLayout(), {
[property]: property === 'getRibSep' ? () => value : value,
}),
);
await graph.layout();
// 调整 direction 后部分node可能会溢出屏幕,重新执行下fitView
if (property === 'direction') {
graph.fitView();
}
});
},
);
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({
layout: {
type: 'fishbone',
direction: 'LR',
hGap: 50,
vGap: 50,
getRibSep: () => 60,
},
});
配置项
| 属性 | 描述 | 类型 | 默认值 | 必选 |
|---|---|---|---|---|
| type | 布局类型 | fishbone |
- | ✓ |
| direction | 排布方向,RL 从右到左,鱼头在右;LR 从左到右,鱼头在左 |
RL | LR |
RL |
|
| hGap | 水平间距 | number | - | |
| vGap | 垂直间距 | number | - | |
| getRibSep | 获取鱼骨间距 | (node: NodeData) => number | () => 60 | |
| width | 布局宽度 | number | - | |
| height | 布局高度 | number | - | |
| nodeSize | 节点大小 | number | [number, number] | [number, number, number] | ((node: NodeData) => number | [number, number] | [number, number, number]) | - | |
| isLayoutInvisibleNodes | 不可见节点是否参与布局,当 preLayout 为 true 时生效 | boolean | - | |
| nodeFilter | 参与该布局的节点 | (node: NodeData) => boolean | - | |
| preLayout | 使用前布局,在初始化元素前计算布局,不适用于流水线布局 | boolean | - |
代码示例
基础用法
最简单的配置方式:
import { Graph, treeToGraphData } from '@antv/g6';
const graph = new Graph({
layout: {
type: 'fishbone',
},
autoFit: 'view',
data: treeToGraphData({
nodes: [
{ id: 'root', data: { label: 'Root' } },
{ id: 'child1', data: { label: 'Child 1' } },
{ id: 'child2', data: { label: 'Child 2' } },
{ id: 'child3', data: { label: 'Child 3' } },
],
edges: [
{ id: 'e1', source: 'root', target: 'child1' },
{ id: 'e2', source: 'root', target: 'child2' },
{ id: 'e3', source: 'root', target: 'child3' },
],
}),
edge: {
type: 'polyline',
style: {
lineWidth: 3,
},
},
behaviors: ['drag-canvas'],
});
效果如下:
import { Graph } from '@antv/g6';
const graph = new Graph({
container: 'container',
width: 600,
height: 400,
layout: {
type: 'fishbone',
},
autoFit: 'view',
data: {
nodes: [
{
id: 'Quality',
depth: 0,
children: ['Machine', 'Method', 'Material', 'Man Power', 'Measurement', 'Milieu'],
},
{
id: 'Machine',
depth: 1,
children: ['Mill', 'Mixer', 'Metal Lathe'],
},
{
id: 'Mill',
depth: 2,
},
{
id: 'Mixer',
depth: 2,
},
{
id: 'Metal Lathe',
depth: 2,
children: ['Milling'],
},
{
id: 'Milling',
depth: 3,
},
{
id: 'Method',
depth: 1,
},
{
id: 'Material',
depth: 1,
children: ['Masonite', 'Marscapone', 'Meat'],
},
{
id: 'Masonite',
depth: 2,
children: ['spearMint', 'pepperMint', 'test1'],
},
{
id: 'spearMint',
depth: 3,
},
{
id: 'pepperMint',
depth: 3,
children: ['test3'],
},
{
id: 'test3',
depth: 4,
},
{
id: 'test1',
depth: 3,
children: ['test4'],
},
{
id: 'test4',
depth: 4,
},
{
id: 'Marscapone',
depth: 2,
children: ['Malty', 'Minty'],
},
{
id: 'Malty',
depth: 3,
},
{
id: 'Minty',
depth: 3,
},
{
id: 'Meat',
depth: 2,
children: ['Mutton'],
},
{
id: 'Mutton',
depth: 3,
},
{
id: 'Man Power',
depth: 1,
children: ['Manager', "Master's Student", 'Magician', 'Miner', 'Magister', 'Massage Artist'],
},
{
id: 'Manager',
depth: 2,
},
{
id: "Master's Student",
depth: 2,
},
{
id: 'Magician',
depth: 2,
},
{
id: 'Miner',
depth: 2,
},
{
id: 'Magister',
depth: 2,
children: ['Malpractice'],
},
{
id: 'Malpractice',
depth: 3,
},
{
id: 'Massage Artist',
depth: 2,
children: ['Masseur', 'Masseuse'],
},
{
id: 'Masseur',
depth: 3,
},
{
id: 'Masseuse',
depth: 3,
},
{
id: 'Measurement',
depth: 1,
children: ['Malleability'],
},
{
id: 'Malleability',
depth: 2,
},
{
id: 'Milieu',
depth: 1,
children: ['Marine'],
},
{
id: 'Marine',
depth: 2,
},
],
edges: [
{
source: 'Quality',
target: 'Machine',
},
{
source: 'Quality',
target: 'Method',
},
{
source: 'Quality',
target: 'Material',
},
{
source: 'Quality',
target: 'Man Power',
},
{
source: 'Quality',
target: 'Measurement',
},
{
source: 'Quality',
target: 'Milieu',
},
{
source: 'Machine',
target: 'Mill',
},
{
source: 'Machine',
target: 'Mixer',
},
{
source: 'Machine',
target: 'Metal Lathe',
},
{
source: 'Metal Lathe',
target: 'Milling',
},
{
source: 'Material',
target: 'Masonite',
},
{
source: 'Material',
target: 'Marscapone',
},
{
source: 'Material',
target: 'Meat',
},
{
source: 'Masonite',
target: 'spearMint',
},
{
source: 'Masonite',
target: 'pepperMint',
},
{
source: 'Masonite',
target: 'test1',
},
{
source: 'pepperMint',
target: 'test3',
},
{
source: 'test1',
target: 'test4',
},
{
source: 'Marscapone',
target: 'Malty',
},
{
source: 'Marscapone',
target: 'Minty',
},
{
source: 'Meat',
target: 'Mutton',
},
{
source: 'Man Power',
target: 'Manager',
},
{
source: 'Man Power',
target: "Master's Student",
},
{
source: 'Man Power',
target: 'Magician',
},
{
source: 'Man Power',
target: 'Miner',
},
{
source: 'Man Power',
target: 'Magister',
},
{
source: 'Man Power',
target: 'Massage Artist',
},
{
source: 'Magister',
target: 'Malpractice',
},
{
source: 'Massage Artist',
target: 'Masseur',
},
{
source: 'Massage Artist',
target: 'Masseuse',
},
{
source: 'Measurement',
target: 'Malleability',
},
{
source: 'Milieu',
target: 'Marine',
},
],
},
edge: {
type: 'polyline',
style: {
lineWidth: 3,
},
},
behaviors: ['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, treeToGraphData } from '@antv/g6';
const data = {
id: 'Quality',
children: [
{
id: 'Machine',
children: [{ id: 'Mill' }, { id: 'Mixer' }, { id: 'Metal Lathe', children: [{ id: 'Milling' }] }],
},
{ id: 'Method' },
{
id: 'Material',
children: [
{
id: 'Masonite',
children: [
{ id: 'spearMint' },
{ id: 'pepperMint', children: [{ id: 'test3' }] },
{ id: 'test1', children: [{ id: 'test4' }] },
],
},
{
id: 'Marscapone',
children: [{ id: 'Malty' }, { id: 'Minty' }],
},
{ id: 'Meat', children: [{ id: 'Mutton' }] },
],
},
{
id: 'Man Power',
children: [
{ id: 'Manager' },
{ id: "Master's Student" },
{ id: 'Magician' },
{ id: 'Miner' },
{ id: 'Magister', children: [{ id: 'Malpractice' }] },
{
id: 'Massage Artist',
children: [{ id: 'Masseur' }, { id: 'Masseuse' }],
},
],
},
{
id: 'Measurement',
children: [{ id: 'Malleability' }],
},
{
id: 'Milieu',
children: [{ id: 'Marine' }],
},
],
};
export const layoutFishbone = async (context) => {
const graph = new Graph({
...context,
container: 'container',
autoFit: 'view',
data: treeToGraphData(data),
node: {
type: 'rect',
style: {
size: [32, 32],
// fill: () => randomColor(),
label: false,
labelFill: '#262626',
labelFontFamily: 'Gill Sans',
labelMaxLines: 2,
labelMaxWidth: '100%',
labelPlacement: 'center',
labelText: (d) => d.id,
labelWordWrap: true,
},
},
edge: {
type: 'polyline',
style: {
lineWidth: 3,
},
},
layout: {
type: 'fishbone',
vGap: 48,
hGap: 48,
direction: 'RL',
},
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
animation: false,
});
await graph.render();
layoutFishbone.form = (panel) => {
const config = {
type: 'fishbone',
direction: 'RL',
};
return [
panel
.add(config, 'direction', ['LR', 'RL'])
.name('Direction')
.onChange((value) => {
graph.setLayout((prev) => ({ ...prev, direction: value }));
graph.render();
}),
];
};
return graph;
};
layoutFishbone();
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;
}