跳到主要内容

折线图与面积图

折线图是最适合展示连续数据变化趋势的可视化形式。与柱状图相比,折线图能够更清晰地呈现数据的波动、上升或下降趋势。在金融图表、时间序列分析、监控系统等场景中,折线图都是首选的展示方式。

理解折线图的绘制原理很重要:它将数据点用线段依次连接,形成一条连续的曲线。每个数据点代表一个具体的观测值,而线段的斜率则反映了数据变化的速率。

d3.line() 生成器详解

d3-shape 模块中的 d3.line() 函数是绘制折线图的核心工具。它是一个生成器函数,接收一个数据数组,返回对应的 SVG path 数据字符串。

基础用法

// 准备时间序列数据
const data = [
{date: new Date('2024-01-01'), value: 100},
{date: new Date('2024-01-02'), value: 120},
{date: new Date('2024-01-03'), value: 115},
{date: new Date('2024-01-04'), value: 130},
{date: new Date('2024-01-05'), value: 145}
];

// 创建线段生成器
const lineGenerator = d3.line()
.x(d => xScale(d.date)) // 设置 x 坐标映射
.y(d => yScale(d.value)); // 设置 y 坐标映射

// 生成 path 数据的 d 属性值
const pathData = lineGenerator(data);
// 输出类似: "M0,100L100,80L200,85L300,70L400,55"
// 每个命令格式是: M(移动到)x,y L(线段到)x,y

创建线段生成器后,只需要把数据传进去,就能自动生成完整的 SVG path 字符串。

曲线类型

默认情况下,d3.line() 生成的是折线(相邻点之间用直线连接)。通过 curve() 方法可以指定不同的曲线类型,让折线更加平滑:

// 直线(默认)
d3.line().curve(d3.curveLinear)

// 猫爪曲线(最常用)- 在数据点之间创建平滑曲线
d3.line().curve(d3.curveCatmullRom)

// 阶梯曲线 - 用于离散数据
d3.line().curve(d3.curveStep)

// CSS 风格曲线(类似贝塞尔)
d3.line().curve(d3.curveBasis)

各个曲线类型的视觉效果对比如下:curveLinear 生成的是最简单的折线,适合离散数据点;curveCatmullRom 会经过每一个数据点生成平滑曲线,是最常用的选择;curveStep 生成阶梯状线条,适合展示阶段性变化的数据;curveBasis 生成平滑曲线但不一定经过所有数据点。

水平曲线生成器

d3.curveStep 实际上有两种变体,curveStepPrepend 和 curveStepBefore 的区别在于阶梯的转折点位置不同:

// 普通阶梯
d3.line().curve(d3.curveStep)

// 阶梯从左侧开始
d3.line().curve(d3.curveStepBefore)

// 阶梯从右侧开始
d3.line().curve(d3.curveStepAfter)

完整折线图示例

让我们通过一个完整示例来理解折线图的构建过程。需要展示公司一个月内的每日活跃用户数趋势。

// 生成示例数据
const data = Array.from({length: 30}, (_, i) => ({
date: new Date(2024, 0, i + 1),
users: Math.floor(Math.random() * 500 + 1000) // 1000-1500 之间的随机数
}));

// 画布尺寸
const margin = {top: 30, right: 30, bottom: 50, left: 60};
const width = 800 - margin.left - margin.right;
const height = 400 - 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)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);

// 创建比例尺
const xScale = d3.scaleTime()
.domain(d3.extent(data, d => d.date)) // 日期范围
.range([0, width]); // 像素范围

const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.users) * 1.1]) // 留出 10% 上边距
.range([height, 0]);

// 创建线段生成器
const lineGenerator = d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.users))
.curve(d3.curveCatmullRom); // 使用平滑曲线

// 添加网格线(可选,提升可读性)
const yAxisGrid = d3.axisLeft(yScale)
.tickSize(-width)
.tickFormat('');

svg.append('g')
.attr('class', 'grid')
.call(yAxisGrid)
.selectAll('line')
.attr('stroke', '#e0e0e0')
.attr('stroke-dasharray', '3,3');

svg.selectAll('.grid .domain').remove();

// 绘制折线
svg.append('path')
.datum(data) // 用 datum 绑定整个数组而不是 data
.attr('fill', 'none')
.attr('stroke', 'steelblue')
.attr('stroke-width', 2.5)
.attr('d', lineGenerator);

// 添加 X 轴
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(xScale)
.ticks(7) // 大约每 4 天一个刻度
.tickFormat(d3.timeFormat('%m/%d')))
.selectAll('text')
.attr('font-size', '12px');

// 添加 Y 轴
svg.append('g')
.call(d3.axisLeft(yScale).tickFormat(d => `${d}`))
.selectAll('text')
.attr('font-size', '12px');

// 添加数据点(可选,增强交互)
svg.selectAll('.dot')
.data(data)
.join('circle')
.attr('class', 'dot')
.attr('cx', d => xScale(d.date))
.attr('cy', d => yScale(d.users))
.attr('r', 4)
.attr('fill', 'steelblue')
.attr('stroke', 'white')
.attr('stroke-width', 2);

这段代码展示了折线图的完整构建过程:创建时间和数值比例尺、定义线段生成器并指定曲线类型、绘制网格线作为辅助参考、添加折线路径和刻度、最后添加数据点以增强视觉效果。

关于 datum 和 data 的区别:data() 是将数组中的单个元素分配给选择集中的元素,而 datum() 是将整个数据对象(可以是数组也可以是单个对象)绑定到选中的元素。如果选择集只有一个元素(如这里),使用 datum() 将整个数组作为单一数据源绑定是更简洁的做法。

面积图

面积图是折线图的增强版,通过在折线下方填充颜色来强调数据的总量。与折线图相比,面积图能够同时展示数据的总量和变化趋势,特别适合展示累积效果。

基础面积图

生成面积图只需要使用 d3.area() 函数,并指定基线位置:

// 创建面积生成器
const areaGenerator = d3.area()
.x(d => xScale(d.date))
.y0(height) // 基线(底部)位置,固定在图表底部
.y1(d => yScale(d.users)) // 顶部(数据值对应的高度)
.curve(d3.curveCatmullRom);

// 绘制面积
svg.append('area')
.datum(data)
.attr('fill', 'steelblue')
.attr('fill-opacity', 0.3) // 半透明填充
.attr('stroke', 'none')
.attr('d', areaGenerator);

渐变面积图

想让面积图更美观,可以使用 SVG 渐变填充:

// 定义渐变
const gradient = svg.append('defs')
.append('linearGradient')
.attr('id', 'area-gradient')
.attr('x1', '0%')
.attr('y1', '0%')
.attr('x2', '0%')
.attr('y2', '100%');

gradient.append('stop')
.attr('offset', '0%')
.attr('stop-color', 'steelblue')
.attr('stop-opacity', 0.6);

gradient.append('stop')
.attr('offset', '100%')
.attr('stop-color', 'steelblue')
.attr('stop-opacity', 0.1);

// 使用渐变
svg.append('path')
.datum(data)
.attr('fill', 'url(#area-gradient)')
.attr('d', areaGenerator);

通过调整渐变的起始和结束透明度,可以创造出从深到浅的视觉效果,引导用户的视觉焦点放在数据量较大的区域。

多折线图

有时需要在一个图表中展示多条折线进行对比,比如同时展示多个产品的销售趋势。多折线图在实现上稍有不同。

并列图例的多折线图

// 产品销售数据
const data = [
{date: new Date('2024-01-01'), productA: 100, productB: 80, productC: 60},
{date: new Date('2024-01-02'), productA: 120, productB: 90, productC: 70},
{date: new Date('2024-01-03'), productA: 115, productB: 85, productC: 75},
{date: new Date('2024-01-04'), productA: 130, productB: 100, productC: 80},
{date: new Date('2024-01-05'), productA: 145, productB: 110, productC: 85}
];

// 产品配置(颜色、名称)
const products = [
{key: 'productA', name: '产品 A', color: '#3498db'},
{key: 'productB', name: '产品 B', color: '#e74c3c'},
{key: 'productC', name: '产品 C', color: '#2ecc71'}
];

products.forEach(product => {
// 为每个产品创建独立的生成器
const lineGenerator = d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d[product.key]))
.curve(d3.curveCatmullRom);

svg.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', product.color)
.attr('stroke-width', 2)
.attr('d', lineGenerator);
});

// 添加图例
const legend = svg.selectAll('.legend')
.data(products)
.join('g')
.attr('class', 'legend')
.attr('transform', (d, i) => `translate(${width - 100},${i * 20})`);

legend.append('rect')
.attr('width', 15)
.attr('height', 15)
.attr('fill', d => d.color);

legend.append('text')
.attr('x', 20)
.attr('y', 12)
.text(d => d.name)
.attr('font-size', '12px');

对于多折线图,关键是理解数据结构的设计。上述示例采用了「宽表」格式,每条记录包含所有产品在同一时间点的数值。另一种「长表」格式是每条记录只有一个产品的数据,长表格式在数据绑定时更加直观。

长表格式的数据处理

如果数据是长表格式,需要先将其转换为宽表格式,或者为每条产品线单独绑定数据:

// 长表格式数据
const longData = [
{date: new Date('2024-01-01'), product: 'A', sales: 100},
{date: new Date('2024-01-01'), product: 'B', sales: 80},
{date: new Date('2024-01-01'), product: 'C', sales: 60},
// ...
];

// 方法一:按产品筛选数据
const productALines = longData.filter(d => d.product === 'A');
const productBLines = longData.filter(d => d.product === 'B');
// ...

选择哪种数据格式取决于你的数据来源和个人偏好。如果使用 d3.csv() 加载数据后又需要进行格式转换,可以使用 d3.group() 和 d3.rollup() 进行分组聚合。

交互式折线图

折线图的交互功能主要集中在三个方面:鼠标悬停时显示数据详情、点击折线时聚焦或筛选其他数据、通过缩放和平移探索数据细节。

悬停提示功能

最基础也是最有用的交互是鼠标悬停时显示数据详情:

// 添加交互层矩形(用于捕获鼠标事件)
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', 'transparent')
.on('mousemove', onMouseMove)
.on('mouseleave', onMouseLeave);

// 提示框
const tooltip = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('display', 'none')
.style('background', 'rgba(0,0,0,0.7)')
.style('color', '#fff')
.style('padding', '8px 12px')
.style('border-radius', '4px')
.style('font-size', '12px')
.style('pointer-events', 'none');

// 垂直参考线
const verticalLine = svg.append('line')
.attr('stroke', '#999')
.attr('stroke-dasharray', '3,3')
.attr('y1', 0)
.attr('y2', height)
.style('display', 'none');

// 数据点高亮
const focusDot = svg.append('circle')
.attr('r', 5)
.attr('fill', 'steelblue')
.attr('stroke', 'white')
.attr('stroke-width', 2)
.style('display', 'none');

function onMouseMove(event) {
// 获取鼠标 x 坐标
const [mx] = d3.pointer(event);

// 根据 x 坐标反推对应的日期
const date = xScale.invert(mx);

// 找到最近的数据点(使用 bisector)
const bisect = d3.bisector(d => d.date).left;
const index = bisect(data, date);
const d = data[Math.min(index, data.length - 1)];

// 更新提示框位置和内容
tooltip
.style('display', 'block')
.style('left', `${event.pageX + 15}px`)
.style('top', `${event.pageY - 28}px`)
.html(`
<strong>日期:</strong>${d3.timeFormat('%Y-%m-%d')(d.date)}<br/>
<strong>用户数:</strong>${d.users}
`);

// 更新参考线和焦点圆
verticalLine
.style('display', 'block')
.attr('x1', xScale(d.date))
.attr('x2', xScale(d.date));

focusDot
.style('display', 'block')
.attr('cx', xScale(d.date))
.attr('cy', yScale(d.users));
}

function onMouseLeave() {
tooltip.style('display', 'none');
verticalLine.style('display', 'none');
focusDot.style('display', 'none');
}

这段代码实现了一个完整的鼠标交互系统:使用 bisector(一种二分查找算法)根据鼠标的 x 坐标找到最近的数据点;使用 xScale.invert() 方法将像素坐标转换回数据值;动态显示提示框、参考线和高亮数据点。要让代码正常运行,需要先定义好 xScale、yScale 和 data 等变量,确保它们在交互函数的作用域内可以访问。

总结

折线图是数据可视化中最基础也最重要的图表类型之一。D3 提供了 d3.line() 生成器来创建折线路径,支持多种曲线类型以满足不同的视觉需求。面积图作为折线图的扩展,在展示累积数据时特别有用。多折线图允许在同一图表中进行数据对比,而交互功能则让用户能够深入探索数据细节。

掌握折线图的绘制方法后,你会发现其他类型的图表(饼图、散点图等)的实现思路有很多相通之处。核心仍然是:准备好数据、创建比例尺、生成图形路径、添加坐标轴和必要时的交互。