跳转至

事件、拖拽与刷选 — 交互的地基

这一章讲什么(上半): 让图响应用户,第一步不是「监听事件」,而是把屏幕坐标翻译成图的坐标; 第二步是管好事件流(冒泡、穿透、默认行为);然后才轮到 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. 可带走的

  1. d3.pointer(event, node) 一键把屏幕坐标变局部坐标——交互第一行代码;
  2. 夹层结构三件事:stopPropagation 截冒泡、pointer-events:none 做穿透、preventDefault 拦默认;
  3. 行为三件套:造对象、on 挂语义事件、call 附元素;事件对象带 type/target/sourceEvent;
  4. 拖拽用 dx/dy 做相对移动(配 CTM),或直接写回 {x,y} 数据;scaled 数据必须 scale.invert 反算;
  5. 拖数据点 = 改数据 + 改自己 + 重算兄弟图形;line 图拖点的三段式代码直接可搬;
  6. Canvas 拖拽 = 自定义 subject 反查 + 数组置顶 + 整图重绘;
  7. 刷选先写 isValid()(null/sourceEvent/零面积三查);brushX/brushY 选一维区间;
  8. extent 限刷区、move 程序摆刷、clear 清刷;
  9. 框选缩放 = invert 反算 domain → 过渡更新轴与点 → clear;
  10. 刷选与缩放/拖拽共存:修饰键 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)」)

  1. 出处:「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).」 ↩

  2. 出处:「Events and Interactivity」第 112–161 段(text/21-ch14-chapter-14-events-and-interactivity.txt:121,搜「event propagation」;:151,搜「stopPropagation()」;:161,搜「stopImmediatePropagation()」)。 ↩

  3. 出处:「Events and Interactivity」第 163–189 段(text/21-ch14-chapter-14-events-and-interactivity.txt:169,搜「pointer-events", "none"」;:188,搜「fill and stroke」)。 ↩

  4. 出处:「Events and Interactivity」第 191–207 段(text/21-ch14-chapter-14-events-and-interactivity.txt:204,搜「on("wheel"」)。 ↩

  5. 出处:「Events and Interactivity」第 210–232 段(text/21-ch14-chapter-14-events-and-interactivity.txt:218,搜「selection.dispatch」;:229,搜「not covered in this book」)。 ↩↩

  6. 出处:「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 转写。 ↩

  7. 出处:「Events and Interactivity」第 285–376 段(text/21-ch14-chapter-14-events-and-interactivity.txt:359,搜「x and y」;:361,搜「dx and dy」;:363,搜「subject」)。 ↩

  8. 出处:「Events and Interactivity」第 341–352 段(text/21-ch14-chapter-14-events-and-interactivity.txt:345,搜「grabbing」)。 ↩

  9. 出处:「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)」)。 ↩

  10. 出处:「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")」)。 ↩

  11. 出处:「Events and Interactivity」第 525–598 段(text/21-ch14-chapter-14-events-and-interactivity.txt:556,搜「getSubject」;:560,搜「polygonContains」;:581,搜「subject(getSubject)」;:572,搜「reorder」)。 ↩↩

  12. 出处:「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.」 ↩↩

  13. 出处:「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)」)。 ↩

  14. 出处:「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"」)。 ↩

  15. 出处:「Events and Interactivity」第 853–905 段(text/21-ch14-chapter-14-events-and-interactivity.txt:884,搜「rescale([start, end])」;:893,搜「updateView()」;:898,搜「brush.clear」)。 ↩

  16. 出处:「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%")」)。 ↩

  17. 出处:「Events and Interactivity」第 959–975 段(text/21-ch14-chapter-14-events-and-interactivity.txt:964,搜「filter(evt => evt.shiftKey)」)。 ↩↩