跳到主要内容

层次结构可视化

许多数据天然具有层次结构:公司的组织架构、文件系统的目录树、网站的导航结构、分类学的生物分类等。即使是原本非层次化的数据,也可以通过聚类等方法组织成层次结构。层次结构可视化帮助我们在不同尺度上理解数据——既能观察整体的宏观结构,又能深入了解个别元素的细节。

D3 的 d3-hierarchy 模块提供了多种层次数据可视化技术,包括节点链接图(如树形图)、邻接图(如旭日图)、包围图(如树图和圆打包)等。每种方法都有其独特的视觉表达和适用场景。

层次数据结构

在使用 D3 绘制层次结构图之前,需要理解层次数据的表示方法。

嵌套对象格式

最直接的层次数据格式是嵌套的对象结构:

const data = {
name: "公司",
children: [
{
name: "技术部",
children: [
{ name: "前端组", value: 10 },
{ name: "后端组", value: 15 },
{ name: "测试组", value: 8 }
]
},
{
name: "市场部",
children: [
{ name: "销售组", value: 20 },
{ name: "运营组", value: 12 }
]
},
{
name: "行政部",
children: [
{ name: "人事组", value: 5 },
{ name: "财务组", value: 6 }
]
}
]
};

这种格式的特点是:每个节点可以有 children 属性,包含其子节点数组;叶节点通常有 value 属性,表示数值(如人数、金额等);根节点是整个结构的入口点。

扁平表格格式

有时候数据是扁平的表格形式,需要转换成层次结构:

// 扁平数据
const flatData = [
{ id: "技术部", parent: "" },
{ id: "前端组", parent: "技术部", value: 10 },
{ id: "后端组", parent: "技术部", value: 15 },
{ id: "市场部", parent: "" },
{ id: "销售组", parent: "市场部", value: 20 }
];

// 使用 d3.stratify() 转换
const stratify = d3.stratify()
.id(d => d.id)
.parentId(d => d.parent);

const root = stratify(flatData);

d3.stratify() 需要指定两个访问器函数:id 返回节点的唯一标识;parentId 返回父节点的标识。根节点的 parentId 应该返回 null 或空字符串。

创建层次结构

使用 d3.hierarchy() 将嵌套数据转换为 D3 的层次节点对象:

const root = d3.hierarchy(data)
.sum(d => d.value) // 计算每个节点的累计值
.sort((a, b) => b.value - a.value); // 按值排序

// 转换后的节点对象包含以下属性:
// - data: 原始数据
// - depth: 节点深度(根节点为 0)
// - height: 节点高度(叶节点为 0)
// - parent: 父节点引用
// - children: 子节点数组
// - value: 累计值(调用 sum() 后才有)

sum() 方法会递归地计算每个节点的值,节点 value 等于其所有后代叶节点 value 之和。sort() 方法可以对子节点进行排序。

层次节点的遍历方法

D3 层次节点提供了多种遍历方法:

// 深度优先前序遍历(先访问节点,再访问子节点)
root.each(d => console.log(d.data.name));

// 深度优先后序遍历(先访问子节点,再访问节点)
root.eachAfter(d => console.log(d.data.name));

// 广度优先遍历(按层级遍历)
root.eachBefore(d => console.log(d.data.name));

// 获取所有后代节点
const descendants = root.descendants(); // 返回数组

// 获取所有叶节点
const leaves = root.leaves(); // 返回数组

// 获取连接关系(用于绘制连线)
const links = root.links(); // 返回 {source, target} 数组

// 查找特定节点
const found = root.find(d => d.data.name === "前端组");

// 获取节点路径
const path = root.path(found); // 返回从 root 到 found 的节点数组

树形图(Tree Layout)

树形图是最常见的层次结构可视化方式。D3 的 d3.tree() 布局将层次数据转换为笛卡尔坐标系下的节点位置。

基本树形图

// 数据
const data = {
name: "根节点",
children: [
{
name: "子节点A",
children: [
{ name: "叶子A1" },
{ name: "叶子A2" }
]
},
{
name: "子节点B",
children: [
{ name: "叶子B1" },
{ name: "叶子B2" },
{ name: "叶子B3" }
]
}
]
};

// 创建层次结构
const root = d3.hierarchy(data);

// 设置尺寸
const width = 800;
const height = 400;

// 创建树形布局
const treeLayout = d3.tree()
.size([height, width - 200]); // [高度, 宽度]

// 应用布局
treeLayout(root);

// 创建 SVG
const svg = d3.select('#chart')
.append('svg')
.attr('width', width)
.attr('height', height);

// 绘制连线
svg.selectAll('.link')
.data(root.links())
.join('path')
.attr('class', 'link')
.attr('fill', 'none')
.attr('stroke', '#ccc')
.attr('stroke-width', 1.5)
.attr('d', d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x));

// 绘制节点
const nodes = svg.selectAll('.node')
.data(root.descendants())
.join('g')
.attr('class', 'node')
.attr('transform', d => `translate(${d.y},${d.x})`);

// 节点圆圈
nodes.append('circle')
.attr('r', 6)
.attr('fill', d => d.children ? '#555' : '#999')
.attr('stroke', 'steelblue')
.attr('stroke-width', 2);

// 节点标签
nodes.append('text')
.attr('dy', '0.31em')
.attr('x', d => d.children ? -10 : 10)
.attr('text-anchor', d => d.children ? 'end' : 'start')
.text(d => d.data.name)
.attr('font-size', '12px');

树形布局后,每个节点会获得 xy 属性,表示其在画布上的位置。默认情况下,x 是垂直位置,y 是水平位置(从左到右展开)。

垂直树形图

将树形图旋转为垂直方向:

const treeLayout = d3.tree()
.size([width, height]); // 交换宽高

// 使用 linkVertical 代替 linkHorizontal
svg.selectAll('.link')
.attr('d', d3.linkVertical()
.x(d => d.x)
.y(d => d.y));

// 节点变换也需要调整
nodes.attr('transform', d => `translate(${d.x},${d.y})`);

节点大小模式

使用 nodeSize() 可以精确控制节点间距:

const treeLayout = d3.tree()
.nodeSize([30, 150]); // [垂直间距, 水平间距]

size() 设置整个树的尺寸,D3 会自动计算节点间距;nodeSize() 设置节点间距,树的尺寸会自动扩展。对于大型树,nodeSize() 更容易控制布局。

径向树形图

径向树形图将树形结构呈放射状展开,中心是根节点:

const radius = 300;

const treeLayout = d3.tree()
.size([2 * Math.PI, radius])
.separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth);

treeLayout(root);

// 绘制连线(使用径向线生成器)
const radialLink = d3.linkRadial()
.angle(d => d.x)
.radius(d => d.y);

svg.selectAll('.link')
.data(root.links())
.join('path')
.attr('fill', 'none')
.attr('stroke', '#ccc')
.attr('d', radialLink);

// 绘制节点
const nodes = svg.selectAll('.node')
.data(root.descendants())
.join('g')
.attr('transform', d => `
rotate(${d.x * 180 / Math.PI - 90})
translate(${d.y}, 0)
`);

nodes.append('circle')
.attr('r', 4);

nodes.append('text')
.attr('dy', '0.31em')
.attr('x', d => d.x < Math.PI === !d.children ? 6 : -6)
.attr('text-anchor', d => d.x < Math.PI === !d.children ? 'start' : 'end')
.attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
.text(d => d.data.name);

聚类图(Cluster Layout)

聚类图与树形图类似,但强制所有叶节点位于同一层级:

const clusterLayout = d3.cluster()
.size([height, width - 200]);

clusterLayout(root);

// 其余绘制代码与树形图相同

聚类图适合展示层级关系的同时强调叶节点的重要性,比如在生物分类学中展示物种关系。

分区图与旭日图(Partition)

分区图将层次结构映射到矩形区域,每个节点占据一块面积。当节点围绕中心呈放射状排列时,就形成了旭日图(Sunburst)。

矩形分区图

// 创建层次结构并计算值
const root = d3.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.value - a.value);

// 创建分区布局
const partition = d3.partition()
.size([height, width])
.padding(1);

partition(root);

// 颜色比例尺
const color = d3.scaleOrdinal(d3.schemeCategory10);

// 绘制矩形
svg.selectAll('rect')
.data(root.descendants())
.join('rect')
.attr('x', d => d.y0)
.attr('y', d => d.x0)
.attr('width', d => d.y1 - d.y0)
.attr('height', d => d.x1 - d.x0)
.attr('fill', d => color((d.children ? d : d.parent).data.name))
.attr('stroke', 'white');

分区布局后,每个节点会获得四个坐标属性:x0x1(起始和结束位置)、y0y1(深度方向的起始和结束位置)。

旭日图(Sunburst)

旭日图是分区图的径向版本:

const radius = 300;

const partition = d3.partition()
.size([2 * Math.PI, radius]);

partition(root);

// 弧形生成器
const arc = d3.arc()
.startAngle(d => d.x0)
.endAngle(d => d.x1)
.padAngle(d => Math.min((d.x1 - d.x0) / 2, 0.005))
.padRadius(radius / 2)
.innerRadius(d => d.y0)
.outerRadius(d => d.y1 - 1);

// 绘制扇形
svg.selectAll('path')
.data(root.descendants().filter(d => d.depth))
.join('path')
.attr('fill', d => color((d.children ? d : d.parent).data.name))
.attr('d', arc)
.attr('stroke', 'white')
.on('mouseover', function(event, d) {
// 显示祖先路径
const ancestors = d.ancestors().reverse();
svg.selectAll('path')
.attr('opacity', node => ancestors.includes(node) ? 1 : 0.3);
});

旭日图适合展示层次结构中各部分的占比关系,常用于分析磁盘空间使用、网站访问路径等。

树图(Treemap)

树图通过矩形的嵌套来展示层次结构,每个矩形的面积对应节点的值。树图非常适合展示占比关系,是「空间填充」可视化技术的代表。

基本树图

const root = d3.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.height - a.height || b.value - a.value);

// 创建树图布局
const treemap = d3.treemap()
.size([width, height])
.paddingOuter(3)
.paddingTop(19)
.paddingInner(1)
.round(true);

treemap(root);

// 颜色比例尺
const color = d3.scaleOrdinal()
.domain(root.children.map(d => d.data.name))
.range(d3.schemeCategory10);

// 格式化函数
const format = d3.format(',d');

// 绘制节点
const leaf = svg.selectAll('g')
.data(root.leaves())
.join('g')
.attr('transform', d => `translate(${d.x0},${d.y0})`);

leaf.append('rect')
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.attr('fill', d => color(d.parent.data.name))
.attr('fill-opacity', 0.8);

leaf.append('text')
.attr('x', 3)
.attr('y', 14)
.text(d => d.data.name)
.attr('font-size', '12px');

leaf.append('text')
.attr('x', 3)
.attr('y', 28)
.text(d => format(d.value))
.attr('font-size', '10px')
.attr('fill', '#666');

树图平铺策略

D3 提供了多种平铺策略,控制矩形如何排列:

// 默认策略(Squarified)- 尽量保持正方形比例
treemap.tile(d3.treemapSquarify);

// 切片策略 - 水平或垂直切片
treemap.tile(d3.treemapSlice); // 水平切片
treemap.tile(d3.treemapDice); // 垂直切片

// 切片切块策略 - 交替水平和垂直
treemap.tile(d3.treemapSliceDice);

// 二分策略 - 二分图风格
treemap.tile(d3.treemapBinary);

// 自定义比例
treemap.tile(d3.treemapResquarify); // 动态更新时保持稳定

可缩放树图

添加交互功能,允许用户点击放大某个分支:

function zoom(d) {
const treemap = d3.treemap()
.size([width, height])
.paddingOuter(3)
.paddingTop(19)
.paddingInner(1);

treemap(d);

const t = svg.transition().duration(750);

leaf.selectAll('rect')
.transition(t)
.attr('transform', d => `translate(${d.x0},${d.y0})`)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0);

leaf.selectAll('text')
.transition(t)
.attr('transform', d => `translate(${d.x0},${d.y0})`);
}

svg.selectAll('rect')
.on('click', (event, d) => {
event.stopPropagation();
zoom(d);
});

// 点击空白区域返回顶层
svg.on('click', () => zoom(root));

圆打包(Circle Packing)

圆打包用嵌套的圆形来表示层次结构。相比树图,圆打包的空间利用率较低,但更能清晰地展示拓扑结构。

const root = d3.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.value - a.value);

// 创建圆打包布局
const pack = d3.pack()
.size([width, height])
.padding(3);

pack(root);

// 颜色比例尺
const color = d3.scaleOrdinal()
.domain(root.children.map(d => d.data.name))
.range(d3.schemeCategory10);

// 绘制圆形
svg.selectAll('circle')
.data(root.descendants())
.join('circle')
.attr('cx', d => d.x)
.attr('cy', d => d.y)
.attr('r', d => d.r)
.attr('fill', d => d.children ? color(d.data.name) : color(d.parent.data.name))
.attr('fill-opacity', d => d.children ? 0.3 : 0.8)
.attr('stroke', '#999')
.attr('stroke-width', 0.5)
.on('mouseover', function() {
d3.select(this).attr('stroke-width', 2);
})
.on('mouseout', function() {
d3.select(this).attr('stroke-width', 0.5);
});

// 添加标签(仅叶节点)
svg.selectAll('text')
.data(root.leaves())
.join('text')
.attr('x', d => d.x)
.attr('y', d => d.y + 4)
.attr('text-anchor', 'middle')
.attr('font-size', '10px')
.text(d => d.data.name);

圆打包的特点是每个圆的面积与节点值成正比。通过填充透明度的差异,可以区分父节点和子节点。

缩进树(Indented Tree)

缩进树是另一种常见的树形表示方式,特别适合展示文件目录或大纲结构:

const root = d3.hierarchy(data);

// 设置缩进参数
const dx = 20; // 每层缩进量
const dy = 25; // 行高

// 手动计算位置
let index = -1;
root.eachBefore(d => {
d.x = ++index * dy;
d.y = d.depth * dx;
});

// 绘制节点
svg.selectAll('g')
.data(root.descendants())
.join('g')
.attr('transform', d => `translate(${d.y},${d.x})`)
.call(g => g.append('text')
.attr('dy', '0.31em')
.attr('x', d => d.children ? -10 : 10)
.attr('text-anchor', d => d.children ? 'end' : 'start')
.text(d => d.data.name))
.call(g => g.append('circle')
.attr('r', 4)
.attr('fill', d => d.children ? '#555' : '#999'));

// 绘制连线(可选)
svg.selectAll('path')
.data(root.links())
.join('path')
.attr('fill', 'none')
.attr('stroke', '#ccc')
.attr('d', d => `
M${d.source.y},${d.source.x}
V${d.target.x}
H${d.target.y}
`);

层次结构可视化选择指南

可视化类型适用场景优势劣势
树形图展示层级关系、组织架构清晰展示拓扑结构深度大时难以阅读
聚类图生物分类、强调叶节点叶节点对齐内部节点布局可能混乱
分区图/旭日图展示占比和层级直观展示占比层级深时空间紧张
树图空间填充、占比分析空间利用率高层级关系不够直观
圆打包层级关系和占比兼顾拓扑清晰空间利用率低
缩进树文件目录、大纲结构紧凑、易浏览不直观展示占比

总结

层次结构可视化是数据可视化中的重要分支。D3 的 d3-hierarchy 模块提供了从数据处理到布局计算的完整工具链。理解层次数据的表示方式、掌握各种布局的特点和配置方法,是创建有效层次可视化的关键。

选择合适的可视化形式取决于数据的特性和你想传达的信息。如果你想强调层级关系和结构,树形图或聚类图是好的选择;如果你想展示各部分的占比,树图或旭日图更合适;如果你需要在有限空间内展示完整的层次信息,圆打包或缩进树值得考虑。

无论选择哪种形式,都应该添加适当的交互功能,如缩放、高亮、悬停提示等,让用户能够深入探索数据的不同层次。