事件、拖拽与刷选 — 交互的地基
事件、拖拽与刷选 — 交互的地基
这一章讲什么(上半): 让图响应用户,第一步不是「监听事件」,而是把屏幕坐标翻译成图的坐标; 第二步是管好事件流(冒泡、穿透、默认行为);然后才轮到 D3 的两个「行为」封装—— 拖拽(d3-drag)和刷选(d3-brush)。它们把「按下-移动-抬起」这类多阶段手势 收拢成 start/drag(brush)/end 三个语义事件。缩放(d3-zoom)是本章的另一半,见下一篇。
1. 顶层全景:交互的三层
③ 行为层:d3.drag(拖拽)/ d3.brush(框选)/ d3.zoom(缩放,下一篇)
└ 把多阶段手势翻译成语义事件,event 对象带现成数据(坐标/区间/变换)
② 事件流层:冒泡 stopPropagation · 穿透 pointer-events:none · 默认行为 preventDefault
① 坐标层:屏幕坐标(clientX/Y)→ 局部坐标 →(可选)数据坐标(scale.invert)
└ 一行解决:d3.pointer(event, node)
图说:每层解决一类麻烦;层数越高,代码越少,但底层永远在兜底。2. 坐标:先搞清你点的是哪儿
事件的 clientX/clientY 是相对浏览器窗口的绝对坐标,而 SVG/Canvas 有自己的局部坐标系——
直接用绝对坐标画图,点会偏。三种解法,复杂度递增1:
① 减偏移:getBoundingClientRect() 取元素左上角,做减法
够用条件:元素没有被缩放/旋转
② 逆矩阵:SVG 的 getScreenCTM().inverse() 把屏幕点映回局部坐标
任何变换下都对,但代码长
③ d3.pointer(event, node):一行版,效果同 ②,node 默认是当前事件目标
const [x, y] = d3.pointer(evt); // 点击处的 SVG 局部坐标3. 事件流:冒泡、穿透、默认行为
D3 的图表常见「SVG 叠在 Canvas 上」的夹层结构,事件管理因此重要:
- 冒泡:内层元素的事件默认向外层传。
evt.stopPropagation()截断; 同一元素上的多个处理器用stopImmediatePropagation()互斥2。 - 穿透:
svg.style("pointer-events", "none")让整个 SVG 层对指针事件隐身, 事件直落底下的 Canvas;SVG 里还能用fill/stroke值让「只有描边部分响应」3。 - 默认行为:
evt.preventDefault()拦默认动作——滚轮缩放页面、表单提交、链接跳转; 可缩放的图表第一件事就是on("wheel", evt => evt.preventDefault())4。
自定义事件两件套(正文只给目录):selection.dispatch('名字', {detail}) 从元素发事件;
d3.dispatch('a','b') 建与 DOM 无关的事件总线——d3 内部用它实现行为,仓库 Dispatching/ 有完整教程5。
4. 行为对象:统一的玩法
三个行为模块同一张配方:造行为对象 → on() 挂语义事件 → call() 附到选择器。
事件对象统一带 type、target(行为对象自己)、sourceEvent(底层原生事件)6:
const dragObject = d3.drag().on("drag", e => console.log(e.x, e.y));
circle.call(dragObject); // ← 行为必须 call 到元素上才生效5. 拖拽 d3-drag
5.1 最小拖拽与其事件属性
const drag = d3.drag().on('drag', function(e) {
d3.select(this).attr("transform", `translate(${[e.x, e.y]})`);
});
svg.selectAll("*").call(drag);拖拽事件对象的四件套7:
| 属性 | 含义 |
|---|---|
x, y | 指针在容器坐标系下的当前位置(= d3.pointer 的结果) |
dx, dy | 与上一帧的位移差——相对移动靠它累加 |
subject | 被拖的东西:默认是绑定的 datum,没绑数据则是 {x,y} 对象 |
拖拽时的鼠标形态惯例:'start' 里 cursor = grabbing,'end' 里还原8。
5.2 三种移动模式(主线例子)
原书用一个圆逐步升级,正好把三种模式各归其位9:
模式 A:无数据,用 dx/dy 累加 CTM(getCTM 的 e/f 是 translate 的 x/y)
const ctm = this.getCTM();
const [x,y] = [ctm.e + evt.dx, ctm.f + evt.dy];
d3.select(this).attr("transform", `translate(${[x,y]})`);
模式 B:绑了 {x,y} 数据,直接写回数据再渲染(数据驱动的拖法)
.on('drag', function(evt, d) { [d.x, d.y] = [evt.x, evt.y]; update(); });
模式 C:数据是「未缩放」的(比如 {x:1, y:1} 对应屏幕中心),指针坐标要 invert 回去
d.x = evt.subject.x + scale.x.invert(evt.x);
// subject() 先存拖拽起点,invert 把像素反算成数据值——和比例尺的 invert 一节呼应5.3 拖图表里的点:改数据、改自己、改兄弟
线图上拖数据点是综合演练:'start' 里 raise() 父组置顶;'drag' 里三改:
改绑定的数据(d[1] = scale.y.invert(evt.y))、改自己(cy)、改兄弟 <path class="line">
的 d(用第 11 章的 line 函数重算)——数据永远是第一改动对象,DOM 是它的投影10。
5.4 Canvas 拖拽:subject 反查
Canvas 没有元素可绑数据,被拖对象要自己找。原书的 getSubject:
从后往前(findLastIndex,后画的在上层)找包含点击点的最顶层矩形——
d3.polygonContains(矩形四角, d3.pointer(evt)) 做点在多边形内的判定;
找到后 reorder 数组把被拖对象挪到最后(= 置顶),drag 里累加 dx/dy 后整图重绘11。
6. 刷选 d3-brush
6.1 最小刷选与有效性检查
const brush = d3.brush().on("brush", evt => {
if (!isValid(evt)) return; // ← 三查,缺了就崩
const [start, end] = evt.selection;
});
container.call(brush); // 挂到图表上方的一个 <g> 层原书给的 isValid() 值得整个抄进你的工具箱:selection 为 null(刷选刚清除)、
无 sourceEvent(程序性 move 触发)、选区为零面积——三种「假刷选」都返回 false12。
刷型的选择:d3.brush() 二维区域,d3.brushX()/brushY() 一维区间
(时间轴选段、直方图选桶)。extent() 限定可刷区域(一般是图区、不含轴);
move(选区, 区间) 程序化摆刷,clear(选区) 清刷13。
6.2 主线例子:一万个点的散点框选
数据:一万个 [x,y] 正态分布点(均值 50、标准差 15),x/y 线性尺 + clamp。
三步:刷动时 inBrush(selection, d) 判定每个点的屏幕坐标是否落入选区、
选中的点染红;'start' 事件里全体复位蓝色;'end' 里 filter 出选中集、报数量14:
function inBrush(selection, d) {
const [[x0,y0],[x1,y1]] = selection;
const [cx,cy] = [xScale(d[0]), yScale(d[1])];
return x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1;
}
brush.on("brush", function(evt) {
if (!isValid(evt)) return;
d3.selectAll("circle").style("fill", d => inBrush(evt.selection, d) ? "red" : "blue");
});6.3 框选缩放:从选区到新的 domain
刷选最经典的归宿——松手后把选区变成新视野。核心两行:scale.invert 把像素区间反算成
数据区间,重设 domain,然后过渡更新轴和点,顺手清刷15:
function rescale([start, end]) {
xScale.domain([xScale.invert(start[0]), xScale.invert(end[0])]);
yScale.domain([yScale.invert(end[1]), yScale.invert(start[1])]); // y 反装所以端点交换
}
function updateView() {
d3.select(".x-axis").transition().call(xAxis);
container.selectAll("circle").data(d => d).join("circle")
.transition().attr("cx", d => xScale(d[0])).attr("cy", d => yScale(d[1]));
container.call(brush.clear); // 或把选区 move 到角落隐藏
}6.4 一维刷与组合冲突
直方图选桶是 brushX 的好例子:brushed = (bin, sel) => xScale(bin.x0) >= sel[0] && xScale(bin.x1) <= sel[1]
判桶整体落入区间,选中桶染红,顺手算「选中了百分之几的数据」(d3.format(".2%"))16。
刷选与其他手势冲突时:filter(evt => evt.shiftKey).keyModifiers(false) ——
只有按住 Shift 才激活刷选,把无修饰键的场景让给缩放或拖拽17。
7. 作者的判断与证据
| 判断 | 性质 | 依据 |
|---|---|---|
| 「Canvas 拖拽必须自写 subject 反查」 | 平台限制 | Canvas 无 DOM 元素可绑数据11 |
| 「isValid 三查是必备品」 | 防御性实践 | 原书建议放进可复用模块12 |
| 「brush/zoom 共存用修饰键分流」 | 组合策略 | filter + keyModifiers 的官方姿势17 |
8. 边界与局限
- d3.dispatch 事件总线正文明确不展开(「complex topic」),只有仓库示例5。
- 触摸/多点手势的底层细节由行为内部处理,本书不拆。
apply()/invert()变换点换算属于下一篇(缩放)的工具,本章仅在 Extras 指路。- 刷选外观定制(CSS 手柄、选区样式)只在 Extras 给了文件路径。
9. 可带走的
d3.pointer(event, node)一键把屏幕坐标变局部坐标——交互第一行代码;- 夹层结构三件事:stopPropagation 截冒泡、pointer-events:none 做穿透、preventDefault 拦默认;
- 行为三件套:造对象、on 挂语义事件、call 附元素;事件对象带 type/target/sourceEvent;
- 拖拽用
dx/dy做相对移动(配 CTM),或直接写回{x,y}数据;scaled 数据必须scale.invert反算; - 拖数据点 = 改数据 + 改自己 + 重算兄弟图形;line 图拖点的三段式代码直接可搬;
- Canvas 拖拽 = 自定义 subject 反查 + 数组置顶 + 整图重绘;
- 刷选先写
isValid()(null/sourceEvent/零面积三查);brushX/brushY 选一维区间; extent限刷区、move程序摆刷、clear清刷;- 框选缩放 = invert 反算 domain → 过渡更新轴与点 → clear;
- 刷选与缩放/拖拽共存:修饰键 filter 分流。
10. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| d3.pointer 与坐标换算 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:76(搜「getBoundingClientRect」) · text/21-ch14-chapter-14-events-and-interactivity.txt:95(搜「getScreenCTM」) · text/21-ch14-chapter-14-events-and-interactivity.txt:100(搜「d3.pointer(event,node)」) |
| 冒泡与 stopPropagation | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:121(搜「event bubbling」) · text/21-ch14-chapter-14-events-and-interactivity.txt:152(搜「stopPropagation()」) |
| pointer-events 穿透 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:164(搜「pointer-events」) · text/21-ch14-chapter-14-events-and-interactivity.txt:169(搜「pointer-events", "none"」) |
| preventDefault | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:194(搜「preventDefault()」) · text/21-ch14-chapter-14-events-and-interactivity.txt:204(搜「on("wheel"」) |
| dispatch 工具表 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:210(搜「Dispatching custom events」) |
| 行为总表与配方 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:238(搜「Using interactive behaviors」) · text/21-ch14-chapter-14-events-and-interactivity.txt:255(搜「d3.drag()」) |
| 拖拽事件属性 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:326(搜「on(types,listener)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:359(搜「x and y」) |
| CTM 拖拽与 scaled 数据 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:406(搜「getCTM()」) · text/21-ch14-chapter-14-events-and-interactivity.txt:468(搜「subject((evt,d)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:480(搜「scale.x.invert(ctm.e」) |
| 线图拖点 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:512(搜「const drag = d3.drag()」) · text/21-ch14-chapter-14-events-and-interactivity.txt:520(搜「select(".line")」) |
| Canvas subject 拖拽 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:556(搜「getSubject」) · text/21-ch14-chapter-14-events-and-interactivity.txt:560(搜「polygonContains」) · text/21-ch14-chapter-14-events-and-interactivity.txt:581(搜「subject(getSubject)」) |
| brush 函数与 isValid | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:627(搜「d3.brush()」) · text/21-ch14-chapter-14-events-and-interactivity.txt:637(搜「isValid(evt)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:645(搜「d3.brushX()」) |
| brush 配置与事件 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:671(搜「move(group,selection)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:718(搜「selection」) |
| 散点框选 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:736(搜「d3.range(10000)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:785(搜「inBrush(selection, d)」) |
| 框选缩放 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:884(搜「rescale([start, end])」) · text/21-ch14-chapter-14-events-and-interactivity.txt:893(搜「updateView()」) |
| 一维刷与修饰键分流 | Events and Interactivity | text/21-ch14-chapter-14-events-and-interactivity.txt:930(搜「d3.brushX()」) · text/21-ch14-chapter-14-events-and-interactivity.txt:964(搜「filter(evt => evt.shiftKey)」) |
Footnotes
-
出处:「Events and Interactivity」第 64–110 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:76,搜「getBoundingClientRect」;:90,搜「inverse screen CTM」;:100,搜「d3.pointer(event,node)」)。原文:「D3 replaces all the code in the examples above with a single function: d3.pointer(event,node).」 ↩ -
出处:「Events and Interactivity」第 112–161 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:121,搜「event propagation」;:151,搜「stopPropagation()」;:161,搜「stopImmediatePropagation()」)。 ↩ -
出处:「Events and Interactivity」第 163–189 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:169,搜「pointer-events", "none"」;:188,搜「fill and stroke」)。 ↩ -
出处:「Events and Interactivity」第 191–207 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:204,搜「on("wheel"」)。 ↩ -
出处:「Events and Interactivity」第 210–232 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:218,搜「selection.dispatch」;:229,搜「not covered in this book」)。 ↩ ↩2 -
出处:「Events and Interactivity」第 238–283 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:255,搜「Drag and drop」;:264,搜「sourceEvent」;:271,搜「call(dragObject)」)。行为表照原书 Table 14.3 转写。 ↩ -
出处:「Events and Interactivity」第 285–376 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:359,搜「x and y」;:361,搜「dx and dy」;:363,搜「subject」)。 ↩ -
出处:「Events and Interactivity」第 341–352 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:345,搜「grabbing」)。 ↩ -
出处:「Events and Interactivity」第 378–487 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:406,搜「getCTM()」;:386,搜「[d.x, d.y] = [evt.x, evt.y]」;:468,搜「subject((evt,d)」;:470,搜「scale.x.invert(evt.x)」)。 ↩ -
出处:「Events and Interactivity」第 494–523 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:512,搜「const drag = d3.drag()」;:517,搜「scale.y.invert(evt.y)」;:520,搜「select(".line")」)。 ↩ -
出处:「Events and Interactivity」第 525–598 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:556,搜「getSubject」;:560,搜「polygonContains」;:581,搜「subject(getSubject)」;:572,搜「reorder」)。 ↩ ↩2 -
出处:「Events and Interactivity」第 621–642 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:637,搜「isValid(evt)」)。原文:「Place it in a module you can import since you will reuse it frequently.」 ↩ ↩2 -
出处:「Events and Interactivity」第 644–711 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:649,搜「one-dimensional brush」;:668,搜「move(group,selection)」;:680,搜「extent(extent)」)。 ↩ -
出处:「Events and Interactivity」第 731–830 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:736,搜「d3.range(10000)」;:774,搜「inBrush」;:785,搜「inBrush(selection, d)」;:802,搜「brush.on("end"」)。 ↩ -
出处:「Events and Interactivity」第 853–905 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:884,搜「rescale([start, end])」;:893,搜「updateView()」;:898,搜「brush.clear」)。 ↩ -
出处:「Events and Interactivity」第 909–958 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:930,搜「d3.brushX()」;:941,搜「brushed = (bin, sel)」;:951,搜「d3.format(".2%")」)。 ↩ -
出处:「Events and Interactivity」第 959–975 段(
text/21-ch14-chapter-14-events-and-interactivity.txt:964,搜「filter(evt => evt.shiftKey)」)。 ↩ ↩2