综合示例:交互式柱状图
本章将前面学到的选择集、数据绑定、比例尺、坐标轴、过渡动画和交互等概念综合起来,创建一个完整的交互式柱状图。通过这个综合示例,你将看到 D3.js 各个模块是如何协同工作的。
设计目标
我们要创建的柱状图需要具备以下特性:从外部数据源加载数据并支持动态更新;带有完整的坐标轴、网格线和标签;鼠标悬停时显示详细信息的提示框;平滑的过渡动画效果;响应式设计,适应不同的屏幕尺寸。
数据准备
假设我们有一组产品销售数据,存储在 JSON 格式中:
// 可以从外部文件加载,这里为了演示直接定义
const salesData = [
{ product: 'iPhone 15', category: '电子产品', sales: 8500, growth: 12.5 },
{ product: 'MacBook Pro', category: '电子产品', sales: 5200, growth: 8.3 },
{ product: 'AirPods', category: '电子产品', sales: 12000, growth: 25.0 },
{ product: 'iPad', category: '电子产品', sales: 3800, growth: -5.2 },
{ product: 'Apple Watch', category: '电子产品', sales: 6500, growth: 15.8 },
{ product: 'T恤', category: '服装', sales: 2300, growth: 3.2 },
{ product: '牛仔裤', category: '服装', sales: 1800, growth: -2.1 },
{ product: '运动鞋', category: '服装', sales: 3200, growth: 10.5 }
];
基础结构
首先创建 SVG 画布和基本结构。使用边距约定(Margin Convention)来预留坐标轴和标签的空间:
// 尺寸设置
const margin = { top: 40, right: 30, bottom: 80, left: 70 };
const width = 900 - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
// 创建 SVG 画布
const svg = d3.select('#chart')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
// 创建主绘图区域
const g = svg.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
边距约定是 D3 可视化的标准做法:margin 对象定义了图表四周的留白区域;width 和 height 是实际绑制区域的大小;SVG 的总尺寸需要加上边距。主绘图区域通过 translate 变换移动到边距之后的位置。
创建比例尺
柱状图需要两种比例尺:X 轴使用条形比例尺处理分类数据;Y 轴使用线性比例尺处理数值数据。
// X 轴比例尺
const xScale = d3.scaleBand()
.domain(salesData.map(d => d.product)) // 产品名称作为分类
.range([0, width]) // 水平范围
.padding(0.2); // 柱子之间的间距
// Y 轴比例尺
const yScale = d3.scaleLinear()
.domain([0, d3.max(salesData, d => d.sales) * 1.1]) // 留出 10% 顶部空间
.range([height, 0]) // Y 轴方向是从下往上
.nice(); // 调整为整齐的刻度值
// 颜色比例尺(根据类别着色)
const colorScale = d3.scaleOrdinal()
.domain(['电子产品', '服装'])
.range(['#3498db', '#e74c3c']);
创建比例尺时需要注意:domain 是数据的范围,range 是像素的范围。Y 轴的 range 是 [height, 0] 而不是 [0, height],这是因为 SVG 的 Y 坐标从上往下增加,但图表中数据值应该从下往上增加。
绘制坐标轴和网格线
坐标轴提供数据的参考框架,网格线帮助读者更准确地读取数值:
// X 轴
const xAxis = d3.axisBottom(xScale);
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${height})`)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-45)') // 旋转标签避免重叠
.attr('text-anchor', 'end')
.attr('dx', '-0.5em')
.attr('dy', '0.5em')
.attr('font-size', '11px');
// Y 轴
const yAxis = d3.axisLeft(yScale)
.tickFormat(d => `¥${(d / 1000).toFixed(0)}k`); // 格式化为千元单位
g.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// 网格线
g.append('g')
.attr('class', 'grid')
.call(d3.axisLeft(yScale)
.tickSize(-width) // 延伸到整个宽度
.tickFormat('') // 不显示文字
)
.selectAll('line')
.attr('stroke', '#e0e0e0')
.attr('stroke-dasharray', '2,2');
// 移除网格线的轴线
g.select('.grid .domain').remove();
// Y 轴标题
g.append('text')
.attr('class', 'y-axis-title')
.attr('transform', 'rotate(-90)')
.attr('y', -50)
.attr('x', -height / 2)
.attr('text-anchor', 'middle')
.attr('font-size', '12px')
.text('销售额(元)');
// 图表标题
svg.append('text')
.attr('class', 'chart-title')
.attr('x', (width + margin.left + margin.right) / 2)
.attr('y', 25)
.attr('text-anchor', 'middle')
.attr('font-size', '16px')
.attr('font-weight', 'bold')
.text('产品销售数据分析');
这段代码展示了 D3 坐标轴的完整配置:创建坐标轴生成器并调用它;通过 CSS 选择器修改坐标轴元素的样式;添加网格线作为数据读取的辅助参考;添加轴标题和图表标题。
绘制柱状图
使用数据绑定和 enter-update-exit 模式绘制柱状图:
// 创建柱状图组
const bars = g.selectAll('.bar')
.data(salesData, d => d.product) // 键函数确保正确匹配
.join('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.product))
.attr('y', height) // 初始位置在底部
.attr('width', xScale.bandwidth())
.attr('height', 0) // 初始高度为 0
.attr('fill', d => colorScale(d.category))
.attr('opacity', 0.8)
.attr('stroke', '#fff')
.attr('stroke-width', 1);
// 入场动画:从底部向上生长
bars.transition()
.duration(800)
.delay((d, i) => i * 50) // 依次入场
.ease(d3.easeCubicOut)
.attr('y', d => yScale(d.sales))
.attr('height', d => height - yScale(d.sales));
这段代码使用了 join() 方法,它是 D3 v5+ 推荐的数据绑定方式。柱子的初始位置在底部,通过过渡动画向上生长到目标位置。delay 函数让每个柱子依次入场,创造出流畅的动画效果。
添加数值标签
在柱子顶部显示具体的数值:
// 添加数值标签
const labels = g.selectAll('.value-label')
.data(salesData, d => d.product)
.join('text')
.attr('class', 'value-label')
.attr('x', d => xScale(d.product) + xScale.bandwidth() / 2)
.attr('y', height) // 初始位置
.attr('text-anchor', 'middle')
.attr('font-size', '10px')
.attr('fill', '#333')
.text(d => `¥${d.sales.toLocaleString()}`);
// 标签动画
labels.transition()
.duration(800)
.delay((d, i) => i * 50)
.ease(d3.easeCubicOut)
.attr('y', d => yScale(d.sales) - 5);
创建提示框
提示框在鼠标悬停时显示详细信息:
// 创建提示框
const tooltip = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('display', 'none')
.style('background', 'rgba(0, 0, 0, 0.85)')
.style('color', '#fff')
.style('padding', '12px 16px')
.style('border-radius', '6px')
.style('font-size', '13px')
.style('pointer-events', 'none')
.style('z-index', '1000')
.style('box-shadow', '0 2px 10px rgba(0,0,0,0.2)');
添加交互事件
为柱子添加悬停和点击交互:
bars
.on('mouseover', function(event, d) {
// 高亮当前柱子
d3.select(this)
.transition()
.duration(200)
.attr('opacity', 1)
.attr('stroke-width', 2);
// 显示提示框
const growthText = d.growth >= 0
? `<span style="color: #2ecc71">+${d.growth}%</span>`
: `<span style="color: #e74c3c">${d.growth}%</span>`;
tooltip
.style('display', 'block')
.html(`
<div style="font-weight: bold; margin-bottom: 8px; font-size: 14px;">
${d.product}
</div>
<div>类别: ${d.category}</div>
<div>销售额: ¥${d.sales.toLocaleString()}</div>
<div>增长率: ${growthText}</div>
`);
})
.on('mousemove', function(event) {
// 更新提示框位置
tooltip
.style('left', (event.pageX + 15) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mouseout', function() {
// 恢复柱子样式
d3.select(this)
.transition()
.duration(200)
.attr('opacity', 0.8)
.attr('stroke-width', 1);
// 隐藏提示框
tooltip.style('display', 'none');
})
.on('click', function(event, d) {
// 点击时显示详细信息
console.log('点击了:', d.product);
// 可以在这里实现下钻或其他交互
});
这段代码展示了完整的交互实现:mouseover 时高亮柱子并显示提示框;mousemove 时更新提示框位置;mouseout 时恢复样式并隐藏提示框。注意在提示框中使用 HTML 来格式化增长率,正数显示绿色,负数显示红色。
数据更新功能
实际应用中,数据往往需要动态更新。下面实现一个数据更新的函数:
// 数据更新函数
function updateChart(newData) {
// 更新比例尺的定义域
xScale.domain(newData.map(d => d.product));
yScale.domain([0, d3.max(newData, d => d.sales) * 1.1]).nice();
// 更新柱子
const bars = g.selectAll('.bar')
.data(newData, d => d.product);
// Enter:新增的柱子
bars.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.product))
.attr('y', height)
.attr('width', xScale.bandwidth())
.attr('height', 0)
.attr('fill', d => colorScale(d.category))
.attr('opacity', 0.8)
.attr('stroke', '#fff')
.attr('stroke-width', 1)
.merge(bars) // 合并新旧元素
.transition()
.duration(800)
.attr('x', d => xScale(d.product))
.attr('y', d => yScale(d.sales))
.attr('width', xScale.bandwidth())
.attr('height', d => height - yScale(d.sales));
// Exit:移除的柱子
bars.exit()
.transition()
.duration(500)
.attr('y', height)
.attr('height', 0)
.remove();
// 更新坐标轴
g.select('.x-axis')
.transition()
.duration(800)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-45)')
.attr('text-anchor', 'end')
.attr('dx', '-0.5em')
.attr('dy', '0.5em');
g.select('.y-axis')
.transition()
.duration(800)
.call(yAxis);
// 更新网格线
g.select('.grid')
.transition()
.duration(800)
.call(d3.axisLeft(yScale).tickSize(-width).tickFormat(''));
// 更新标签
const labels = g.selectAll('.value-label')
.data(newData, d => d.product);
labels.enter()
.append('text')
.attr('class', 'value-label')
.attr('x', d => xScale(d.product) + xScale.bandwidth() / 2)
.attr('y', height)
.attr('text-anchor', 'middle')
.attr('font-size', '10px')
.attr('fill', '#333')
.text(d => `¥${d.sales.toLocaleString()}`)
.merge(labels)
.transition()
.duration(800)
.attr('x', d => xScale(d.product) + xScale.bandwidth() / 2)
.attr('y', d => yScale(d.sales) - 5);
labels.exit()
.transition()
.duration(500)
.attr('y', height)
.remove();
}
// 示例:更新数据
document.getElementById('update-btn').addEventListener('click', () => {
const newData = salesData.map(d => ({
...d,
sales: Math.floor(d.sales * (0.8 + Math.random() * 0.4)) // 随机变化
}));
updateChart(newData);
});
数据更新遵循标准的 Enter-Update-Exit 模式:Enter 处理新增数据,Update 处理更新数据,Exit 处理删除数据。通过 merge() 方法将新旧元素合并,统一应用过渡动画。
排序功能
添加排序功能可以让用户从不同角度查看数据:
// 排序函数
function sortBars(type) {
let sortedData;
switch(type) {
case 'name':
sortedData = [...salesData].sort((a, b) =>
a.product.localeCompare(b.product)
);
break;
case 'sales-asc':
sortedData = [...salesData].sort((a, b) => a.sales - b.sales);
break;
case 'sales-desc':
sortedData = [...salesData].sort((a, b) => b.sales - a.sales);
break;
default:
sortedData = salesData;
}
// 更新 X 轴比例尺的定义域
xScale.domain(sortedData.map(d => d.product));
// 更新柱子位置
g.selectAll('.bar')
.transition()
.duration(800)
.attr('x', d => xScale(d.product));
// 更新标签位置
g.selectAll('.value-label')
.transition()
.duration(800)
.attr('x', d => xScale(d.product) + xScale.bandwidth() / 2);
// 更新 X 轴
g.select('.x-axis')
.transition()
.duration(800)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-45)')
.attr('text-anchor', 'end')
.attr('dx', '-0.5em')
.attr('dy', '0.5em');
}
// 绑定排序按钮
document.getElementById('sort-name').addEventListener('click', () => sortBars('name'));
document.getElementById('sort-sales-asc').addEventListener('click', () => sortBars('sales-asc'));
document.getElementById('sort-sales-desc').addEventListener('click', () => sortBars('sales-desc'));
排序的关键是更新 X 轴比例尺的定义域,然后通过过渡动画平滑地移动柱子和标签到新位置。
响应式设计
为了让图表适应不同的屏幕尺寸,需要实现响应式设计:
function resize() {
// 获取容器宽度
const containerWidth = document.getElementById('chart').clientWidth;
// 计算新尺寸
const newWidth = containerWidth - margin.left - margin.right;
const newHeight = Math.min(500, newWidth * 0.5) - margin.top - margin.bottom;
// 更新 SVG 尺寸
svg
.attr('width', newWidth + margin.left + margin.right)
.attr('height', newHeight + margin.top + margin.bottom);
// 更新比例尺范围
xScale.range([0, newWidth]);
yScale.range([newHeight, 0]);
// 更新柱子
g.selectAll('.bar')
.attr('x', d => xScale(d.product))
.attr('y', d => yScale(d.sales))
.attr('width', xScale.bandwidth())
.attr('height', d => newHeight - yScale(d.sales));
// 更新坐标轴
g.select('.x-axis')
.attr('transform', `translate(0, ${newHeight})`)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-45)')
.attr('text-anchor', 'end');
g.select('.y-axis')
.call(yAxis);
// 更新网格线
g.select('.grid')
.call(d3.axisLeft(yScale).tickSize(-newWidth).tickFormat(''));
// 更新标签
g.selectAll('.value-label')
.attr('x', d => xScale(d.product) + xScale.bandwidth() / 2)
.attr('y', d => yScale(d.sales) - 5);
}
// 监听窗口大小变化
window.addEventListener('resize', resize);
响应式设计的关键步骤:获取容器的实际宽度;重新计算尺寸和比例尺范围;更新所有图形元素的位置和大小。为了性能考虑,通常会使用防抖(debounce)来减少 resize 事件的触发频率。
完整代码
将所有部分组合起来,完整的图表代码如下:
(function() {
// ========== 数据 ==========
const salesData = [
{ product: 'iPhone 15', category: '电子产品', sales: 8500, growth: 12.5 },
{ product: 'MacBook Pro', category: '电子产品', sales: 5200, growth: 8.3 },
{ product: 'AirPods', category: '电子产品', sales: 12000, growth: 25.0 },
{ product: 'iPad', category: '电子产品', sales: 3800, growth: -5.2 },
{ product: 'Apple Watch', category: '电子产品', sales: 6500, growth: 15.8 },
{ product: 'T恤', category: '服装', sales: 2300, growth: 3.2 },
{ product: '牛仔裤', category: '服装', sales: 1800, growth: -2.1 },
{ product: '运动鞋', category: '服装', sales: 3200, growth: 10.5 }
];
// ========== 尺寸 ==========
const margin = { top: 40, right: 30, bottom: 80, left: 70 };
let width = 900 - margin.left - margin.right;
let height = 500 - margin.top - margin.bottom;
// ========== SVG ==========
const svg = d3.select('#chart')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
const g = svg.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
// ========== 比例尺 ==========
const xScale = d3.scaleBand()
.domain(salesData.map(d => d.product))
.range([0, width])
.padding(0.2);
const yScale = d3.scaleLinear()
.domain([0, d3.max(salesData, d => d.sales) * 1.1])
.range([height, 0])
.nice();
const colorScale = d3.scaleOrdinal()
.domain(['电子产品', '服装'])
.range(['#3498db', '#e74c3c']);
// ========== 坐标轴 ==========
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale)
.tickFormat(d => `¥${(d / 1000).toFixed(0)}k`);
// X 轴
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${height})`)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-45)')
.attr('text-anchor', 'end')
.attr('dx', '-0.5em')
.attr('dy', '0.5em')
.attr('font-size', '11px');
// Y 轴
g.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// 网格线
g.append('g')
.attr('class', 'grid')
.call(d3.axisLeft(yScale).tickSize(-width).tickFormat(''))
.selectAll('line')
.attr('stroke', '#e0e0e0')
.attr('stroke-dasharray', '2,2');
g.select('.grid .domain').remove();
// 标题
svg.append('text')
.attr('class', 'chart-title')
.attr('x', (width + margin.left + margin.right) / 2)
.attr('y', 25)
.attr('text-anchor', 'middle')
.attr('font-size', '16px')
.attr('font-weight', 'bold')
.text('产品销售数据分析');
// ========== 提示框 ==========
const tooltip = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('display', 'none')
.style('background', 'rgba(0, 0, 0, 0.85)')
.style('color', '#fff')
.style('padding', '12px 16px')
.style('border-radius', '6px')
.style('font-size', '13px')
.style('pointer-events', 'none')
.style('z-index', '1000');
// ========== 柱状图 ==========
const bars = g.selectAll('.bar')
.data(salesData, d => d.product)
.join('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.product))
.attr('y', height)
.attr('width', xScale.bandwidth())
.attr('height', 0)
.attr('fill', d => colorScale(d.category))
.attr('opacity', 0.8)
.attr('stroke', '#fff')
.attr('stroke-width', 1);
// 入场动画
bars.transition()
.duration(800)
.delay((d, i) => i * 50)
.ease(d3.easeCubicOut)
.attr('y', d => yScale(d.sales))
.attr('height', d => height - yScale(d.sales));
// 标签
g.selectAll('.value-label')
.data(salesData, d => d.product)
.join('text')
.attr('class', 'value-label')
.attr('x', d => xScale(d.product) + xScale.bandwidth() / 2)
.attr('y', d => yScale(d.sales) - 5)
.attr('text-anchor', 'middle')
.attr('font-size', '10px')
.attr('fill', '#333')
.text(d => `¥${d.sales.toLocaleString()}`);
// ========== 交互 ==========
bars
.on('mouseover', function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr('opacity', 1)
.attr('stroke-width', 2);
const growthText = d.growth >= 0
? `<span style="color: #2ecc71">+${d.growth}%</span>`
: `<span style="color: #e74c3c">${d.growth}%</span>`;
tooltip
.style('display', 'block')
.html(`
<div style="font-weight: bold; margin-bottom: 8px;">${d.product}</div>
<div>类别: ${d.category}</div>
<div>销售额: ¥${d.sales.toLocaleString()}</div>
<div>增长率: ${growthText}</div>
`);
})
.on('mousemove', function(event) {
tooltip
.style('left', (event.pageX + 15) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mouseout', function() {
d3.select(this)
.transition()
.duration(200)
.attr('opacity', 0.8)
.attr('stroke-width', 1);
tooltip.style('display', 'none');
});
})();
总结
这个综合示例展示了 D3.js 可视化的完整开发流程:数据准备是基础,决定了图表能展示什么;比例尺是核心,建立了数据到视觉的映射;坐标轴提供参考框架,帮助理解数据;图形元素是最终呈现,需要精心设计;过渡动画增强体验,让变化可视化;交互功能赋予生命,让用户能够探索数据。
掌握这个综合示例后,你可以举一反三,创建其他类型的图表。无论是折线图、饼图还是散点图,核心思路都是相同的:数据 → 比例尺 → 图形 → 交互。D3 的强大之处在于它提供了构建这些步骤的灵活工具,而不是限制你的创造力。