跳到主要内容

缩放与刷选交互

在数据可视化中,缩放和刷选是两种最常用的交互方式。缩放让用户能够放大查看细节或缩小查看全局;刷选则允许用户通过框选来筛选数据范围。这两种交互结合使用,能够极大提升用户探索数据的能力。

缩放交互(d3-zoom)

d3-zoom 模块提供了一套完整的缩放和平移功能。它支持鼠标滚轮缩放、拖拽平移、触摸手势(捏合缩放)等多种输入方式,并且能够优雅地处理各种浏览器兼容性问题。

缩放基础

缩放本质上是一个二维变换矩阵,包含三个参数:平移量 xy,以及缩放比例 k。当我们对元素应用缩放变换时,元素的坐标系会发生改变。

在 SVG 中,缩放变换通常通过 transform 属性实现:

// 变换矩阵形式
// | k 0 tx |
// | 0 k ty |
// | 0 0 1 |

// SVG transform 属性语法
// transform="translate(tx, ty) scale(k)"

创建缩放行为

// 创建缩放行为
const zoom = d3.zoom()
.scaleExtent([0.5, 8]) // 缩放范围:最小 0.5 倍,最大 8 倍
.translateExtent([[0, 0], [width, height]]) // 平移范围限制
.on('zoom', zoomed); // 缩放事件回调

// 应用到 SVG
const svg = d3.select('#chart')
.append('svg')
.attr('width', width)
.attr('height', height);

// 创建内容组
const g = svg.append('g');

// 应用缩放行为
svg.call(zoom);

// 缩放事件处理函数
function zoomed(event) {
const { transform } = event;
g.attr('transform', transform);
}

这段代码的核心流程是:创建缩放行为并配置参数;将其应用到 SVG 元素(通过 selection.call());在缩放事件回调中,将变换应用到内容组。

缩放事件的三个阶段

缩放行为会触发三种事件:

start:缩放开始时触发(如鼠标按下或滚轮开始滚动)。

zoom:缩放进行中持续触发(如拖拽移动或滚轮滚动)。

end:缩放结束时触发(如鼠标释放或滚轮停止)。

const zoom = d3.zoom()
.on('start', () => console.log('缩放开始'))
.on('zoom', zoomed)
.on('end', () => console.log('缩放结束'));

缩放范围控制

scaleExtent:限制缩放比例的范围。

zoom.scaleExtent([0.5, 8]);  // 最小缩小到 0.5 倍,最大放大到 8 倍

translateExtent:限制平移范围,防止内容移出可视区域。

// 限制在画布范围内
zoom.translateExtent([[0, 0], [width, height]]);

// 或者设置更大的范围(允许部分内容移出)
zoom.translateExtent([[-width, -height], [width * 2, height * 2]]);

extent:设置视口范围,影响缩放中心点和插值计算。

zoom.extent([[0, 0], [width, height]]);

程序化控制缩放

除了用户交互,也可以通过代码控制缩放:

// 获取当前变换状态
const transform = d3.zoomTransform(svg.node());
console.log(transform.x, transform.y, transform.k);

// 重置到初始状态
svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity);

// 缩放到指定比例(以画布中心为基准)
svg.transition().duration(750).call(zoom.scaleTo, 2);

// 相对缩放(在当前基础上放大 2 倍)
svg.transition().duration(750).call(zoom.scaleBy, 2);

// 平移到指定位置
svg.transition().duration(750).call(zoom.translateTo, x, y);

// 平移一定距离
svg.transition().duration(750).call(zoom.translateBy, 100, 50);

缩放与坐标轴联动

当图表有坐标轴时,缩放后坐标轴也需要相应更新:

// 创建比例尺
const xScale = d3.scaleLinear()
.domain([0, 100])
.range([0, width]);

const yScale = d3.scaleLinear()
.domain([0, 100])
.range([height, 0]);

// 创建初始坐标轴
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);

const gx = svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(xAxis);

const gy = svg.append('g')
.call(yAxis);

// 缩放事件处理
function zoomed(event) {
// 方法一:重新计算比例尺的值域
const newXScale = event.transform.rescaleX(xScale);
const newYScale = event.transform.rescaleY(yScale);

// 更新坐标轴
gx.call(xAxis.scale(newXScale));
gy.call(yAxis.scale(newYScale));

// 更新数据点位置(使用新的比例尺)
circles
.attr('cx', d => newXScale(d.x))
.attr('cy', d => newYScale(d.y));

// 方法二:直接应用变换到内容组
// g.attr('transform', event.transform);
}

rescaleXrescaleY 方法会返回新的比例尺,其定义域已经根据缩放变换进行了调整。这种方法比直接变换整个内容组更精确,特别是对于需要精确刻度的图表。

过滤缩放输入

使用 filter 方法可以控制哪些输入事件能够触发缩放:

const zoom = d3.zoom()
.filter(event => {
// 默认过滤器:忽略 Ctrl 键按下时的鼠标事件,忽略右键
return !event.ctrlKey && !event.button;
});

// 禁用双击缩放
svg.call(zoom)
.on('dblclick.zoom', null);

// 只允许滚轮缩放,禁用拖拽平移
svg.call(zoom)
.on('mousedown.zoom', null)
.on('touchstart.zoom', null);

自定义滚轮缩放速度

const zoom = d3.zoom()
.wheelDelta(event => {
// 默认值:滚轮每次滚动缩放 10%(按住 Ctrl 则 100%)
// 返回正值表示放大,负值表示缩小
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : 0.002);
});

缩放动画

使用过渡动画可以让缩放更加平滑:

// 平滑缩放到指定变换
svg.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity.translate(100, 50).scale(2));

// 缩放到特定元素
function zoomToElement(element) {
const bounds = element.getBoundingClientRect();
const dx = bounds.width / 2;
const dy = bounds.height / 2;
const x = bounds.left + dx;
const y = bounds.top + dy;

svg.transition()
.duration(750)
.call(zoom.translateTo, x, y);
}

刷选交互(d3-brush)

刷选(Brushing)是一种通过拖拽创建矩形选区的交互方式。它广泛应用于数据筛选、区域选择、缩放上下文等场景。

创建刷选

D3 提供了三种刷选类型:

brush:二维刷选,可选择矩形区域。

brushX:一维水平刷选,只能选择 X 轴方向的范围。

brushY:一维垂直刷选,只能选择 Y 轴方向的范围。

// 创建二维刷选
const brush = d3.brush()
.extent([[0, 0], [width, height]]) // 刷选范围
.on('start brush end', brushed);

// 创建一维刷选
const brushX = d3.brushX()
.extent([[0, 0], [width, height]])
.on('brush', brushedX);

// 添加刷选层
svg.append('g')
.attr('class', 'brush')
.call(brush);

刷选事件

刷选行为会触发三种事件:

start:开始刷选时触发(鼠标按下)。

brush:刷选中触发(拖拽移动)。

end:刷选结束时触发(鼠标释放)。

const brush = d3.brush()
.on('start', () => console.log('开始刷选'))
.on('brush', brushed)
.on('end', () => console.log('刷选结束'));

function brushed(event) {
if (!event.selection) return; // 没有选区

// 获取选区范围
const [[x0, y0], [x1, y1]] = event.selection;

// 处理选中的数据
const selected = data.filter(d =>
xScale(d.x) >= x0 && xScale(d.x) <= x1 &&
yScale(d.y) >= y0 && yScale(d.y) <= y1
);

// 高亮选中的数据点
circles.attr('fill', d =>
selected.includes(d) ? 'red' : 'steelblue'
);
}

刷选范围

刷选范围(selection)是一个二维数组:

  • 二维刷选:[[x0, y0], [x1, y1]]
  • 一维 X 刷选:[x0, x1]
  • 一维 Y 刷选:[y0, y1]

程序化控制刷选

// 设置刷选范围
svg.select('.brush')
.call(brush.move, [[100, 100], [200, 200]]);

// 清除刷选
svg.select('.brush')
.call(brush.clear);

// 获取当前刷选范围
const selection = d3.brushSelection(svg.select('.brush').node());

刷选与数据筛选

下面是一个完整的刷选筛选示例:

// 散点图数据
const data = Array.from({length: 100}, (_, i) => ({
x: Math.random() * 100,
y: Math.random() * 100,
category: ['A', 'B', 'C'][Math.floor(Math.random() * 3)]
}));

// 创建比例尺
const xScale = d3.scaleLinear()
.domain([0, 100])
.range([margin.left, width - margin.right]);

const yScale = d3.scaleLinear()
.domain([0, 100])
.range([height - margin.bottom, margin.top]);

// 绘制散点图
const circles = svg.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 5)
.attr('fill', d => colorScale(d.category))
.attr('opacity', 0.7);

// 创建刷选
const brush = d3.brush()
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]])
.on('brush end', brushed);

svg.append('g')
.attr('class', 'brush')
.call(brush);

function brushed(event) {
const selection = event.selection;

if (!selection) {
// 没有选区时,恢复所有点的样式
circles.attr('opacity', 0.7);
return;
}

const [[x0, y0], [x1, y1]] = selection;

// 判断每个点是否在选区内
circles.attr('opacity', d => {
const cx = xScale(d.x);
const cy = yScale(d.y);
return x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1 ? 1 : 0.2;
});
}

双向刷选:概览与详细

一种常见的模式是创建两个关联的图表:一个小型概览图和一个大型详细图。在概览图上刷选时,详细图会放大显示选中的区域。

// 概览图
const overviewSvg = d3.select('#overview')
.append('svg')
.attr('width', 200)
.attr('height', 100);

// 详细图
const detailSvg = d3.select('#detail')
.append('svg')
.attr('width', 800)
.attr('height', 400);

// 刷选事件处理
const brush = d3.brushX()
.extent([[0, 0], [200, 100]])
.on('brush end', function(event) {
if (!event.selection) return;

const [x0, x1] = event.selection;

// 将刷选范围转换为数据范围
const domain = [
overviewXScale.invert(x0),
overviewXScale.invert(x1)
];

// 更新详细图的 X 比例尺
detailXScale.domain(domain);

// 重绘详细图
drawDetailChart();
});

overviewSvg.append('g')
.attr('class', 'brush')
.call(brush);

刷选样式定制

刷选的外观可以通过 CSS 进行定制:

/* 选区填充色 */
.brush .selection {
fill: steelblue;
fill-opacity: 0.3;
stroke: steelblue;
stroke-width: 1;
}

/* 刷选手柄 */
.brush .handle {
fill: white;
stroke: #333;
stroke-width: 1;
}

/* 刷选区域覆盖层 */
.brush .overlay {
fill: none;
pointer-events: all;
}

过滤刷选输入

与缩放类似,可以过滤刷选的输入事件:

const brush = d3.brush()
.filter(event => {
// 只响应左键点击
return !event.ctrlKey && !event.button;
});

// 禁用按键修饰符(Alt、Space 等特殊效果)
const brush = d3.brush()
.keyModifiers(false);

自定义手柄大小

const brush = d3.brush()
.handleSize(10); // 手柄大小,默认为 6

缩放与刷选结合

缩放和刷选可以结合使用,创造更丰富的交互体验。

刷选后缩放

一种常见模式是:刷选一个区域,然后将视图缩放到该区域:

const brush = d3.brush()
.on('end', function(event) {
if (!event.selection) return;

const [[x0, y0], [x1, y1]] = event.selection;

// 清除刷选
d3.select(this).call(brush.clear);

// 计算新的缩放变换
const dx = x1 - x0;
const dy = y1 - y0;
const k = Math.min(width / dx, height / dy);
const tx = (width / 2 - k * (x0 + x1) / 2);
const ty = (height / 2 - k * (y0 + y1) / 2);

// 应用缩放
svg.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
});

缩放时保持刷选

当缩放与刷选同时使用时,需要注意事件冲突:

// 在缩放时禁用刷选,在刷选时禁用缩放
let isBrushing = false;

const brush = d3.brush()
.on('start', () => { isBrushing = true; })
.on('end', () => { isBrushing = false; });

const zoom = d3.zoom()
.filter(event => !isBrushing);

完整示例:交互式散点图

下面是一个结合缩放和刷选的完整示例:

function createInteractiveScatterplot() {
// 数据
const data = Array.from({length: 500}, (_, i) => ({
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 10 + 2,
category: ['A', 'B', 'C', 'D'][Math.floor(Math.random() * 4)]
}));

// 尺寸
const margin = {top: 30, right: 30, bottom: 40, left: 50};
const width = 800 - 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})`);

// 比例尺
const xScale = d3.scaleLinear()
.domain([0, 100])
.range([0, width]);

const yScale = d3.scaleLinear()
.domain([0, 100])
.range([height, 0]);

const colorScale = d3.scaleOrdinal()
.domain(['A', 'B', 'C', 'D'])
.range(d3.schemeCategory10);

// 坐标轴
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);

g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${height})`)
.call(xAxis);

g.append('g')
.attr('class', 'y-axis')
.call(yAxis);

// 绘制数据点
const circles = g.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', d => d.size)
.attr('fill', d => colorScale(d.category))
.attr('fill-opacity', 0.7)
.attr('stroke', 'white')
.attr('stroke-width', 1);

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

circles.on('mouseover', function(event, d) {
tooltip
.style('display', 'block')
.html(`X: ${d.x.toFixed(1)}<br/>Y: ${d.y.toFixed(1)}<br/>类别: ${d.category}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mouseout', () => tooltip.style('display', 'none'));

// 创建缩放行为
const zoom = d3.zoom()
.scaleExtent([0.5, 10])
.on('zoom', function(event) {
const newXScale = event.transform.rescaleX(xScale);
const newYScale = event.transform.rescaleY(yScale);

// 更新坐标轴
g.select('.x-axis').call(xAxis.scale(newXScale));
g.select('.y-axis').call(yAxis.scale(newYScale));

// 更新数据点
circles
.attr('cx', d => newXScale(d.x))
.attr('cy', d => newYScale(d.y));
});

svg.call(zoom);

// 创建刷选行为
const brush = d3.brush()
.extent([[0, 0], [width, height]])
.on('end', function(event) {
if (!event.selection) return;

const [[x0, y0], [x1, y1]] = event.selection;

// 高亮选中的点
const newXScale = d3.zoomTransform(svg.node()).rescaleX(xScale);
const newYScale = d3.zoomTransform(svg.node()).rescaleY(yScale);

circles.attr('fill-opacity', d => {
const cx = newXScale(d.x);
const cy = newYScale(d.y);
return x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1 ? 1 : 0.1;
});

// 清除刷选
g.select('.brush').call(brush.clear);
});

const brushG = g.append('g')
.attr('class', 'brush')
.call(brush);

// 控制按钮
d3.select('#reset').on('click', () => {
svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
circles.attr('fill-opacity', 0.7);
});
}

总结

缩放和刷选是数据可视化中最强大的两种交互方式。缩放让用户能够从宏观视角切换到微观视角,探索数据的细节;刷选则让用户能够精确地筛选感兴趣的数据区域。

在实际应用中,这两种交互往往结合使用。比如在时间序列图表中,用户可以在概览图上刷选时间范围,然后在详细图中缩放查看具体的数据点。理解这两种交互的原理和实现方式,是构建高质量交互式可视化的基础。

记住,好的交互设计应该是无感的——用户不需要思考如何操作,交互方式应该自然地引导用户探索数据。过多的交互控件或复杂的操作方式反而会分散用户对数据本身的注意力。